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 01/80] [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 02/80] [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 03/80] [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

::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 04/80] [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 05/80] [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 06/80] [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 07/80] [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 08/80] [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 09/80] [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 10/80] [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 11/80] [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 12/80] [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 13/80] [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 14/80] [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 15/80] [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 16/80] [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 17/80] [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 18/80] [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 19/80] [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 20/80] [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 21/80] [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 22/80] [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 23/80] [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 24/80] [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 25/80] [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 26/80] [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 27/80] [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 28/80] [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 29/80] [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 30/80] [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 31/80] [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 32/80] [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 33/80] [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 34/80] [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 35/80] [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 36/80] 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 37/80] [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 38/80] [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 39/80] [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 2d6e3f42e634422cb22d8116a994e42bf8666114 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:48:07 -1000 Subject: [PATCH 40/80] Fold the update flag into the flush, inline the rotation, write a buffered block straight out when not updating --- esphome/components/mipi_dsi/mipi_dsi.cpp | 45 +++++-------------- esphome/components/mipi_dsi/mipi_dsi.h | 25 +++++++++-- esphome/components/mipi_rgb/mipi_rgb.cpp | 43 ++++-------------- esphome/components/mipi_rgb/mipi_rgb.h | 23 ++++++++-- esphome/components/mipi_spi/mipi_spi.h | 3 -- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 2 - esphome/components/st7701s/st7701s.cpp | 2 - 7 files changed, 61 insertions(+), 82 deletions(-) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 9fddc62a2e7..192772d7699 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -183,7 +183,6 @@ void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } - // Blocks drawn by the writer stay in the buffer until the one flush below this->updating_ = true; if (this->show_test_card_) { this->test_card(); @@ -198,14 +197,9 @@ void MipiDsi::update() { this->flush_dirty_(); } -// Outside update() nothing else would flush, inside it the end of update() does. -void MipiDsi::flush_now_() { - if (!this->updating_) - this->flush_dirty_(); -} - void MipiDsi::flush_dirty_() { - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + // 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; @@ -223,9 +217,7 @@ 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; - // Copy as is only when the bytes are laid out the way draw_pixel_at() writes them; an RGB - // ordered little endian 888 block is b,g,r in memory. Anything else, or a block drawn under a clipping - // region, goes per pixel. + // An RGB ordered little endian 888 block is b,g,r in memory, as the BGR colour mode writes it const bool same_layout = bitness == display::COLOR_BITNESS_565 ? big_endian == this->big_endian_ : big_endian == (this->color_mode_ == display::COLOR_ORDER_RGB); @@ -234,12 +226,11 @@ void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 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_now_(); + 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 - // Only the part on the panel 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) @@ -251,8 +242,12 @@ void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 ptr + ((y_offset + y) * stride + x_offset + first_col) * bytes_per_pixel, (end_col - first_col) * bytes_per_pixel); } - this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); - this->flush_now_(); + 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); @@ -300,26 +295,6 @@ bool MipiDsi::check_buffer_() { return true; } -// Maps drawn coordinates to the buffer the way draw_pixel_at() stores them. -void MipiDsi::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; - } -} - // 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; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 948c013a979..20acf02966b 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -7,6 +7,8 @@ #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" #include "esphome/core/helpers.h" + +#include #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/gpio.h" @@ -88,8 +90,25 @@ class MipiDsi final : public display::Display { bool check_buffer_(); /// Send the region touched since the last flush to the panel. void flush_dirty_(); - void flush_now_(); - void rotate_(int &x, int &y) const; + // 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 { @@ -118,6 +137,7 @@ class MipiDsi final : public display::Display { display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; display::ColorBitness color_depth_; bool big_endian_{false}; + bool updating_{false}; uint8_t pixel_mode_{}; esp_lcd_panel_handle_t handle_{}; @@ -125,7 +145,6 @@ class MipiDsi final : public display::Display { esp_lcd_panel_io_handle_t io_handle_{}; SemaphoreHandle_t io_lock_{}; uint8_t *buffer_{nullptr}; - bool updating_{false}; uint16_t x_low_{1}; uint16_t y_low_{1}; uint16_t x_high_{0}; diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 8b0a938932b..2bc300e5b82 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -188,7 +188,6 @@ void MipiRgb::update() { if (this->auto_clear_enabled_) { this->clear(); } - // Blocks drawn by the writer stay in the buffer until the one flush below this->updating_ = true; if (this->show_test_card_) { this->test_card(); @@ -203,14 +202,9 @@ void MipiRgb::update() { this->flush_dirty_(); } -// Outside update() nothing else would flush, inside it the end of update() does. -void MipiRgb::flush_now_() { - if (!this->updating_) - this->flush_dirty_(); -} - void MipiRgb::flush_dirty_() { - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + // 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; @@ -228,16 +222,13 @@ 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; - // RGB565 in the configured byte order at rotation 0 copies as is; anything else, or a block drawn under a - // clipping region, goes per pixel 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->mark_dirty_(x_start, y_start, w, h); - this->flush_now_(); + 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 - // Only the part on the panel 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) @@ -247,8 +238,12 @@ void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 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); } - this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); - this->flush_now_(); + 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); } @@ -291,26 +286,6 @@ bool MipiRgb::check_buffer_() { return true; } -// Maps drawn coordinates to the buffer the way draw_pixel_at() stores them. -void MipiRgb::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; - } -} - // 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; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 5c6d310b986..b2d9e06c007 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -80,8 +80,25 @@ class MipiRgb : public display::Display { } /// Send the region touched since the last flush to the panel. void flush_dirty_(); - void flush_now_(); - void rotate_(int &x, int &y) const; + // 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_(); @@ -104,11 +121,11 @@ class MipiRgb : public display::Display { 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}; std::vector enable_pins_{}; - bool updating_{false}; uint16_t x_low_{1}; uint16_t y_low_{1}; uint16_t x_high_{0}; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 3122cffb2f0..ece4b2c6ff0 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -671,15 +671,12 @@ class MipiSpiBuffer } protected: - // Convert per pixel into the buffer 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); } - // Rotate the coordinates to match the display orientation. - // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { if constexpr (BUFFERPIXEL == PIXEL_MODE_8) { diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c38d54516be..9f92a850f55 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -51,8 +51,6 @@ 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; - // Big endian RGB565 at rotation 0 copies as is; anything else, or a block drawn under a clipping region, goes - // per pixel 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, diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 1009f36940e..756473dae71 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -54,8 +54,6 @@ 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; - // Big endian RGB565 at rotation 0 copies as is; anything else, or a block drawn under a clipping region, goes - // per pixel 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, From 46df48930dd2280294b32399697d6f6b5c7ad8db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:51:25 -1000 Subject: [PATCH 41/80] Put the utility include with the system headers and name clipping in the blit contract --- esphome/components/display/display.h | 5 +++-- esphome/components/mipi_dsi/mipi_dsi.h | 4 ++-- esphome/components/mipi_rgb/mipi_rgb.h | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index bb35402b92d..f0f32adbe9c 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -364,8 +364,9 @@ class Display : public PollingComponent { * * 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 or software - * rotation through this base version, which draws per pixel. Callers keep the block on screen. + * 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/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 20acf02966b..04d8791b01c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -7,8 +7,6 @@ #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" #include "esphome/core/helpers.h" - -#include #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/gpio.h" @@ -20,6 +18,8 @@ #include "esp_lcd_mipi_dsi.h" +#include + namespace esphome::mipi_dsi { ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index b2d9e06c007..393e052b26b 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -6,6 +6,7 @@ #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 From ac74796263f8e5d4b1f6b657f2128c3599511440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:52:13 -1000 Subject: [PATCH 42/80] [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 43/80] [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 44/80] 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 45/80] 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 46/80] [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 47/80] [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 48/80] 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 49/80] [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 50/80] [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 51/80] 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 52/80] [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 53/80] [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 54/80] [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 55/80] [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 56/80] [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 57/80] [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 58/80] [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 59/80] [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 60/80] [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 61/80] [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 62/80] [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 63/80] [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 64/80] [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 65/80] [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 66/80] [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 67/80] [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 68/80] [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 69/80] [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 70/80] [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 71/80] [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 72/80] [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 73/80] [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 74/80] [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 75/80] [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 76/80] [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 77/80] [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 78/80] [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 79/80] [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 80/80] [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));