From 3be29eab35709dc428046df941067cd2f292578e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:14:21 -0400 Subject: [PATCH 01/37] [tests] Cover untested entity platforms, automations and platform-gated code (#21506) Co-authored-by: J. Nick Koston --- .../addressable_light/common-ard-fastled.yaml | 32 ---------- .../common-idf-esp32_rmt_led_strip.yaml | 30 --------- ...d-esp32_rmt_led_strip.yaml => common.yaml} | 6 +- .../esp32_rmt_led_strip.esp32-c3-idf.yaml | 4 -- .../esp32_rmt_led_strip.esp32-idf.yaml | 4 -- .../addressable_light/test.esp32-idf.yaml | 5 ++ tests/components/api/common-base.yaml | 4 ++ tests/components/api/test.rtl87xx-ard.yaml | 6 ++ tests/components/ballu/common.yaml | 4 ++ tests/components/binary/common.yaml | 17 ++++++ tests/components/binary/test.esp32-idf.yaml | 6 ++ tests/components/binary/test.esp8266-ard.yaml | 6 ++ tests/components/binary/test.rp2040-ard.yaml | 6 ++ tests/components/ble_client/common.yaml | 34 +++++++++++ tests/components/bmi270/common.yaml | 14 +++++ tests/components/cap1188/common.yaml | 6 ++ tests/components/copy/common.yaml | 61 +++++++++++++++++++ .../display/common-page-change.yaml | 8 +++ tests/components/esp32/test.esp32-idf.yaml | 1 + tests/components/esp32_ble/common.yaml | 1 + .../esp32_can/test.esp32-p4-idf.yaml | 6 ++ .../esp32_can/test.esp32-s3-idf.yaml | 6 ++ tests/components/esp32_dac/common.yaml | 2 +- .../components/esp32_dac/test.esp32-idf.yaml | 6 +- .../esp32_dac/test.esp32-s2-idf.yaml | 5 ++ tests/components/ezo/common.yaml | 12 ++++ tests/components/factory_reset/common.yaml | 6 ++ tests/components/fan/common-triggers.yaml | 9 +++ tests/components/libretiny/common.yaml | 4 ++ .../components/libretiny/test.bk72xx-ard.yaml | 3 + .../components/libretiny/test.ln882x-ard.yaml | 3 + .../libretiny/test.rtl87xx-ard.yaml | 3 + tests/components/ltr501/common.yaml | 4 ++ tests/components/ltr_als_ps/common.yaml | 4 ++ tests/components/lvgl/common.yaml | 27 ++++++++ tests/components/max6956/common.yaml | 6 ++ tests/components/mics_5524/common.yaml | 6 ++ .../components/modbus_controller/common.yaml | 3 + tests/components/nextion/common.yaml | 1 + tests/components/nextion/test.bk72xx-ard.yaml | 3 + tests/components/ota/common.yaml | 3 + tests/components/output/common.yaml | 11 ++++ tests/components/pn7150_i2c/common.yaml | 6 ++ tests/components/pn7160_i2c/common.yaml | 6 ++ tests/components/rc522_spi/common.yaml | 5 ++ .../safe_mode/test-enabled.nrf52-mcumgr.yaml | 29 +++++++++ tests/components/sdl/common.yaml | 6 ++ tests/components/sim800l/common.yaml | 14 +++++ tests/components/sx126x/common.yaml | 7 +++ tests/components/sx127x/common.yaml | 7 +++ tests/components/sx1509/common.yaml | 6 ++ .../tinyusb_keyboard/test.esp32-p4-idf.yaml | 2 + tests/components/tm1637/common.yaml | 7 +++ tests/components/tuya/common.yaml | 14 +++++ .../usb_host/test.esp32-p4-idf.yaml | 11 ++++ .../usb_uart/test.esp32-p4-idf.yaml | 2 + .../voice_assistant/common-idf.yaml | 14 +++++ tests/components/wifi/test.esp32-idf.yaml | 2 + tests/components/wled/test.bk72xx-ard.yaml | 16 +++++ .../common/i2c_camera/esp32-idf.yaml | 4 ++ 60 files changed, 471 insertions(+), 75 deletions(-) delete mode 100644 tests/components/addressable_light/common-ard-fastled.yaml delete mode 100644 tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml rename tests/components/addressable_light/{common-ard-esp32_rmt_led_strip.yaml => common.yaml} (86%) delete mode 100644 tests/components/addressable_light/esp32_rmt_led_strip.esp32-c3-idf.yaml delete mode 100644 tests/components/addressable_light/esp32_rmt_led_strip.esp32-idf.yaml create mode 100644 tests/components/addressable_light/test.esp32-idf.yaml create mode 100644 tests/components/api/test.rtl87xx-ard.yaml create mode 100644 tests/components/binary/common.yaml create mode 100644 tests/components/binary/test.esp32-idf.yaml create mode 100644 tests/components/binary/test.esp8266-ard.yaml create mode 100644 tests/components/binary/test.rp2040-ard.yaml create mode 100644 tests/components/esp32_can/test.esp32-p4-idf.yaml create mode 100644 tests/components/esp32_can/test.esp32-s3-idf.yaml create mode 100644 tests/components/esp32_dac/test.esp32-s2-idf.yaml create mode 100644 tests/components/libretiny/common.yaml create mode 100644 tests/components/nextion/test.bk72xx-ard.yaml create mode 100644 tests/components/safe_mode/test-enabled.nrf52-mcumgr.yaml create mode 100644 tests/components/tinyusb_keyboard/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_host/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_uart/test.esp32-p4-idf.yaml create mode 100644 tests/components/wled/test.bk72xx-ard.yaml diff --git a/tests/components/addressable_light/common-ard-fastled.yaml b/tests/components/addressable_light/common-ard-fastled.yaml deleted file mode 100644 index 77a5c5fe124..00000000000 --- a/tests/components/addressable_light/common-ard-fastled.yaml +++ /dev/null @@ -1,32 +0,0 @@ -light: - - platform: fastled_clockless - id: led_matrix_32x8 - name: led_matrix_32x8 - chipset: WS2812B - pin: ${pin} - num_leds: 256 - rgb_order: GRB - default_transition_length: 0s - color_correct: [50%, 50%, 50%] - -display: - - platform: addressable_light - id: led_matrix_32x8_display - addressable_light_id: led_matrix_32x8 - width: 32 - height: 8 - pixel_mapper: |- - if (x % 2 == 0) { - return (x * 8) + y; - } - return (x * 8) + (7 - y); - lambda: |- - Color red = Color(0xFF0000); - Color green = Color(0x00FF00); - Color blue = Color(0x0000FF); - it.rectangle(0, 0, it.get_width(), it.get_height(), red); - it.rectangle(1, 1, it.get_width()-2, it.get_height()-2, green); - it.rectangle(2, 2, it.get_width()-4, it.get_height()-4, blue); - it.rectangle(3, 3, it.get_width()-6, it.get_height()-6, red); - rotation: 0° - update_interval: 16ms diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml deleted file mode 100644 index d21c4b61b96..00000000000 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ /dev/null @@ -1,30 +0,0 @@ -light: - - platform: esp32_rmt_led_strip - id: led_matrix_32x8 - default_transition_length: 500ms - chipset: ws2812 - channel_colors: GRB - num_leds: 256 - pin: ${pin} - -display: - - platform: addressable_light - id: led_matrix_32x8_display - addressable_light_id: led_matrix_32x8 - width: 32 - height: 8 - pixel_mapper: |- - if (x % 2 == 0) { - return (x * 8) + y; - } - return (x * 8) + (7 - y); - lambda: |- - Color red = Color(0xFF0000); - Color green = Color(0x00FF00); - Color blue = Color(0x0000FF); - it.rectangle(0, 0, it.get_width(), it.get_height(), red); - it.rectangle(1, 1, it.get_width()-2, it.get_height()-2, green); - it.rectangle(2, 2, it.get_width()-4, it.get_height()-4, blue); - it.rectangle(3, 3, it.get_width()-6, it.get_height()-6, red); - rotation: 0° - update_interval: 16ms diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common.yaml similarity index 86% rename from tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml rename to tests/components/addressable_light/common.yaml index d21c4b61b96..b7e2c51c354 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common.yaml @@ -1,6 +1,6 @@ light: - platform: esp32_rmt_led_strip - id: led_matrix_32x8 + id: addressable_light_matrix default_transition_length: 500ms chipset: ws2812 channel_colors: GRB @@ -9,8 +9,8 @@ light: display: - platform: addressable_light - id: led_matrix_32x8_display - addressable_light_id: led_matrix_32x8 + id: addressable_light_display + addressable_light_id: addressable_light_matrix width: 32 height: 8 pixel_mapper: |- diff --git a/tests/components/addressable_light/esp32_rmt_led_strip.esp32-c3-idf.yaml b/tests/components/addressable_light/esp32_rmt_led_strip.esp32-c3-idf.yaml deleted file mode 100644 index ca2c244d805..00000000000 --- a/tests/components/addressable_light/esp32_rmt_led_strip.esp32-c3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO2 - -<<: !include common-idf-esp32_rmt_led_strip.yaml diff --git a/tests/components/addressable_light/esp32_rmt_led_strip.esp32-idf.yaml b/tests/components/addressable_light/esp32_rmt_led_strip.esp32-idf.yaml deleted file mode 100644 index ca2c244d805..00000000000 --- a/tests/components/addressable_light/esp32_rmt_led_strip.esp32-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO2 - -<<: !include common-idf-esp32_rmt_led_strip.yaml diff --git a/tests/components/addressable_light/test.esp32-idf.yaml b/tests/components/addressable_light/test.esp32-idf.yaml new file mode 100644 index 00000000000..03ab65a07fa --- /dev/null +++ b/tests/components/addressable_light/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + pin: GPIO2 + +packages: + addressable_light: !include common.yaml diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 67f5ef62428..9d15307ccea 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -32,6 +32,10 @@ esphome: action: notify.html5 data: message: Button was pressed + - homeassistant.service: + service: notify.html5 + data: + message: Button was pressed - homeassistant.tag_scanned: pulse - homeassistant.action: action: weather.get_forecasts diff --git a/tests/components/api/test.rtl87xx-ard.yaml b/tests/components/api/test.rtl87xx-ard.yaml new file mode 100644 index 00000000000..bd0915f0ef0 --- /dev/null +++ b/tests/components/api/test.rtl87xx-ard.yaml @@ -0,0 +1,6 @@ +packages: + api: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/ballu/common.yaml b/tests/components/ballu/common.yaml index 178c39b8ce0..77c1b2f1943 100644 --- a/tests/components/ballu/common.yaml +++ b/tests/components/ballu/common.yaml @@ -7,3 +7,7 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + + - platform: ballu + name: Ballu Climate + transmitter_id: xmitr diff --git a/tests/components/binary/common.yaml b/tests/components/binary/common.yaml new file mode 100644 index 00000000000..2df89e8e857 --- /dev/null +++ b/tests/components/binary/common.yaml @@ -0,0 +1,17 @@ +output: + - platform: gpio + id: binary_fan_output + pin: ${fan_pin} + - platform: gpio + id: binary_light_output + pin: ${light_pin} + +fan: + - platform: binary + name: Binary Fan + output: binary_fan_output + +light: + - platform: binary + name: Binary Light + output: binary_light_output diff --git a/tests/components/binary/test.esp32-idf.yaml b/tests/components/binary/test.esp32-idf.yaml new file mode 100644 index 00000000000..5e4efc84f1a --- /dev/null +++ b/tests/components/binary/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + fan_pin: GPIO12 + light_pin: GPIO13 + +packages: + binary: !include common.yaml diff --git a/tests/components/binary/test.esp8266-ard.yaml b/tests/components/binary/test.esp8266-ard.yaml new file mode 100644 index 00000000000..5e4efc84f1a --- /dev/null +++ b/tests/components/binary/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + fan_pin: GPIO12 + light_pin: GPIO13 + +packages: + binary: !include common.yaml diff --git a/tests/components/binary/test.rp2040-ard.yaml b/tests/components/binary/test.rp2040-ard.yaml new file mode 100644 index 00000000000..5e4efc84f1a --- /dev/null +++ b/tests/components/binary/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + fan_pin: GPIO12 + light_pin: GPIO13 + +packages: + binary: !include common.yaml diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ed6ad7fc93..a92a95fd7c3 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -31,6 +31,10 @@ ble_client: - ble_client.numeric_comparison_reply: id: test_blec accept: false + on_passkey_notification: + - logger.log: + format: "Passkey %u" + args: [passkey] sensor: - platform: ble_client @@ -52,6 +56,11 @@ sensor: name: "BLE Sensor without Lambda" service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1237-abcd-1234-abcd-abcd12345678" + notify: true + on_notify: + - logger.log: + format: "Notify %.2f" + args: [x] number: - platform: template @@ -74,3 +83,28 @@ button: value: !lambda |- uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; + - ble_client.disconnect: test_blec + - ble_client.connect: test_blec + - ble_client.remove_bond: + id: test_blec + +switch: + - platform: ble_client + ble_client_id: test_blec + name: BLE Client Enabled + +output: + - platform: ble_client + id: ble_client_output + ble_client_id: test_blec + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1238-abcd-1234-abcd-abcd12345678" + require_response: true + +text_sensor: + - platform: ble_client + ble_client_id: test_blec + name: BLE Text Sensor + service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" + characteristic_uuid: "abcd1239-abcd-1234-abcd-abcd12345678" + notify: true diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 390def04a15..11f3f0019a1 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -95,3 +95,17 @@ motion: x: y y: x z: -z + +button: + - platform: template + name: Calibrate Motion + on_press: + - motion.calibrate_level: + id: bmi270_motion + save: true + on_success: + - logger.log: Level calibrated + on_error: + - logger.log: Level calibration failed + - motion.calibrate_heading: + id: bmi270_motion diff --git a/tests/components/cap1188/common.yaml b/tests/components/cap1188/common.yaml index 3e4ed972ffc..f7bf4c19644 100644 --- a/tests/components/cap1188/common.yaml +++ b/tests/components/cap1188/common.yaml @@ -5,3 +5,9 @@ cap1188: reset_pin: ${reset_pin} touch_threshold: 0x20 allow_multiple_touches: true + +binary_sensor: + - platform: cap1188 + cap1188_id: cap1188_component + name: CAP1188 Touch Key 0 + channel: 0 diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a3e7f7f13db..3e5d1cdd393 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -32,3 +32,64 @@ button: - platform: copy source_id: copy_source_button name: Button Copy + +binary_sensor: + - platform: template + id: copy_source_binary_sensor + lambda: return true; + - platform: copy + source_id: copy_source_binary_sensor + name: Binary Sensor Copy + +cover: + - platform: template + id: copy_source_cover + optimistic: true + - platform: copy + source_id: copy_source_cover + name: Cover Copy + +lock: + - platform: template + id: copy_source_lock + optimistic: true + - platform: copy + source_id: copy_source_lock + name: Lock Copy + +number: + - platform: template + id: copy_source_number + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + - platform: copy + source_id: copy_source_number + name: Number Copy + +switch: + - platform: template + id: copy_source_switch + optimistic: true + - platform: copy + source_id: copy_source_switch + name: Switch Copy + +text: + - platform: template + id: copy_source_text + optimistic: true + mode: text + - platform: copy + source_id: copy_source_text + name: Text Copy + mode: text + +text_sensor: + - platform: template + id: copy_source_text_sensor + lambda: return {"hello"}; + - platform: copy + source_id: copy_source_text_sensor + name: Text Sensor Copy diff --git a/tests/components/display/common-page-change.yaml b/tests/components/display/common-page-change.yaml index 649cad767be..1d18213a447 100644 --- a/tests/components/display/common-page-change.yaml +++ b/tests/components/display/common-page-change.yaml @@ -24,3 +24,11 @@ display: to: display_page_change_page2 then: lambda: 'ESP_LOGD("display", "1 -> 2, %p %p", from, to);' + +button: + - platform: template + name: Show Page + on_press: + - display.page.show: display_page_change_page1 + - display.page.show: + id: !lambda return id(display_page_change_page2); diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 5c7cb1d61b8..076503edea5 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -4,6 +4,7 @@ esp32: type: esp-idf advanced: enable_ota_rollback: true + ignore_efuse_custom_mac: true enable_lwip_mdns_queries: true enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index b2114f5dc41..004c1f39262 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,5 +1,6 @@ esp32_ble: io_capability: keyboard_only + advertising: true button: - platform: template diff --git a/tests/components/esp32_can/test.esp32-p4-idf.yaml b/tests/components/esp32_can/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..7f66a86deae --- /dev/null +++ b/tests/components/esp32_can/test.esp32-p4-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +packages: + esp32_can: !include common.yaml diff --git a/tests/components/esp32_can/test.esp32-s3-idf.yaml b/tests/components/esp32_can/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..c1b6f06c114 --- /dev/null +++ b/tests/components/esp32_can/test.esp32-s3-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + esp32_can: !include common.yaml diff --git a/tests/components/esp32_dac/common.yaml b/tests/components/esp32_dac/common.yaml index 225627f5af9..a5c17d582cb 100644 --- a/tests/components/esp32_dac/common.yaml +++ b/tests/components/esp32_dac/common.yaml @@ -1,4 +1,4 @@ output: - platform: esp32_dac id: dac_output - pin: 25 + pin: ${dac_pin} diff --git a/tests/components/esp32_dac/test.esp32-idf.yaml b/tests/components/esp32_dac/test.esp32-idf.yaml index dade44d145b..a7b784bc12a 100644 --- a/tests/components/esp32_dac/test.esp32-idf.yaml +++ b/tests/components/esp32_dac/test.esp32-idf.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +substitutions: + dac_pin: 25 + +packages: + esp32_dac: !include common.yaml diff --git a/tests/components/esp32_dac/test.esp32-s2-idf.yaml b/tests/components/esp32_dac/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..06180636414 --- /dev/null +++ b/tests/components/esp32_dac/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + dac_pin: 17 + +packages: + esp32_dac: !include common.yaml diff --git a/tests/components/ezo/common.yaml b/tests/components/ezo/common.yaml index 79afbb8aa64..dfe809497b5 100644 --- a/tests/components/ezo/common.yaml +++ b/tests/components/ezo/common.yaml @@ -4,3 +4,15 @@ sensor: id: ph_ezo address: 99 unit_of_measurement: pH + on_custom: + - logger.log: custom + on_calibration: + - logger.log: calibration + on_slope: + - logger.log: slope + on_t: + - logger.log: temperature compensation + on_device_information: + - logger.log: device information + on_led: + - logger.log: led diff --git a/tests/components/factory_reset/common.yaml b/tests/components/factory_reset/common.yaml index 7617dc4b811..a500d6bc3e7 100644 --- a/tests/components/factory_reset/common.yaml +++ b/tests/components/factory_reset/common.yaml @@ -5,3 +5,9 @@ button: factory_reset: resets_required: 5 max_delay: 10s + on_increment: + - logger.log: Reset counter incremented + +switch: + - platform: factory_reset + name: Factory Reset Switch diff --git a/tests/components/fan/common-triggers.yaml b/tests/components/fan/common-triggers.yaml index d64f7b5e829..90a051cd92f 100644 --- a/tests/components/fan/common-triggers.yaml +++ b/tests/components/fan/common-triggers.yaml @@ -33,3 +33,12 @@ fan: - logger.log: format: "Preset %s" args: ["x.c_str()"] + +button: + - platform: template + name: Cycle Fan Speed + on_press: + - fan.cycle_speed: fan_triggers + - fan.cycle_speed: + id: fan_triggers + off_speed_cycle: false diff --git a/tests/components/libretiny/common.yaml b/tests/components/libretiny/common.yaml new file mode 100644 index 00000000000..0d65ccfe57b --- /dev/null +++ b/tests/components/libretiny/common.yaml @@ -0,0 +1,4 @@ +text_sensor: + - platform: libretiny + version: + name: LibreTiny Version diff --git a/tests/components/libretiny/test.bk72xx-ard.yaml b/tests/components/libretiny/test.bk72xx-ard.yaml index 039a2610160..a4981ea50df 100644 --- a/tests/components/libretiny/test.bk72xx-ard.yaml +++ b/tests/components/libretiny/test.bk72xx-ard.yaml @@ -1,3 +1,6 @@ +packages: + libretiny: !include common.yaml + logger: level: VERBOSE diff --git a/tests/components/libretiny/test.ln882x-ard.yaml b/tests/components/libretiny/test.ln882x-ard.yaml index fa33431b928..d406c070946 100644 --- a/tests/components/libretiny/test.ln882x-ard.yaml +++ b/tests/components/libretiny/test.ln882x-ard.yaml @@ -1,2 +1,5 @@ +packages: + libretiny: !include common.yaml + logger: level: VERBOSE diff --git a/tests/components/libretiny/test.rtl87xx-ard.yaml b/tests/components/libretiny/test.rtl87xx-ard.yaml index fa33431b928..d406c070946 100644 --- a/tests/components/libretiny/test.rtl87xx-ard.yaml +++ b/tests/components/libretiny/test.rtl87xx-ard.yaml @@ -1,2 +1,5 @@ +packages: + libretiny: !include common.yaml + logger: level: VERBOSE diff --git a/tests/components/ltr501/common.yaml b/tests/components/ltr501/common.yaml index 406450f8c53..b4e6c431f1e 100644 --- a/tests/components/ltr501/common.yaml +++ b/tests/components/ltr501/common.yaml @@ -7,3 +7,7 @@ sensor: integration_time: 100ms ambient_light: "Ambient light" ps_counts: "Proximity counts" + on_ps_high_threshold: + - logger.log: PS high + on_ps_low_threshold: + - logger.log: PS low diff --git a/tests/components/ltr_als_ps/common.yaml b/tests/components/ltr_als_ps/common.yaml index 6c555eeb740..400e9e8201d 100644 --- a/tests/components/ltr_als_ps/common.yaml +++ b/tests/components/ltr_als_ps/common.yaml @@ -9,3 +9,7 @@ sensor: full_spectrum_counts: "Full spectrum counts" infrared_counts: "Infrared counts" actual_gain: "Actual gain" + on_ps_high_threshold: + - logger.log: PS high + on_ps_low_threshold: + - logger.log: PS low diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index b4d5fe03872..0b6aa3501e1 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -90,6 +90,25 @@ binary_sensor: - lvgl.page.previous: animation: move_right time: 600ms + - lvgl.page.show: page1 + - lvgl.page.show: + id: page1 + animation: fade_in + time: 300ms + - lvgl.animimg.start: anim_img + - lvgl.tileview.select: + id: tileview_id + tile_id: tile_1 + animated: true + - lvgl.tileview.select: + id: tileview_id + row: 0 + column: 0 + - if: + condition: + lvgl.is_paused: + then: + - logger.log: LVGL is paused - platform: lvgl id: common_button_checker name: Common button @@ -125,6 +144,9 @@ text: mode: text text_sensor: + - platform: lvgl + widget: hello_label + name: LVGL Label Text - platform: template id: test_text_sensor name: "Test Text Sensor" @@ -141,3 +163,8 @@ text_sensor: return "WILDCARD"; } return x.c_str(); + +switch: + - platform: lvgl + widget: checkbox_id + name: LVGL Checkbox Switch diff --git a/tests/components/max6956/common.yaml b/tests/components/max6956/common.yaml index 665a6060278..af7de5e0aed 100644 --- a/tests/components/max6956/common.yaml +++ b/tests/components/max6956/common.yaml @@ -13,3 +13,9 @@ binary_sensor: input: true pullup: true inverted: false + +output: + - platform: max6956 + id: max6956_output + max6956: max6956_1 + pin: 5 diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml index 86a9c764203..fce8994efd0 100644 --- a/tests/components/mics_5524/common.yaml +++ b/tests/components/mics_5524/common.yaml @@ -25,3 +25,9 @@ sensor: max_ppm: 500.0 hydrogen: name: MiCS-5524 H2 + +button: + - platform: template + name: MiCS-5524 Calibrate + on_press: + - mics_5524.request_calibration: mics_5524_module diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b488e51f3c8..ab19fca25a7 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,6 +6,9 @@ modbus_controller: on_online: then: logger.log: "Module Online" + on_command_sent: + then: + logger.log: "Command sent" binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index fc2c833e83c..dda673201e3 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -345,3 +345,4 @@ display: touch_sleep_timeout: 3 update_interval: 5s wake_up_page: 2 + skip_connection_handshake: true diff --git a/tests/components/nextion/test.bk72xx-ard.yaml b/tests/components/nextion/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..1da0d45d2d9 --- /dev/null +++ b/tests/components/nextion/test.bk72xx-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml + base: !include common.yaml diff --git a/tests/components/ota/common.yaml b/tests/components/ota/common.yaml index af8cf66708d..94c6ea5643b 100644 --- a/tests/components/ota/common.yaml +++ b/tests/components/ota/common.yaml @@ -12,6 +12,9 @@ ota: on_end: then: - logger.log: "OTA end" + on_abort: + then: + - logger.log: "OTA abort" on_error: then: - logger.log: diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 6448b3cfe6d..03187a1ed91 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -20,3 +20,14 @@ output: - platform: ${output_platform} id: output_light_output_1 pin: ${pin} + +switch: + - platform: output + name: Output Switch + output: output_light_output_1 + +button: + - platform: output + name: Output Button + output: output_light_output_1 + duration: 500ms diff --git a/tests/components/pn7150_i2c/common.yaml b/tests/components/pn7150_i2c/common.yaml index a317b72b9ca..c5b83460ddd 100644 --- a/tests/components/pn7150_i2c/common.yaml +++ b/tests/components/pn7150_i2c/common.yaml @@ -15,6 +15,12 @@ esphome: - tag.emulation_on: nfcc_pn7150 - tag.polling_off: nfcc_pn7150 - tag.polling_on: nfcc_pn7150 + - if: + condition: + pn7150.is_writing: + id: nfcc_pn7150 + then: + - logger.log: Writing pn7150_i2c: id: nfcc_pn7150 diff --git a/tests/components/pn7160_i2c/common.yaml b/tests/components/pn7160_i2c/common.yaml index 6e58364e6c0..052f309a188 100644 --- a/tests/components/pn7160_i2c/common.yaml +++ b/tests/components/pn7160_i2c/common.yaml @@ -8,6 +8,12 @@ esphome: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false - tag.set_write_mode: nfcc_pn7160_i2c + - if: + condition: + pn7160.is_writing: + id: nfcc_pn7160_i2c + then: + - logger.log: Writing - tag.set_emulation_message: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false diff --git a/tests/components/rc522_spi/common.yaml b/tests/components/rc522_spi/common.yaml index c4830850de0..747cda99574 100644 --- a/tests/components/rc522_spi/common.yaml +++ b/tests/components/rc522_spi/common.yaml @@ -7,3 +7,8 @@ binary_sensor: rc522_id: rc522_nfcc_spi name: RC522 NFC Tag uid: 74-10-37-94 + + - platform: rc522_spi + rc522_id: rc522_nfcc_spi + name: RC522 SPI NFC Tag + uid: 74-10-37-95 diff --git a/tests/components/safe_mode/test-enabled.nrf52-mcumgr.yaml b/tests/components/safe_mode/test-enabled.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..d654b54dd86 --- /dev/null +++ b/tests/components/safe_mode/test-enabled.nrf52-mcumgr.yaml @@ -0,0 +1,29 @@ +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + transport: + ble: true + hardware_uart: CDC + +safe_mode: + id: safe_mode_1 + boot_is_good_after: 2min + num_attempts: 3 + reboot_timeout: 2min + on_safe_mode: + - logger.log: Time for safe mode + +button: + - platform: safe_mode + name: Safe Mode Button + +switch: + - platform: safe_mode + name: Safe Mode Switch + +esphome: + on_boot: + - safe_mode.mark_successful + - safe_mode.mark_successful: + id: safe_mode_1 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 1bb04340574..4669953bb82 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -73,3 +73,9 @@ interval: - interval: 10s then: - snapshot.take: sdl_sdl_display + +touchscreen: + - platform: sdl + id: sdl_touch + sdl_id: sdl_sdl_display + display: sdl_sdl_display diff --git a/tests/components/sim800l/common.yaml b/tests/components/sim800l/common.yaml index 503637b35f6..12603e9e06f 100644 --- a/tests/components/sim800l/common.yaml +++ b/tests/components/sim800l/common.yaml @@ -29,3 +29,17 @@ sim800l: - sim800l.disconnect on_ussd_received: - logger.log: "ussd_received" + on_call_connected: + - logger.log: "call connected" + on_call_disconnected: + - logger.log: "call disconnected" + +binary_sensor: + - platform: sim800l + registered: + name: SIM800L Registered + +sensor: + - platform: sim800l + rssi: + name: SIM800L RSSI diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index eff5785baee..10c0809ebe7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -57,3 +57,10 @@ button: # Same bytes as above on purpose: equal payloads share one flash table - sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: "hello" + +packet_transport: + - platform: sx126x + id: sx126x_transport + update_interval: 5s + providers: + - name: other-device diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index aa74b52cda6..0538f938661 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -57,3 +57,10 @@ button: return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; - sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: "hello" + +packet_transport: + - platform: sx127x + id: sx127x_transport + update_interval: 5s + providers: + - name: other-device diff --git a/tests/components/sx1509/common.yaml b/tests/components/sx1509/common.yaml index cf7e234f09a..e750e1acdd6 100644 --- a/tests/components/sx1509/common.yaml +++ b/tests/components/sx1509/common.yaml @@ -38,3 +38,9 @@ switch: number: 1 mode: output: true + +output: + - platform: sx1509 + id: sx1509_output + sx1509_id: sx1509_hub + pin: 5 diff --git a/tests/components/tinyusb_keyboard/test.esp32-p4-idf.yaml b/tests/components/tinyusb_keyboard/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..763cc106fcc --- /dev/null +++ b/tests/components/tinyusb_keyboard/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + tinyusb_keyboard: !include common.yaml diff --git a/tests/components/tm1637/common.yaml b/tests/components/tm1637/common.yaml index b6debc055dc..ca18ab3d7ee 100644 --- a/tests/components/tm1637/common.yaml +++ b/tests/components/tm1637/common.yaml @@ -1,5 +1,6 @@ display: - platform: tm1637 + id: tm1637_display clk_pin: ${clk_pin} dio_pin: ${dio_pin} intensity: 3 @@ -7,3 +8,9 @@ display: it.print("1234"); static const uint8_t buf[] = {0x3f, 0x06, 0x5b, 0x4f | 0x80}; it.set_buffer(buf, sizeof(buf)); + +binary_sensor: + - platform: tm1637 + tm1637_id: tm1637_display + name: TM1637 Key 0 + key: 0 diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index 1d0fc473acc..69387086748 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -144,3 +144,17 @@ water_heater: min_temperature: 30 max_temperature: 75 target_temperature_step: 1 + +fan: + - platform: tuya + id: tuya_fan + switch_datapoint: 10 + speed_datapoint: 11 + oscillation_datapoint: 12 + direction_datapoint: 13 + speed_count: 4 + +text_sensor: + - platform: tuya + id: tuya_text_sensor + sensor_datapoint: 7 diff --git a/tests/components/usb_host/test.esp32-p4-idf.yaml b/tests/components/usb_host/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..a71140feca3 --- /dev/null +++ b/tests/components/usb_host/test.esp32-p4-idf.yaml @@ -0,0 +1,11 @@ +usb_host: + max_transfer_requests: 32 # Test uint32_t bitmask path (17-32 requests) + devices: + - id: device_1 + vid: 0x1234 + pid: 0x1234 + - id: device_2 + vid: 0x1234 + pid: 0xABCD + manufacturer: Example Corp + product: Example Widget diff --git a/tests/components/usb_uart/test.esp32-p4-idf.yaml b/tests/components/usb_uart/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..949cb3c5dd9 --- /dev/null +++ b/tests/components/usb_uart/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_uart: !include common.yaml diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index d08c76879e1..49a61552bc9 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -74,6 +74,20 @@ voice_assistant: args: [x.c_str()] on_end: - logger.log: "Voice assistant ended" + on_intent_start: + - logger.log: "Intent start" + on_intent_progress: + - logger.log: "Intent progress" + on_intent_end: + - logger.log: "Intent end" + on_stt_vad_start: + - logger.log: "VAD start" + on_stt_vad_end: + - logger.log: "VAD end" + on_tts_stream_start: + - logger.log: "TTS stream start" + on_tts_stream_end: + - logger.log: "TTS stream end" on_error: - logger.log: format: "Voice assistant error - code %s, message: %s" diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index d000c611709..6bea24b73d4 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -17,6 +17,8 @@ esphome: wifi: use_psram: true + enable_btm: true + enable_rrm: true min_auth_mode: WPA post_connect_roaming: false manual_ip: diff --git a/tests/components/wled/test.bk72xx-ard.yaml b/tests/components/wled/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..62ca79ca2fc --- /dev/null +++ b/tests/components/wled/test.bk72xx-ard.yaml @@ -0,0 +1,16 @@ +wifi: + ssid: MySSID + password: password1 + +wled: + +light: + - platform: beken_spi_led_strip + id: addr + name: Beken LED Strip + channel_colors: GRB + pin: P16 + num_leds: 30 + chipset: ws2812 + effects: + - wled: diff --git a/tests/test_build_components/common/i2c_camera/esp32-idf.yaml b/tests/test_build_components/common/i2c_camera/esp32-idf.yaml index 07ab6cdc8da..acefa111ab9 100644 --- a/tests/test_build_components/common/i2c_camera/esp32-idf.yaml +++ b/tests/test_build_components/common/i2c_camera/esp32-idf.yaml @@ -35,3 +35,7 @@ esp32_camera: then: - lambda: |- ESP_LOGD("main", "image len=%d, data=%c", image.length, image.data[0]); + on_stream_start: + - logger.log: Stream started + on_stream_stop: + - logger.log: Stream stopped From 3c06d41c6a3f28521b7975f7a8fb99fb488afb96 Mon Sep 17 00:00:00 2001 From: Olivier ARCHER Date: Fri, 23 Oct 2026 23:38:24 +0200 Subject: [PATCH 02/37] [modem] Add PPPoS networking over a UART cellular modem on ESP32 (#6721) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Rapsssito Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/modem/__init__.py | 232 ++++ esphome/components/modem/modem_component.cpp | 1083 ++++++++++++++++++ esphome/components/modem/modem_component.h | 282 +++++ esphome/components/modem/text_sensor.py | 27 + esphome/core/__init__.py | 9 +- esphome/core/defines.h | 7 + esphome/idf_component.yml | 2 + script/analyze_component_buses.py | 1 + sdkconfig.defaults | 4 + tests/components/modem/common.yaml | 19 + tests/components/modem/test.esp32-idf.yaml | 32 + tests/unit_tests/test_core.py | 20 + 13 files changed, 1718 insertions(+), 1 deletion(-) create mode 100644 esphome/components/modem/__init__.py create mode 100644 esphome/components/modem/modem_component.cpp create mode 100644 esphome/components/modem/modem_component.h create mode 100644 esphome/components/modem/text_sensor.py create mode 100644 tests/components/modem/common.yaml create mode 100644 tests/components/modem/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 63ca2194fe1..12dffaf5c50 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -436,6 +436,7 @@ esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras esphome/components/modbus_server/* @exciton +esphome/components/modem/* @oarcher esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt diff --git a/esphome/components/modem/__init__.py b/esphome/components/modem/__init__.py new file mode 100644 index 00000000000..71672f38565 --- /dev/null +++ b/esphome/components/modem/__init__.py @@ -0,0 +1,232 @@ +import voluptuous as vol + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components.const import CONF_RTS_PIN +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option +from esphome.components.network import add_use_address, require_ipv4 +import esphome.config_validation as cv +from esphome.const import ( + CONF_BAUD_RATE, + CONF_COMMAND, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_MODEL, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, + CONF_REBOOT_TIMEOUT, + CONF_RX_BUFFER_SIZE, + CONF_RX_PIN, + CONF_TX_BUFFER_SIZE, + CONF_TX_PIN, + CONF_USE_ADDRESS, +) +from esphome.core import CORE, ID, coroutine_with_priority +from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +# Static so `api:` finds network before its dependency check; a config-dependent +# AUTO_LOAD resolves after that check and fails a cellular-only node +AUTO_LOAD = ["network"] +CODEOWNERS = ["@oarcher"] +CONFLICTS_WITH = ["captive_portal", "ethernet"] +DEPENDENCIES = ["esp32"] +DOMAIN = "modem" + +CONF_PIN_CODE = "pin_code" +CONF_APN = "apn" +CONF_CTS_PIN = "cts_pin" +CONF_INIT_AT = "init_at" +CONF_ON_SYNC = "on_sync" +CONF_DTE_BUFFER_SIZE = "dte_buffer_size" +CONF_MODEM_ID = "modem_id" + +esp_modem_ns = cg.global_ns.namespace("esp_modem") + +# Model name to the esp_modem factory that drives it +MODEM_MODELS = { + "BG96": esp_modem_ns.create_BG96_dce, + "SIM800": esp_modem_ns.create_SIM800_dce, + "SIM7000": esp_modem_ns.create_SIM7000_dce, + "SIM7070": esp_modem_ns.create_SIM7070_dce, + "SIM7080": esp_modem_ns.create_SIM7000_dce, + "SIM7600": esp_modem_ns.create_SIM7600_dce, + "SIM7670": esp_modem_ns.create_SIM7600_dce, + "GENERIC": esp_modem_ns.create_generic_dce, +} +modem_ns = cg.esphome_ns.namespace("modem") +ModemComponent = modem_ns.class_("ModemComponent", cg.Component) + +ModemState = modem_ns.enum("ModemState", is_class=True) + + +def _on_state(conf_key: str, state: str) -> automation.CallbackAutomation: + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + params=[(ModemState, "state")], + forward=[], + when=f"state == modem::ModemState::{state}", + ) + + +def _default_use_address(config: ConfigType) -> ConfigType: + # The host tools resolve the device through this, like the other network components + config.setdefault(CONF_USE_ADDRESS, f"{CORE.name}.local") + return config + + +_CALLBACK_AUTOMATIONS = ( + _on_state(CONF_ON_CONNECT, "MODEM_STATE_CONNECTED"), + _on_state(CONF_ON_DISCONNECT, "MODEM_STATE_DISCONNECTING"), + _on_state(CONF_ON_SYNC, "MODEM_STATE_SYNCED"), +) + +# AT_COMMAND_SIZE - 1 in modem_component.h; the buffer keeps one byte for the terminator +MAX_AT_COMMAND_LENGTH = 127 + + +# A SIM PIN is 4 to 8 digits; anything else would spend one of the SIM's attempts. \Z, not $, so a +# trailing newline is refused too +_validate_pin_code = cv.All( + cv.string_strict, + vol.Match(r"^[0-9]{4,8}\Z", msg="pin_code must be 4 to 8 digits"), +) +# The IDF UART driver needs more than its 128 byte FIFO; the setters take uint16_t +_UART_BUFFER_SIZE = cv.int_range(min=129, max=65535) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModemComponent), + cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_schema, + cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_CTS_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_BAUD_RATE): cv.positive_int, + cv.Required(CONF_MODEL): cv.one_of(*MODEM_MODELS, upper=True), + cv.Required(CONF_APN): cv.string, + cv.Optional(CONF_PIN_CODE): cv.sensitive(_validate_pin_code), + cv.Optional(CONF_USE_ADDRESS): cv.string_strict, + # The command count is stored in a byte + cv.Optional(CONF_INIT_AT): cv.All( + cv.ensure_list( + cv.All(cv.string, cv.ByteLength(max=MAX_AT_COMMAND_LENGTH)) + ), + cv.Length(max=255), + ), + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_TX_BUFFER_SIZE, default=1024): _UART_BUFFER_SIZE, + cv.Optional(CONF_RX_BUFFER_SIZE, default=1024): _UART_BUFFER_SIZE, + # CMUX keeps 128 bytes of the DTE buffer back, so a smaller buffer could not be read + cv.Optional(CONF_DTE_BUFFER_SIZE, default=1024): cv.int_range( + min=256, max=65535 + ), + cv.Optional( + CONF_REBOOT_TIMEOUT, default="10min" + ): cv.positive_time_period_milliseconds, + **{ + cv.Optional(a.conf_key): automation.validate_automation({}) + for a in _CALLBACK_AUTOMATIONS + }, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.has_none_or_all_keys(CONF_RTS_PIN, CONF_CTS_PIN), + _default_use_address, + require_ipv4(DOMAIN), +) + + +MODEM_ID_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(ModemComponent)} +) +SEND_AT_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(ModemComponent), + cv.Required(CONF_COMMAND): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_AT_COMMAND_LENGTH)) + ), + }, + key=CONF_COMMAND, +) + +for _name in ("enable", "disable"): + automation.register_apply_action( + f"modem.{_name}", MODEM_ID_SCHEMA, automation.ApplyCall(f"{_name}()") + ) +_send_at_builder = automation.apply_action_builder( + "modem.send_at", + SEND_AT_SCHEMA, + # A constant renders as a literal and takes the const char * overload; a lambda returns std::string + automation.ApplyField(CONF_COMMAND, "send_at", cg.std_string), +) + + +@automation.register_action( + "modem.send_at", automation.ApplyAction, SEND_AT_SCHEMA, synchronous=True +) +async def send_at_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_define("USE_MODEM_SEND_AT") + return await _send_at_builder(config, action_id, template_arg, args) + + +automation.register_apply_condition( + "modem.connected", MODEM_ID_SCHEMA, "is_connected()" +) +automation.register_apply_condition("modem.enabled", MODEM_ID_SCHEMA, "is_enabled()") + + +@coroutine_with_priority(CoroPriority.COMMUNICATION) +async def to_code(config: ConfigType) -> None: + add_idf_component(name="espressif/esp_modem", ref="2.1.0") + + add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True) + # LCP echo probes notice a modem that went away without a word, which ends the PPP session + add_idf_sdkconfig_option("CONFIG_LWIP_ENABLE_LCP_ECHO", True) + add_idf_sdkconfig_option("CONFIG_ESP_MODEM_CMUX_DELAY_AFTER_DLCI_SETUP", 500) + add_idf_sdkconfig_option( + "CONFIG_ESP_MODEM_CMUX_USE_SHORT_PAYLOADS_ONLY", True + ) # Slower but more reliable + + cg.add_define("USE_MODEM") + + model = config[CONF_MODEL] + factory = MODEM_MODELS[model] + tx = await cg.gpio_pin_expression(config[CONF_TX_PIN]) + rx = await cg.gpio_pin_expression(config[CONF_RX_PIN]) + var = cg.new_Pvariable(config[CONF_ID], tx, rx, model, factory, config[CONF_APN]) + + cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) + add_use_address(var, config[CONF_USE_ADDRESS]) + if (pin_code := config.get(CONF_PIN_CODE)) is not None: + cg.add_define("USE_MODEM_PIN_CODE") + cg.add(var.set_pin_code(pin_code)) + + if config[CONF_ENABLE_ON_BOOT]: + cg.add(var.enable()) + + if init_at := config.get(CONF_INIT_AT): + # The commands are literals in flash; the component keeps a pointer to the array + arr = cg.shared_progmem_array( + "modem_init_at", cg.const_char_ptr, cg.ArrayInitializer(*init_at) + ) + cg.add(var.set_init_at_commands(arr, len(init_at))) + + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) + await cg.add_pin(var.set_cts_pin, config.get(CONF_CTS_PIN)) + + cg.add_optional(var.set_baud_rate, config.get(CONF_BAUD_RATE)) + cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) + cg.add(var.set_tx_buffer_size(config[CONF_TX_BUFFER_SIZE])) + cg.add(var.set_dte_buffer_size(config[CONF_DTE_BUFFER_SIZE])) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + await cg.register_component(var, config) diff --git a/esphome/components/modem/modem_component.cpp b/esphome/components/modem/modem_component.cpp new file mode 100644 index 00000000000..051816a9784 --- /dev/null +++ b/esphome/components/modem/modem_component.cpp @@ -0,0 +1,1083 @@ +#ifdef USE_ESP32 +#include "modem_component.h" +#include "soc/soc_caps.h" +#include + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/network/util.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace esphome::modem { + +using esp_modem::command_result; +using esp_modem::create_uart_dte; +using esp_modem::DTE; +using esp_modem::modem_mode; + +ESPHOME_LOG_TAG(TAG, "modem"); + +#ifdef SOC_UART_HP_NUM +static constexpr int HP_UART_COUNT = SOC_UART_HP_NUM; +#else +static constexpr int HP_UART_COUNT = UART_NUM_MAX; +#endif + +ModemComponent *global_modem_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static constexpr uint32_t WAIT_IP_TIMEOUT_MS = 50000; +static constexpr uint32_t PROBE_TIMEOUT_MS = 100; +// A silent modem is not going to answer the next second, so its probes are spaced out, doubling up to a minute +static constexpr uint32_t PROBE_RETRY_DELAY_MS = 5000; +static constexpr uint32_t PROBE_RETRY_MAX_MS = 60000; +// How long the task waits for the main loop to run on_sync before attaching +static constexpr uint32_t SYNC_ACK_TIMEOUT_MS = 5000; +// esp_modem's command strings and the log lines are built on this stack +static constexpr uint32_t TASK_STACK_SIZE = 5120; +#ifdef USE_MODEM_PIN_CODE +// AT+CPIN= can take a few seconds; the SIM may need longer still before AT+CPIN? says READY +static constexpr uint32_t PIN_TIMEOUT_MS = 5000; +static constexpr uint32_t PIN_UNLOCK_WAIT_MS = 10000; +// Sends of one pin_code the SIM has not confirmed with READY, across reboots, before it is not sent again +static constexpr uint8_t PIN_MAX_UNCONFIRMED_SENDS = 2; +#endif +// The attachment is polled every 4 s; the signal and operator queries only every 4th poll +static constexpr uint32_t ATTACH_POLL_MS = 4000; +static constexpr uint8_t ATTACH_DIAG_EVERY = 4; +static constexpr int RADIO_STATE_RF_OFF = 4; // CFUN=4: transmit and receive off, SIM still powered +static constexpr uint8_t UART_EVENT_QUEUE_SIZE = 10; +static constexpr uint32_t BAUD_PREF_HASH = 76007670UL; +static constexpr uint32_t PIN_PREF_HASH = 76007671UL; +static constexpr int FALLBACK_BAUD_RATE = 115200; + +// Indexed by ModemState +PROGMEM_STRING_TABLE(ModemStateStrings, "DISABLED", "ENABLING", "SYNCED", "CONNECTING", "WAIT_IP", "CONNECTED", + "DISCONNECTING", "UNKNOWN"); +static const LogString *state_to_str(ModemState state) { + return ModemStateStrings::get_log_str(static_cast(state), ModemStateStrings::LAST_INDEX); +} + +// Indexed by ModemFault +PROGMEM_STRING_TABLE(ModemFaultStrings, "", "Modem not responding", "Configured baud rate failed", + "init_at command failed", "SIM not ready", "SIM locked", "SIM PIN not sent; check pin_code", + "PIN record not saved", "APN or radio setup failed", "No network attachment", "PPP link failed", + "Modem teardown failed"); + +// Indexed by esp_modem::command_result +PROGMEM_STRING_TABLE(CommandResultStrings, "OK", "FAIL", "TIMEOUT"); +static const LogString *result_to_str(command_result result) { + return CommandResultStrings::get_log_str(static_cast(result), CommandResultStrings::LAST_INDEX); +} + +ModemComponent::ModemComponent(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, const char *model, DceFactory factory, + const char *apn) + : tx_pin_(tx_pin), rx_pin_(rx_pin), model_(model), dce_factory_(factory), apn_(apn) { + global_modem_component = this; +} + +void ModemComponent::enable() { + ESP_LOGI(TAG, "Setting modem target state to CONNECTED"); + this->target_state_.store(ModemState::MODEM_STATE_CONNECTED); + this->probe_backoff_.store(0, std::memory_order_relaxed); + // A link that is already up has nothing to wait for; a lost one re-arms the timer itself + if (this->component_state_.load() != ModemState::MODEM_STATE_CONNECTED) { + this->arm_connect_timeout_(); + } + this->wake_task_(); +} + +void ModemComponent::disable() { + ESP_LOGI(TAG, "Setting modem target state to DISABLED"); + this->target_state_.store(ModemState::MODEM_STATE_DISABLED); + this->cancel_connect_timeout_(); + this->wake_task_(); +} + +void ModemComponent::wake_task_() { + if (this->task_.is_created()) { + xTaskNotifyGive(this->task_.get_handle()); + } +} + +// Self-keyed so it still fires after a failed setup, where the component's own timers are skipped +void ModemComponent::arm_connect_timeout_() { + // Measures time without a connection, like wifi: neither retry cycles nor a repeated enable() restart it + if (this->timeout_ == 0 || this->connect_timeout_armed_) { + return; + } + this->connect_timeout_armed_ = true; + App.scheduler.set_timeout(this, this->timeout_, [this]() { + // The task may have connected between enable() checking the state and arming this timer + this->connect_timeout_armed_ = false; + if (this->is_connected()) { + return; + } + ESP_LOGE(TAG, "Modem was not able to connect (timeout), rebooting"); + App.reboot(); + }); +} + +void ModemComponent::cancel_connect_timeout_() { + this->connect_timeout_armed_ = false; + App.scheduler.cancel_timeout(this); +} + +network::IPAddresses ModemComponent::get_ip_addresses() { + network::IPAddresses addresses; + esp_netif_ip_info_t ip; + if (this->is_connected() && esp_netif_get_ip_info(this->ppp_netif_, &ip) == ESP_OK) { + addresses[0] = network::IPAddress(&ip.ip); +#if USE_NETWORK_IPV6 + network::fill_ip6_addresses(this->ppp_netif_, addresses); +#endif /* USE_NETWORK_IPV6 */ + } + return addresses; +} + +void ModemComponent::fail_setup_(const LogString *reason) { + this->mark_failed(reason); + if (this->target_state_.load() == ModemState::MODEM_STATE_CONNECTED) { + this->arm_connect_timeout_(); + } +} + +// The status flags, the automations and the preferences belong to the main loop, so the task leaves the +// work in pending_ and wakes loop() +void ModemComponent::hand_over_(uint32_t pending) { + this->pending_.fetch_or(pending, std::memory_order_release); + this->enable_loop_soon_any_context(); +} + +// The task is the only writer, so a load and a store do; a byte-sized read-modify-write is a library call on Xtensa +void ModemComponent::set_fault_(ModemFault fault) { + // A wrong baud_rate or init_at is kept apart, so it stays visible once the modem connects + if (fault == ModemFault::MODEM_FAULT_BAUD || fault == ModemFault::MODEM_FAULT_INIT_AT) { + this->config_fault_.store(fault, std::memory_order_relaxed); + } + if (this->fault_.load(std::memory_order_relaxed) != fault) { + this->fault_.store(fault, std::memory_order_relaxed); + this->hand_over_(PENDING_STATUS); + } +} + +// The fault that ended the session says more than a failed cleanup after it +void ModemComponent::set_teardown_fault_() { + if (this->fault_.load(std::memory_order_relaxed) == ModemFault::MODEM_FAULT_NONE) { + this->set_fault_(ModemFault::MODEM_FAULT_TEARDOWN); + } +} + +void ModemComponent::clear_config_fault_(ModemFault fault) { + if (this->config_fault_.load(std::memory_order_relaxed) == fault) { + this->config_fault_.store(ModemFault::MODEM_FAULT_NONE, std::memory_order_relaxed); + this->hand_over_(PENDING_STATUS); + } +} + +void ModemComponent::publish_status_() { + ModemFault fault = this->fault_.load(std::memory_order_relaxed); +#ifdef USE_MODEM_PIN_CODE + if (this->pin_record_unsaved_) { + fault = ModemFault::MODEM_FAULT_PIN_RECORD; + } +#endif + if (fault == ModemFault::MODEM_FAULT_NONE) { + fault = this->config_fault_.load(std::memory_order_relaxed); + } + if (fault == ModemFault::MODEM_FAULT_NONE) { + this->status_clear_warning(); + this->status_clear_error(); + return; + } + const LogString *message = ModemFaultStrings::get_log_str(static_cast(fault), ModemFaultStrings::LAST_INDEX); + // These stop the modem until the SIM or pin_code is fixed + if (fault == ModemFault::MODEM_FAULT_SIM_LOCKED || fault == ModemFault::MODEM_FAULT_PIN_NOT_SENT || + fault == ModemFault::MODEM_FAULT_PIN_RECORD) { + this->status_clear_warning(); + this->status_set_error(message); + } else { + this->status_clear_error(); + this->status_set_warning(message); + } +} + +void ModemComponent::loop() { + const uint32_t pending = this->pending_.exchange(0, std::memory_order_acquire); + if (pending & (PENDING_ON_SYNC | PENDING_ON_CONNECT | PENDING_ON_DISCONNECT)) { + // The task waits for the sync hand-over, so a pending disconnect came before a pending sync; connected now + // means a pending disconnect also came before the pending connect + const bool disconnect_first = + (pending & PENDING_ON_DISCONNECT) && (this->is_connected() || (pending & PENDING_ON_SYNC)); + if (disconnect_first) { + this->on_state_callback_.call(ModemState::MODEM_STATE_DISCONNECTING); + } + if (pending & PENDING_ON_SYNC) { + this->on_state_callback_.call(ModemState::MODEM_STATE_SYNCED); + this->sync_acked_.store(1, std::memory_order_release); // on_sync finished, so the task may attach + this->wake_task_(); + } + if (pending & PENDING_ON_CONNECT) { + this->dump_connect_params_(); + this->on_state_callback_.call(ModemState::MODEM_STATE_CONNECTED); + } + if ((pending & PENDING_ON_DISCONNECT) && !disconnect_first) { + this->on_state_callback_.call(ModemState::MODEM_STATE_DISCONNECTING); + } + } + if (pending & PENDING_STATUS) { + this->publish_status_(); + } + if (pending & PENDING_CONNECT_TIMEOUT) { + if (this->target_state_.load() == ModemState::MODEM_STATE_CONNECTED && !this->is_connected()) { + this->arm_connect_timeout_(); + } else { + this->cancel_connect_timeout_(); + } + } + if ((pending & PENDING_SAVE_BAUD) && !this->pref_.save(&this->restored_baud_)) { + ESP_LOGW(TAG, "Failed to save the baud rate"); + } +#ifdef USE_MODEM_PIN_CODE + // Written through at once: a power cycle before the periodic flush would send the PIN again + if (pending & PENDING_SAVE_PIN) { + // The task leaves pin_record_ alone until pin_save_state_ reports the outcome + const bool saved = this->pin_pref_.save(&this->pin_record_) && global_preferences->sync(); + if (!saved) { + ESP_LOGE(TAG, "Failed to save the PIN record"); + } + // A power cycle could send a refused PIN again, so a failed save shows until a later one succeeds + if (this->pin_record_unsaved_ != !saved) { + this->pin_record_unsaved_ = !saved; + this->publish_status_(); + } + this->pin_save_state_.store(saved ? PinSave::PIN_SAVE_DONE : PinSave::PIN_SAVE_FAILED, std::memory_order_release); + this->wake_task_(); + } +#endif +#ifdef USE_MODEM_URC + if (this->urc_pending_.load(std::memory_order_acquire)) { + this->urc_text_sensor_->publish_state(this->urc_line_); + this->urc_pending_.store(0, std::memory_order_release); + } +#endif + // A hand-over after these checks re-enables the loop through enable_loop_soon_any_context() + if (this->pending_.load(std::memory_order_acquire) == 0) { + this->disable_loop(); + } +} + +void ModemComponent::setup() { + this->pref_ = global_preferences->make_preference(BAUD_PREF_HASH); + this->pref_.load(&this->restored_baud_); +#ifdef USE_MODEM_PIN_CODE + this->pin_pref_ = global_preferences->make_preference(PIN_PREF_HASH); + if (!this->pin_pref_.load(&this->pin_record_)) { + // Also the first boot with pin_code; after a flash fault the send count starts over + ESP_LOGD(TAG, "No PIN record stored"); + } + // A changed pin_code starts with a clean record and may be sent again + const uint32_t pin_hash = fnv1_hash(this->pin_code_); + if (this->pin_record_.hash != pin_hash) { + this->pin_record_ = PinRecord{pin_hash, 0}; + if (!this->pin_pref_.save(&this->pin_record_)) { + ESP_LOGW(TAG, "Failed to clear the PIN record"); + } + } +#else + // A build without pin_code forgets the count, so flashing once without it clears the record + { + auto pin_pref = global_preferences->make_preference(PIN_PREF_HASH); + PinRecord record{}; + if (pin_pref.load(&record) && record.hash != 0) { + record = PinRecord{}; + if (!pin_pref.save(&record)) { + ESP_LOGW(TAG, "Failed to clear the PIN record"); + } + } + } +#endif + + // Highest free high-performance port first; UART0 stays with the logger and the low-power UART of the + // C6/P4 has restricted pins and clocks + for (int p = HP_UART_COUNT - 1; p > UART_NUM_0; p--) { + if (!uart_is_driver_installed(static_cast(p))) { + this->uart_port_ = p; + break; + } + } + if (this->uart_port_ == UART_NUM_MAX) { + this->mark_failed(LOG_STR("No free UART port for modem")); + return; + } + + esp_netif_config_t netif_ppp_config = ESP_NETIF_DEFAULT_PPP(); + this->ppp_netif_ = esp_netif_new(&netif_ppp_config); + if (this->ppp_netif_ == nullptr) { + this->fail_setup_(LOG_STR("PPP netif creation failed")); + return; + } + + auto ip_event = [](void *arg, esp_event_base_t, int32_t event_id, void *event_data) { + const bool got_ip = event_id == IP_EVENT_PPP_GOT_IP; + if (!got_ip && event_id != IP_EVENT_PPP_LOST_IP) { + return; + } + if (got_ip) { + ESP_LOGD(TAG, "PPP IP event: Got IP " IPSTR, IP2STR(&static_cast(event_data)->ip_info.ip)); + } else { + ESP_LOGD(TAG, "PPP IP event: Lost IP"); + } + auto *self = static_cast(arg); + self->got_ip_.store(got_ip, std::memory_order_release); + self->wake_task_(); + }; + // A dead peer or an LCP failure is reported here, not as a lost IP event; esp_modem enables these + // error events itself when it attaches the netif + auto ppp_status = [](void *arg, esp_event_base_t, int32_t event_id, void *) { + if (event_id < NETIF_PP_PHASE_OFFSET && event_id != NETIF_PPP_ERRORNONE) { + ESP_LOGW(TAG, "PPP error %d", static_cast(event_id)); + auto *self = static_cast(arg); + self->ppp_error_.store(static_cast(event_id), std::memory_order_release); + self->got_ip_.store(0, std::memory_order_release); + self->wake_task_(); + } + }; + if (esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, ip_event, this) != ESP_OK || + esp_event_handler_register(NETIF_PPP_STATUS, ESP_EVENT_ANY_ID, ppp_status, this) != ESP_OK) { + this->fail_setup_(LOG_STR("Event handler register failed")); + return; + } + + // The DTE and DCE live for the life of the program; a baud probe only changes the UART divisor + esp_modem_dte_config_t dte_config = ESP_MODEM_DTE_DEFAULT_CONFIG(); + dte_config.uart_config.tx_io_num = this->tx_pin_->get_pin(); + dte_config.uart_config.rx_io_num = this->rx_pin_->get_pin(); + dte_config.uart_config.rx_buffer_size = this->rx_buffer_size_; + dte_config.uart_config.tx_buffer_size = this->tx_buffer_size_; + dte_config.uart_config.event_queue_size = UART_EVENT_QUEUE_SIZE; + dte_config.uart_config.port_num = static_cast(this->uart_port_); + // The rate the modem answered at last time comes first: after a baud_rate change the modem may still sit in + // CMUX at the old rate, which only that rate can detect; the configured rate is applied once synced + const int start_baud = this->restored_baud_ != 0 ? this->restored_baud_ : this->baud_rate_; + if (start_baud != 0) { + dte_config.uart_config.baud_rate = start_baud; + } + if (this->rts_pin_ != nullptr) { + dte_config.uart_config.rts_io_num = this->rts_pin_->get_pin(); + dte_config.uart_config.cts_io_num = this->cts_pin_->get_pin(); + dte_config.uart_config.flow_control = ESP_MODEM_FLOW_CONTROL_HW; + } + dte_config.dte_buffer_size = this->dte_buffer_size_; + this->current_baud_rate_ = dte_config.uart_config.baud_rate; + this->dte_ = create_uart_dte(&dte_config); + esp_modem_dce_config_t dce_config = ESP_MODEM_DCE_DEFAULT_CONFIG(this->apn_); + if (this->dte_) { + this->dce_ = this->dce_factory_(&dce_config, this->dte_, this->ppp_netif_); + } + if (!this->dce_) { + this->fail_setup_(LOG_STR("Modem DTE/DCE creation failed")); + return; + } + // The pin schema's inverted flag is applied to the UART signals, as the uart component does + uint32_t invert = 0; + if (this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->rts_pin_ != nullptr) { + if (this->rts_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + if (this->cts_pin_->is_inverted()) { + invert |= UART_SIGNAL_CTS_INV; + } + } + if (invert != 0 && uart_set_line_inverse(static_cast(this->uart_port_), invert) != ESP_OK) { + this->fail_setup_(LOG_STR("UART line inversion failed")); + return; + } +#ifdef USE_MODEM_URC + // Runs on the UART task. CMUX hands each frame over on its own, so lines are assembled here; every + // chunk is consumed and a complete line is handed to loop() in urc_line_. + this->dce_->set_enhanced_urc([this](const DTE::UrcBufferInfo &buffer_info) { + // The response parser owns the buffer while a command runs; URCs in that window are best effort, + // and a line started before it would otherwise be glued to the next one + if (buffer_info.is_command_active) { + this->urc_reader_.reset(); + return DTE::UrcConsumeInfo{DTE::UrcConsumeResult::CONSUME_NONE, 0}; + } + bool dropped = false; + for (size_t i = 0; i < buffer_info.new_data_size; i++) { + const uint8_t c = buffer_info.new_data_start[i]; + if (this->urc_pending_.load(std::memory_order_acquire)) { + // The line loop() has not published yet lives in the reader's buffer, which a dropped line leaves alone + dropped |= c != '\r' && c != '\n'; + this->urc_reader_.discard_line(); + } + const char *line = this->urc_reader_.feed(c); + this->urc_reader_.log_if_overflowed(TAG); + if (line != nullptr && line[0] != '\0') { + this->urc_line_ = line; + this->urc_pending_.store(1, std::memory_order_release); + this->enable_loop_soon_any_context(); + } + } + if (dropped) { + ESP_LOGW(TAG, "URC dropped; the previous one is not published yet"); + } + return DTE::UrcConsumeInfo{DTE::UrcConsumeResult::CONSUME_ALL, 0}; + }); +#endif + if (!this->task_.create(ModemComponent::task_entry, "modem", TASK_STACK_SIZE, this, 1, false)) { + this->fail_setup_(LOG_STR("Task creation failed")); + } +} + +void ModemComponent::dump_config() { +#ifdef USE_MODEM_PIN_CODE + const char *pin_code = LOG_STR_LITERAL("Yes (not shown)"); +#else + const char *pin_code = LOG_STR_LITERAL("No"); +#endif + ESP_LOGCONFIG(TAG, + "Modem:\n" + " Model: %s\n" + " APN: %s\n" + " PIN code: %s\n" + " TX Pin: GPIO%u\n" + " RX Pin: GPIO%u\n" + " UART port: %d\n" + " Baud rate: %d\n" + " TX buffer size: %u\n" + " RX buffer size: %u\n" + " DTE buffer size: %u\n" + " Reboot timeout: %" PRIu32 " ms\n" + " Enabled: %s", + this->model_, this->apn_, pin_code, this->tx_pin_->get_pin(), this->rx_pin_->get_pin(), + static_cast(this->uart_port_), this->current_baud_rate_, this->tx_buffer_size_, + this->rx_buffer_size_, this->dte_buffer_size_, this->timeout_, YESNO(this->is_enabled())); + this->dump_connect_params_(); +} + +void ModemComponent::task_entry(void *arg) { static_cast(arg)->run_task_(); } + +void ModemComponent::run_task_() { + while (true) { +#ifdef USE_MODEM_SEND_AT + // A command from send_at() goes out between state steps, never in the middle of a sequence + if (this->at_pending_.load(std::memory_order_acquire)) { + this->at_(this->at_command_, this->at_timeout_); + this->at_pending_.store(0, std::memory_order_release); + } +#endif + this->step_delay_ms_ = 0; + this->transition_to_(this->compute_next_state_()); + // Nothing to do while disabled or connected: enable(), disable() and the PPP events wake the task + const bool parked = this->component_state_.load() == this->target_state_.load(); + ulTaskNotifyTake(pdTRUE, parked ? portMAX_DELAY : pdMS_TO_TICKS(this->step_delay_ms_)); + } +} + +uint32_t ModemComponent::next_probe_delay_() { + const uint8_t backoff = this->probe_backoff_.load(std::memory_order_relaxed); + const uint32_t delay_ms = std::min(PROBE_RETRY_DELAY_MS << backoff, PROBE_RETRY_MAX_MS); + if (delay_ms < PROBE_RETRY_MAX_MS) { + this->probe_backoff_.store(backoff + 1, std::memory_order_relaxed); + } + return delay_ms; +} + +ModemState ModemComponent::retry_(ModemState state, uint32_t delay_ms) { + this->step_delay_ms_ = delay_ms; + return state; +} + +ModemState ModemComponent::compute_next_state_() { + const ModemState state = this->component_state_.load(); + if (this->target_state_.load() == ModemState::MODEM_STATE_DISABLED) { + if (state == ModemState::MODEM_STATE_DISABLED) { + return ModemState::MODEM_STATE_DISABLED; + } + if (state != ModemState::MODEM_STATE_DISCONNECTING) { + return ModemState::MODEM_STATE_DISCONNECTING; + } + } + + switch (state) { + case ModemState::MODEM_STATE_DISABLED: +#ifdef USE_MODEM_PIN_CODE + // A new attempt may send the PIN again; a counted send that has not gone out yet still does + if (this->pin_step_ == PinStep::PIN_STEP_SENT) { + this->pin_step_ = PinStep::PIN_STEP_IDLE; + } +#endif + return ModemState::MODEM_STATE_ENABLING; + case ModemState::MODEM_STATE_ENABLING: + return this->handle_state_enabling_(); + case ModemState::MODEM_STATE_SYNCED: + return this->handle_state_synced_(); + case ModemState::MODEM_STATE_CONNECTING: + return this->handle_state_connecting_(); + case ModemState::MODEM_STATE_WAIT_IP: + return this->handle_state_wait_ip_(); + case ModemState::MODEM_STATE_CONNECTED: + if (this->got_ip_.load(std::memory_order_acquire)) { + return ModemState::MODEM_STATE_CONNECTED; + } + ESP_LOGW(TAG, "Lost IP; PPP error %u", this->ppp_error_.load(std::memory_order_acquire)); + this->ppp_error_.store(0, std::memory_order_release); + this->set_fault_(ModemFault::MODEM_FAULT_PPP); + return ModemState::MODEM_STATE_DISCONNECTING; + case ModemState::MODEM_STATE_DISCONNECTING: + return this->handle_state_disconnecting_(); + } + return state; +} + +ModemState ModemComponent::handle_state_enabling_() { + // Find the baud rate the modem answers at and leave it in command mode, synced + if (!this->dce_->set_mode(modem_mode::AUTODETECT)) { + // A failed detection keeps the previous mode, which would skip the baud rate search below + this->dce_->set_mode(modem_mode::UNDEF); + } + auto mode = this->dce_->get_mode(); + ESP_LOGD(TAG, "Autodetect mode: %d", static_cast(mode)); + + // A detection proves the modem answers; leaving CMUX or data mode has to be checked with a sync + bool exited = true; + switch (mode) { + case modem_mode::CMUX_MANUAL_MODE: + // A data session survived the ESP reboot; the radio off step below ends it + ESP_LOGD(TAG, "Modem warm reboot. Exiting CMUX"); + if (!this->dce_->set_mode(modem_mode::CMUX_MANUAL_EXIT)) { + ESP_LOGW(TAG, "Failed to exit CMUX"); + } + break; + + case modem_mode::UNDEF: { + // The detection above probed the current rate; the other known rates are tried in this pass + const int detected = this->current_baud_rate_; + exited = false; + const int bauds[] = {this->restored_baud_, this->baud_rate_, FALLBACK_BAUD_RATE}; + bool found = false; + bool probed = false; + bool rejected = false; + for (size_t i = 0; i != std::size(bauds) && !found; i++) { + const int baud = bauds[i]; + if (baud == 0 || baud == detected || std::find(bauds, bauds + i, baud) != bauds + i) { + continue; + } + if (uart_set_baudrate(static_cast(this->uart_port_), baud) != ESP_OK) { + ESP_LOGW(TAG, "UART rejected baud rate %d", baud); + this->set_fault_(ModemFault::MODEM_FAULT_BAUD); + rejected = true; + continue; + } + this->current_baud_rate_ = baud; + probed = true; + std::string output; + found = this->dce_->at("AT", output, PROBE_TIMEOUT_MS) == command_result::OK; + if (!found) { + this->not_responding_(baud); + } + } + if (!found) { + // The detection at the current rate already failed; a UART that refused every other rate set BAUD + if (!probed && !rejected) { + this->not_responding_(detected); + } + return this->retry_(ModemState::MODEM_STATE_ENABLING, this->next_probe_delay_()); + } + ESP_LOGD(TAG, "Modem responded at baud %d", this->current_baud_rate_); + break; + } + + case modem_mode::COMMAND_MODE: + exited = false; + break; + + case modem_mode::DATA_MODE: + // Can block 20 s; esp_modem offers no shorter exit + ESP_LOGW(TAG, "Exiting DATA_MODE"); + if (!this->dce_->set_mode(modem_mode::COMMAND_MODE)) { + ESP_LOGW(TAG, "Failed to leave data mode"); + } + break; + + default: + ESP_LOGW(TAG, "Modem in unexpected mode %d. Switching to COMMAND mode.", static_cast(mode)); + if (!this->dce_->set_mode(modem_mode::COMMAND_MODE)) { + ESP_LOGW(TAG, "Failed to switch to command mode"); + } + this->dce_->set_mode(modem_mode::UNDEF); + break; + } + + if (exited && this->dce_->sync() != command_result::OK) { + // The detection proved the modem answers, so the CMUX or data mode exit is what failed; the exit can block + // for 20 s, so it backs off like the probe + ESP_LOGW(TAG, "Modem did not return to command mode"); + this->set_fault_(ModemFault::MODEM_FAULT_TEARDOWN); + return this->retry_(ModemState::MODEM_STATE_ENABLING, this->next_probe_delay_()); + } + this->probe_backoff_.store(0, std::memory_order_relaxed); + // esp_modem keeps the last line before OK as the reply, which can be the echoed command + if (this->dce_->set_echo(false) != command_result::OK) { + ESP_LOGW(TAG, "Failed to disable command echo"); + } + + int radio_state = 0; + if (this->dce_->get_radio_state(radio_state) != command_result::OK) { + ESP_LOGW(TAG, "Failed to read the radio state; skipping the radio off step"); + } else if (radio_state != RADIO_STATE_RF_OFF) { + // Drops a session left by a warm reboot; unlike CFUN=0, which powers the SIM down on the SIM7600, CFUN=4 + // keeps the SIM readable for the PIN and the APN. A modem that refuses is used as it is. + if (this->dce_->set_radio_state(RADIO_STATE_RF_OFF) != command_result::OK) { + ESP_LOGW(TAG, "Failed to turn the radio off"); + } + } + + if (this->baud_rate_ != 0 && this->baud_rate_ != this->current_baud_rate_) { + ESP_LOGD(TAG, "Setting baud rate: %d -> %d", this->current_baud_rate_, this->baud_rate_); + if (this->dce_->set_baud(this->baud_rate_) == command_result::OK) { + delay(200); // NOLINT + if (uart_set_baudrate(static_cast(this->uart_port_), this->baud_rate_) != ESP_OK) { + ESP_LOGE(TAG, "UART rejected baud rate %d", this->baud_rate_); + this->set_fault_(ModemFault::MODEM_FAULT_BAUD); + return ModemState::MODEM_STATE_DISCONNECTING; + } + this->current_baud_rate_ = this->baud_rate_; + if (this->dce_->sync() != command_result::OK) { + ESP_LOGE(TAG, "Failed to sync modem at baud rate %d.", this->baud_rate_); + this->set_fault_(ModemFault::MODEM_FAULT_BAUD); + return ModemState::MODEM_STATE_DISCONNECTING; + } + ESP_LOGI(TAG, "Modem synced at baud rate %d.", this->current_baud_rate_); + } else { + // The modem still answers at the probed rate, so a rejected rate is not worth a retry loop + ESP_LOGW(TAG, "Modem rejected baud rate %d. Staying at %d.", this->baud_rate_, this->current_baud_rate_); + this->set_fault_(ModemFault::MODEM_FAULT_BAUD); + } + } + + // The rate in use is the wanted one, so a baud fault from this or an earlier attempt no longer applies + if (this->baud_rate_ == 0 || this->baud_rate_ == this->current_baud_rate_) { + this->clear_config_fault_(ModemFault::MODEM_FAULT_BAUD); + } + if (this->restored_baud_ != this->current_baud_rate_) { + this->restored_baud_ = this->current_baud_rate_; + this->hand_over_(PENDING_SAVE_BAUD); + } + + // The DTE side was set up with the UART; the modem side has to be asked for RTS/CTS as well + if (this->rts_pin_ != nullptr && this->dce_->set_flow_control(2, 2) != command_result::OK) { + ESP_LOGW(TAG, "Modem refused hardware flow control; UART overruns are possible"); + } + this->modem_synced_ = true; + return ModemState::MODEM_STATE_SYNCED; +} + +ModemState ModemComponent::handle_state_synced_() { + // transition_to_() gave on_sync its head start; a command it queued went out at the top of this pass + bool failed = false; + for (uint8_t i = 0; i != this->init_at_count_; i++) { + failed |= this->at_(this->init_at_commands_[i]) != command_result::OK; // at_() logs the failure + } + if (failed) { + this->set_fault_(ModemFault::MODEM_FAULT_INIT_AT); + } else { + this->clear_config_fault_(ModemFault::MODEM_FAULT_INIT_AT); + } + this->connect_step_ = ConnectStep::CONNECT_STEP_START; + return ModemState::MODEM_STATE_CONNECTING; +} + +ModemState ModemComponent::handle_state_connecting_() { + if (this->connect_step_ == ConnectStep::CONNECT_STEP_START) { + // ENABLING just synced; only a failed attachment read comes back here unsure of the modem + if (this->resync_) { + this->resync_ = false; + if (this->dce_->sync() != command_result::OK) { + ESP_LOGW(TAG, "Modem not synced during network init"); + return ModemState::MODEM_STATE_ENABLING; + } + } + // ENABLING left the radio at CFUN=4, where the SIM answers but the modem cannot attach yet + switch (this->prepare_sim_()) { + case SimResult::SIM_RESULT_READY: + break; + case SimResult::SIM_RESULT_RETRY: + return this->retry_(ModemState::MODEM_STATE_CONNECTING); + case SimResult::SIM_RESULT_LOCKED: + // A reboot would send nothing more, so the reboot timer is cancelled + this->target_state_.store(ModemState::MODEM_STATE_DISABLED); + this->hand_over_(PENDING_CONNECT_TIMEOUT); + return ModemState::MODEM_STATE_DISCONNECTING; + case SimResult::SIM_RESULT_HALT: + // Retrying now could burn the SIM's PIN attempts; the reboot timer stays armed, since a reboot may + // still open the SIM + this->target_state_.store(ModemState::MODEM_STATE_DISABLED); + return ModemState::MODEM_STATE_DISCONNECTING; + } + // The APN has to be in the PDP context before the radio comes on and the modem attaches; esp_modem only + // sends it when PPP starts + esp_modem::PdpContext pdp(this->apn_); + if (this->dce_->set_pdp_context(pdp) != command_result::OK) { + ESP_LOGW(TAG, "Failed to set the APN"); + this->set_fault_(ModemFault::MODEM_FAULT_SETUP); + return this->retry_(ModemState::MODEM_STATE_CONNECTING); + } + if (this->dce_->set_radio_state(1) != command_result::OK) { + ESP_LOGW(TAG, "Failed to turn the radio on"); + this->set_fault_(ModemFault::MODEM_FAULT_SETUP); + return this->retry_(ModemState::MODEM_STATE_CONNECTING); + } + this->connect_step_ = ConnectStep::CONNECT_STEP_RADIO_ON; + this->attach_polls_ = 0; + } + + if (this->connect_step_ == ConnectStep::CONNECT_STEP_RADIO_ON) { + // A modem still searching for a network refuses this; the attachment poll asks again + if (this->dce_->set_network_attachment_state(1) != command_result::OK) { + ESP_LOGD(TAG, "Network attachment request refused"); + } + this->connect_step_ = ConnectStep::CONNECT_STEP_ATTACH_REQUESTED; + } + + int attached = 0; + if (this->dce_->get_network_attachment_state(attached) != command_result::OK) { + ESP_LOGW(TAG, "Failed to read the network attachment state"); + this->set_fault_(ModemFault::MODEM_FAULT_NOT_ATTACHED); + this->connect_step_ = ConnectStep::CONNECT_STEP_START; // start over with a sync on the next pass + this->resync_ = true; + return this->retry_(ModemState::MODEM_STATE_CONNECTING); + } + if (!attached) { + if (this->attach_polls_++ % ATTACH_DIAG_EVERY == 0) { + int rssi = 99, ber = 99, registration = -1, mode = -1; // -1 stays when the query fails + this->dce_->get_network_registration_state(registration); + this->dce_->get_signal_quality(rssi, ber); + this->dce_->get_network_system_mode(mode); + ESP_LOGW(TAG, "Modem not attached yet: registration %d, network type %d, CSQ %d, BER %d", registration, mode, + rssi, ber); + this->connect_step_ = ConnectStep::CONNECT_STEP_RADIO_ON; // request the attachment again + } + this->set_fault_(ModemFault::MODEM_FAULT_NOT_ATTACHED); + return this->retry_(ModemState::MODEM_STATE_CONNECTING, ATTACH_POLL_MS); + } + + ESP_LOGI(TAG, "Modem initialized and ready, starting PPP"); + // A late event from the last session must not count + this->got_ip_.store(0, std::memory_order_release); + this->ppp_error_.store(0, std::memory_order_release); + if (!this->dce_->set_mode(modem_mode::CMUX_MODE)) { + ESP_LOGE(TAG, "Failed to enter PPP. Resetting modem."); + this->set_fault_(ModemFault::MODEM_FAULT_PPP); + return ModemState::MODEM_STATE_DISCONNECTING; + } + this->state_started_ms_ = millis(); + return ModemState::MODEM_STATE_WAIT_IP; +} + +ModemState ModemComponent::handle_state_wait_ip_() { + if (this->got_ip_.load(std::memory_order_acquire)) { + this->set_fault_(ModemFault::MODEM_FAULT_NONE); + return ModemState::MODEM_STATE_CONNECTED; + } + // A PPP failure (wrong APN, authentication, dead peer) ends the wait at once; the event logged the code + if (this->ppp_error_.load(std::memory_order_acquire) != 0) { + this->ppp_error_.store(0, std::memory_order_release); + this->set_fault_(ModemFault::MODEM_FAULT_PPP); + return ModemState::MODEM_STATE_DISCONNECTING; + } + const uint32_t elapsed = millis() - this->state_started_ms_; + if (elapsed < WAIT_IP_TIMEOUT_MS) { + // The IP event wakes the task sooner + return this->retry_(ModemState::MODEM_STATE_WAIT_IP, WAIT_IP_TIMEOUT_MS - elapsed); + } + ESP_LOGE(TAG, "Unable to get IP address"); + this->set_fault_(ModemFault::MODEM_FAULT_PPP); + return ModemState::MODEM_STATE_DISCONNECTING; +} + +ModemState ModemComponent::handle_state_disconnecting_() { + // Each teardown command holds the task for up to 20 s, so a modem that never answered is skipped + if (!this->modem_synced_) { + return ModemState::MODEM_STATE_DISABLED; + } + this->modem_synced_ = false; + // COMMAND_MODE stops PPP and leaves CMUX; the manual exit only applies to a warm reboot + if (this->dce_->get_mode() != modem_mode::COMMAND_MODE && !this->dce_->set_mode(modem_mode::COMMAND_MODE)) { + // The next enable() probes the mode again and retries the exit; the fault keeps this visible + ESP_LOGW(TAG, "Failed to leave data mode"); + this->set_teardown_fault_(); + } + // Turn the radio off; waiting for the lost IP event could take up to 2 min. A reconnect keeps the SIM powered + // so it is not unlocked again; only disable() powers it down. + const bool reconnect = this->target_state_.load() == ModemState::MODEM_STATE_CONNECTED; + if (this->dce_->set_radio_state(reconnect ? RADIO_STATE_RF_OFF : 0) == command_result::OK) { + ESP_LOGI(TAG, "Radio off"); + } else { + ESP_LOGW(TAG, "Radio off failed"); + this->set_teardown_fault_(); + } + return ModemState::MODEM_STATE_DISABLED; +} + +void ModemComponent::transition_to_(ModemState next_state) { + const ModemState state = this->component_state_.load(); + if (next_state == state) { + return; + } + const bool lost = next_state == ModemState::MODEM_STATE_DISCONNECTING && state == ModemState::MODEM_STATE_CONNECTED; + ESP_LOGD(TAG, "State change: %s -> %s", LOG_STR_ARG(state_to_str(state)), LOG_STR_ARG(state_to_str(next_state))); + this->component_state_.store(next_state); + // The automations run on the main loop + if (next_state == ModemState::MODEM_STATE_SYNCED) { + // on_sync may queue an AT command or disable the modem; it gets a bounded head start, and loop() wakes the + // task once it ran. An earlier enable() or a send_at() inside on_sync also notifies, so the flag decides. + this->sync_acked_.store(0, std::memory_order_release); + this->hand_over_(PENDING_ON_SYNC); + const uint32_t started = millis(); + while (this->sync_acked_.load(std::memory_order_acquire) == 0) { + const uint32_t elapsed = millis() - started; + if (elapsed >= SYNC_ACK_TIMEOUT_MS) { + ESP_LOGW(TAG, "on_sync did not run within %u ms; continuing", static_cast(SYNC_ACK_TIMEOUT_MS)); + break; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(SYNC_ACK_TIMEOUT_MS - elapsed)); + } + } else if (next_state == ModemState::MODEM_STATE_CONNECTED) { + this->hand_over_(PENDING_ON_CONNECT | PENDING_CONNECT_TIMEOUT); + } else if (next_state == ModemState::MODEM_STATE_DISCONNECTING) { + // The lost IP event can lag by minutes; a quick re-enable must wait for a fresh address + this->got_ip_.store(0, std::memory_order_release); + this->step_delay_ms_ = COMMAND_TIMEOUT_MS; // let the link settle before the first pass + // on_disconnect pairs with on_connect, so a failed attempt that never connected does not fire it; a lost + // connection starts the reconnect watchdog again + if (lost) { + this->hand_over_(PENDING_ON_DISCONNECT | PENDING_CONNECT_TIMEOUT); + } + } +} + +void ModemComponent::not_responding_(int baud) { + ESP_LOGW(TAG, "Modem not responding at %d baud. Check power, PWRKEY and TX/RX wiring.", baud); + this->set_fault_(ModemFault::MODEM_FAULT_NO_RESPONSE); +} + +#ifdef USE_MODEM_SEND_AT +bool ModemComponent::send_at(const std::string &cmd, uint32_t timeout) { return this->send_at(cmd.c_str(), timeout); } + +bool ModemComponent::send_at(const char *cmd, uint32_t timeout) { + if (!this->task_.is_created()) { + ESP_LOGW(TAG, "Modem not ready, cannot send AT command: %s", cmd); + return false; + } + if (this->at_pending_.load(std::memory_order_acquire)) { + ESP_LOGW(TAG, "AT command '%s' dropped; the previous one is not sent yet", cmd); + return false; + } + const size_t len = strlen(cmd); + if (len >= sizeof(this->at_command_)) { + ESP_LOGW(TAG, "AT command longer than %u bytes dropped", static_cast(sizeof(this->at_command_) - 1)); + return false; + } + memcpy(this->at_command_, cmd, len + 1); + this->at_timeout_ = timeout; + this->at_pending_.store(1, std::memory_order_release); + this->wake_task_(); + return true; +} + +#endif + +command_result ModemComponent::at_(const char *cmd, uint32_t timeout) { + std::string output; + const command_result result = this->dce_->at(cmd, output, timeout); + if (result == command_result::OK) { + ESP_LOGI(TAG, "AT '%s' -> OK: %s", cmd, output.c_str()); + } else { + ESP_LOGW(TAG, "AT '%s' -> %s: %s", cmd, LOG_STR_ARG(result_to_str(result)), output.c_str()); + } + return result; +} + +// esp_modem's read_pin() takes NOT READY for READY and SIM PIN2 for the SIM PIN, so the status is read here +SimResult ModemComponent::prepare_sim_() { +#ifdef USE_MODEM_PIN_CODE + // Every write to pin_record_ happens below, so waiting here keeps the task off it while loop() saves it + if (this->pin_save_state_.load(std::memory_order_acquire) == PinSave::PIN_SAVE_PENDING) { + return SimResult::SIM_RESULT_RETRY; // the save wakes the task + } +#endif + std::string output; + if (this->dce_->at("AT+CPIN?", output, COMMAND_TIMEOUT_MS) != command_result::OK) { + // A SIM that is still starting answers with an error for a second or so + ESP_LOGD(TAG, "SIM status not readable yet: %s", output.c_str()); + this->set_fault_(ModemFault::MODEM_FAULT_SIM_NOT_READY); + return SimResult::SIM_RESULT_RETRY; + } + // Anything else, such as an echoed command, is not a status and must not reach the lock checks below + const char *found = strstr(output.c_str(), "+CPIN:"); + if (found != nullptr) { + found += sizeof("+CPIN:") - 1; + while (*found == ' ') { + found++; + } + } + const StringRef status = found != nullptr ? StringRef(found) : StringRef(); + if (status.empty()) { + ESP_LOGD(TAG, "SIM status not reported yet: %s", output.c_str()); + this->set_fault_(ModemFault::MODEM_FAULT_SIM_NOT_READY); + return SimResult::SIM_RESULT_RETRY; + } + ESP_LOGD(TAG, "SIM: %s", status.c_str()); + + // A prefix match: "NOT READY" contains READY but does not start with it + if (status.starts_with("READY")) { +#ifdef USE_MODEM_PIN_CODE + this->pin_step_ = PinStep::PIN_STEP_IDLE; // proven correct, so a modem self-reset may send it again + if (this->pin_record_.sends != 0) { + this->pin_record_.sends = 0; + this->save_pin_record_(); + } +#endif + return SimResult::SIM_RESULT_READY; + } + if (strstr(status.c_str(), "PUK") != nullptr) { + ESP_LOGE(TAG, "SIM is PUK locked; unlock it in a phone"); + this->set_fault_(ModemFault::MODEM_FAULT_SIM_LOCKED); + return SimResult::SIM_RESULT_LOCKED; + } + // Only the plain SIM PIN takes pin_code; SIM PIN2 and the PH-* locks need other credentials + if (!status.starts_with("SIM PIN") || status.starts_with("SIM PIN2")) { + if (strstr(status.c_str(), "PIN") != nullptr) { + ESP_LOGE(TAG, "SIM lock %s needs a credential pin_code cannot provide", status.c_str()); + this->set_fault_(ModemFault::MODEM_FAULT_SIM_LOCKED); + return SimResult::SIM_RESULT_LOCKED; + } + ESP_LOGW(TAG, "SIM not ready: %s", status.c_str()); // e.g. NOT READY or NOT INSERTED + this->set_fault_(ModemFault::MODEM_FAULT_SIM_NOT_READY); + return SimResult::SIM_RESULT_RETRY; + } +#ifdef USE_MODEM_PIN_CODE + return this->send_pin_(); +#else + ESP_LOGE(TAG, "SIM requires a PIN but pin_code is not set"); + this->set_fault_(ModemFault::MODEM_FAULT_SIM_LOCKED); + return SimResult::SIM_RESULT_LOCKED; +#endif +} + +#ifdef USE_MODEM_PIN_CODE +void ModemComponent::save_pin_record_() { + this->pin_save_state_.store(PinSave::PIN_SAVE_PENDING, std::memory_order_release); + this->hand_over_(PENDING_SAVE_PIN); +} + +// Each send is counted in flash before the PIN goes out, so reboots cannot keep spending the SIM's attempts on a +// PIN it never confirmed; READY clears the count and a refused send uses it up +SimResult ModemComponent::send_pin_() { + switch (this->pin_step_) { + case PinStep::PIN_STEP_IDLE: + if (this->pin_record_.sends >= PIN_MAX_UNCONFIRMED_SENDS) { + ESP_LOGE(TAG, "The SIM never confirmed this pin_code; not sending it again"); + this->set_fault_(ModemFault::MODEM_FAULT_PIN_NOT_SENT); + return SimResult::SIM_RESULT_LOCKED; + } + this->pin_record_.sends++; + this->pin_step_ = PinStep::PIN_STEP_SAVING; + this->save_pin_record_(); + return SimResult::SIM_RESULT_RETRY; + + case PinStep::PIN_STEP_SAVING: { + // prepare_sim_() waited for the outcome + if (this->pin_save_state_.load(std::memory_order_acquire) != PinSave::PIN_SAVE_DONE) { + ESP_LOGE(TAG, "The PIN attempt could not be recorded; not sending it"); + this->pin_record_.sends--; + this->pin_step_ = PinStep::PIN_STEP_IDLE; + this->set_fault_(ModemFault::MODEM_FAULT_PIN_RECORD); + return SimResult::SIM_RESULT_HALT; + } + this->pin_step_ = PinStep::PIN_STEP_SENT; + this->state_started_ms_ = millis(); + std::string cmd = "AT+CPIN=\""; + cmd += this->pin_code_; + cmd += '"'; + if (this->at_(cmd.c_str(), PIN_TIMEOUT_MS) == command_result::FAIL) { + // A blind resend could PUK-lock the SIM, so a refusal uses up the count until pin_code changes + ESP_LOGE(TAG, "The SIM refused the PIN; check pin_code"); + this->pin_record_.sends = PIN_MAX_UNCONFIRMED_SENDS; + this->save_pin_record_(); + this->set_fault_(ModemFault::MODEM_FAULT_PIN_NOT_SENT); + return SimResult::SIM_RESULT_LOCKED; + } + return SimResult::SIM_RESULT_RETRY; // AT+CPIN? confirms READY on the next pass + } + + case PinStep::PIN_STEP_SENT: + // Some SIMs take seconds to unlock, so AT+CPIN? is asked again for a while + if (millis() - this->state_started_ms_ < PIN_UNLOCK_WAIT_MS) { + return SimResult::SIM_RESULT_RETRY; + } + ESP_LOGE(TAG, "SIM still locked after sending the PIN; check the SIM"); + if (this->pin_record_.sends >= PIN_MAX_UNCONFIRMED_SENDS) { + this->set_fault_(ModemFault::MODEM_FAULT_PIN_NOT_SENT); + return SimResult::SIM_RESULT_LOCKED; + } + // This attempt stops rather than sending the PIN blind again; a reboot sends it once more + this->set_fault_(ModemFault::MODEM_FAULT_SIM_LOCKED); + return SimResult::SIM_RESULT_HALT; + } + return SimResult::SIM_RESULT_RETRY; +} +#endif + +void ModemComponent::dump_connect_params_() { + esp_netif_ip_info_t ip; + if (!this->got_ip_.load(std::memory_order_acquire) || esp_netif_get_ip_info(this->ppp_netif_, &ip) != ESP_OK) { + ESP_LOGCONFIG(TAG, " Connection: not connected"); + return; + } + auto dns_str = [this](esp_netif_dns_type_t type, char *buf) -> const char * { + esp_netif_dns_info_t dns{}; + if (esp_netif_get_dns_info(this->ppp_netif_, type, &dns) != ESP_OK) { + return LOG_STR_LITERAL("unavailable"); + } + return network::IPAddress(&dns.ip.u_addr.ip4).str_to(buf); + }; + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char netmask_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char gw_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns_main_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns_backup_buf[network::IP_ADDRESS_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + " Connection:\n" + " IP Address: %s\n" + " Hostname: '%s'\n" + " Subnet: %s\n" + " Gateway: %s\n" + " DNS main: %s\n" + " DNS backup: %s", + network::IPAddress(&ip.ip).str_to(ip_buf), App.get_name().c_str(), + network::IPAddress(&ip.netmask).str_to(netmask_buf), network::IPAddress(&ip.gw).str_to(gw_buf), + dns_str(ESP_NETIF_DNS_MAIN, dns_main_buf), dns_str(ESP_NETIF_DNS_BACKUP, dns_backup_buf)); +} + +} // namespace esphome::modem + +#endif diff --git a/esphome/components/modem/modem_component.h b/esphome/components/modem/modem_component.h new file mode 100644 index 00000000000..fd48072653a --- /dev/null +++ b/esphome/components/modem/modem_component.h @@ -0,0 +1,282 @@ +#pragma once +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#ifdef USE_MODEM_URC +#include "esphome/core/line_reader.h" +#endif +#include "esphome/core/preferences.h" +#include "esphome/core/static_task.h" +#include "esphome/components/network/ip_address.h" +#ifdef USE_MODEM_URC +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +#include +#include + +#include +#include +#include +#include + +namespace esphome::modem { + +enum class ModemState : uint8_t { + MODEM_STATE_DISABLED, + MODEM_STATE_ENABLING, + MODEM_STATE_SYNCED, + MODEM_STATE_CONNECTING, + MODEM_STATE_WAIT_IP, + MODEM_STATE_CONNECTED, + MODEM_STATE_DISCONNECTING, +}; + +// CONNECTING runs its long commands once; the later passes only poll the attachment +enum class ConnectStep : uint8_t { + CONNECT_STEP_START, + CONNECT_STEP_RADIO_ON, + CONNECT_STEP_ATTACH_REQUESTED, +}; + +enum class SimResult : uint8_t { + SIM_RESULT_READY, + SIM_RESULT_RETRY, // status not readable yet, or a PIN step is in progress + SIM_RESULT_LOCKED, // nothing more to try: PUK, another lock, no pin_code or the PIN is not sent again + SIM_RESULT_HALT, // stop this attempt; a reboot may still open the SIM, so the reboot timer stays +}; + +// The step that failed most recently; CONNECTED clears it +enum class ModemFault : uint8_t { + MODEM_FAULT_NONE, + MODEM_FAULT_NO_RESPONSE, + MODEM_FAULT_BAUD, + MODEM_FAULT_INIT_AT, + MODEM_FAULT_SIM_NOT_READY, + MODEM_FAULT_SIM_LOCKED, + MODEM_FAULT_PIN_NOT_SENT, + MODEM_FAULT_PIN_RECORD, + MODEM_FAULT_SETUP, + MODEM_FAULT_NOT_ATTACHED, + MODEM_FAULT_PPP, + MODEM_FAULT_TEARDOWN, +}; + +#ifdef USE_MODEM_PIN_CODE +enum class PinStep : uint8_t { + PIN_STEP_IDLE, + PIN_STEP_SAVING, // the send is counted and waits for the record to reach flash + PIN_STEP_SENT, +}; + +// Written by loop() once it tried to save pin_record_; the task leaves the record alone while PENDING +enum class PinSave : uint8_t { + PIN_SAVE_DONE, + PIN_SAVE_PENDING, + PIN_SAVE_FAILED, +}; +#endif + +// Every esp_modem factory shares this signature, so codegen picks the one for the model +using DceFactory = std::unique_ptr (*)(const esp_modem::dce_config *, std::shared_ptr, + esp_netif_t *); + +class ModemComponent : public Component { + public: + ModemComponent(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, const char *model, DceFactory factory, + const char *apn); + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::WIFI + 1; } // Just before Wi-Fi + + void set_reboot_timeout(uint32_t timeout) { this->timeout_ = timeout; } + void set_use_address(const char *use_address) { this->use_address_ = use_address; } + void set_rts_pin(InternalGPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + void set_cts_pin(InternalGPIOPin *cts_pin) { this->cts_pin_ = cts_pin; } + void set_baud_rate(int baud_rate) { this->baud_rate_ = baud_rate; } +#ifdef USE_MODEM_PIN_CODE + void set_pin_code(const char *pin_code) { this->pin_code_ = pin_code; } +#endif + void set_tx_buffer_size(uint16_t tx_buffer_size) { this->tx_buffer_size_ = tx_buffer_size; } + void set_rx_buffer_size(uint16_t rx_buffer_size) { this->rx_buffer_size_ = rx_buffer_size; } + void set_dte_buffer_size(uint16_t dte_buffer_size) { this->dte_buffer_size_ = dte_buffer_size; } + void set_init_at_commands(const char *const *cmds, uint8_t count) { + this->init_at_commands_ = cmds; + this->init_at_count_ = count; + } +#ifdef USE_MODEM_URC + void set_urc_text_sensor(text_sensor::TextSensor *urc_text_sensor) { this->urc_text_sensor_ = urc_text_sensor; } +#endif + template void add_on_state_callback(F &&callback) { + this->on_state_callback_.add(std::forward(callback)); + } + + bool is_connected() const { return this->component_state_.load() == ModemState::MODEM_STATE_CONNECTED; } + bool is_disabled() const { + return this->component_state_.load() == ModemState::MODEM_STATE_DISABLED && + this->target_state_.load() == ModemState::MODEM_STATE_DISABLED; + } + bool is_enabled() const { return !this->is_disabled(); } + void enable(); + void disable(); +#ifdef USE_MODEM_SEND_AT + /// Queue an AT command for the modem task, which sends it at its next pass and logs the result and response. + /// Returns false when a command is still queued or the command is too long. + bool send_at(const char *cmd, uint32_t timeout = COMMAND_TIMEOUT_MS); + bool send_at(const std::string &cmd, uint32_t timeout = COMMAND_TIMEOUT_MS); +#endif + + network::IPAddresses get_ip_addresses(); + const char *get_use_address() const { return this->use_address_; } + + protected: + static constexpr uint32_t COMMAND_TIMEOUT_MS = 1000; + static constexpr uint32_t RETRY_DELAY_MS = 1000; + static constexpr size_t URC_LINE_SIZE = 128; + static constexpr size_t AT_COMMAND_SIZE = 128; + + // esp_modem's calls block until the modem answers or their own timeouts run out, up to a minute for a + // mode change, so the state machine runs on its own task and leaves the main loop alone + static void task_entry(void *arg); + void run_task_(); + void wake_task_(); + ModemState compute_next_state_(); + ModemState handle_state_enabling_(); + ModemState handle_state_synced_(); + ModemState handle_state_connecting_(); + ModemState handle_state_wait_ip_(); + ModemState handle_state_disconnecting_(); + /// Pause the task before the next pass, then go to state + uint32_t next_probe_delay_(); + ModemState retry_(ModemState state, uint32_t delay_ms = RETRY_DELAY_MS); + void transition_to_(ModemState next_state); + // What the task left for loop() to do + enum Pending : uint32_t { + PENDING_STATUS = 1 << 0, + PENDING_SAVE_BAUD = 1 << 1, +#ifdef USE_MODEM_PIN_CODE + PENDING_SAVE_PIN = 1 << 2, +#endif + PENDING_CONNECT_TIMEOUT = 1 << 3, // arm or cancel the reboot timer for the current state + PENDING_ON_SYNC = 1 << 4, // loop() wakes the task once on_sync ran + PENDING_ON_CONNECT = 1 << 5, + PENDING_ON_DISCONNECT = 1 << 6, + }; + + // Main loop only, so arming and cancelling cannot interleave + void arm_connect_timeout_(); + void cancel_connect_timeout_(); + void fail_setup_(const LogString *reason); + void set_fault_(ModemFault fault); + void set_teardown_fault_(); + void clear_config_fault_(ModemFault fault); + void hand_over_(uint32_t pending); + /// Send an AT command from the task and log the result; blocks for up to the timeout + esp_modem::command_result at_(const char *cmd, uint32_t timeout = COMMAND_TIMEOUT_MS); + void publish_status_(); + void not_responding_(int baud); + void dump_connect_params_(); + SimResult prepare_sim_(); +#ifdef USE_MODEM_PIN_CODE + SimResult send_pin_(); + void save_pin_record_(); +#endif + + InternalGPIOPin *tx_pin_; + InternalGPIOPin *rx_pin_; + InternalGPIOPin *rts_pin_{nullptr}; + InternalGPIOPin *cts_pin_{nullptr}; + const char *model_; + DceFactory dce_factory_; + const char *apn_; + const char *const *init_at_commands_{nullptr}; // static array from code generation +#ifdef USE_MODEM_URC + text_sensor::TextSensor *urc_text_sensor_{nullptr}; + const char *urc_line_{nullptr}; // the line urc_pending_ hands to loop(), inside urc_reader_ +#endif + std::unique_ptr dce_; + std::shared_ptr dte_; + esp_netif_t *ppp_netif_{nullptr}; + ESPPreferenceObject pref_; + LazyCallbackManager on_state_callback_; + int baud_rate_{0}; // 0 = use the esp_modem default + int current_baud_rate_{0}; + int restored_baud_{0}; + StaticTask task_; + uint32_t timeout_{0}; + uint32_t step_delay_ms_{0}; + uint32_t state_started_ms_{0}; // when the IP wait or the PIN unlock wait began + std::atomic pending_{0}; +#ifdef USE_MODEM_PIN_CODE + const char *pin_code_{nullptr}; + ESPPreferenceObject pin_pref_; +#endif + // Kept in flash so reboots cannot keep spending the SIM's attempts on a PIN it never confirmed + struct PinRecord { + uint32_t hash; // pin_code the record is about + uint8_t sends; // AT+CPIN= sent without the SIM reporting READY since + }; +#ifdef USE_MODEM_PIN_CODE + PinRecord pin_record_{}; // the task writes it, loop() saves it while pin_save_state_ is PENDING +#endif +#ifdef USE_MODEM_SEND_AT + uint32_t at_timeout_{COMMAND_TIMEOUT_MS}; +#endif + uint16_t tx_buffer_size_{1024}; + uint16_t rx_buffer_size_{1024}; + uint16_t dte_buffer_size_{1024}; + // The task owns component_state_; the main loop sets target_state_ and reads both + std::atomic component_state_{ModemState::MODEM_STATE_DISABLED}; + std::atomic target_state_{ModemState::MODEM_STATE_DISABLED}; + // Written by the task only, read by loop() + std::atomic fault_{ModemFault::MODEM_FAULT_NONE}; + // A failed baud_rate or init_at step, shown once fault_ is clear until the step passes again + std::atomic config_fault_{ModemFault::MODEM_FAULT_NONE}; + // std::atomic flags: GCC on Xtensa calls out of line for std::atomic + // Written by the IP event task, read by the modem task + std::atomic got_ip_{0}; + std::atomic ppp_error_{0}; // last NETIF_PPP_STATUS error code, 0 = none + // Doublings of the silent-modem probe delay; enable() and an answer reset it + std::atomic probe_backoff_{0}; + // Set by loop() once on_sync ran; the task waits for it, not for any notification + std::atomic sync_acked_{0}; +#ifdef USE_MODEM_SEND_AT + // Set by send_at() on the main loop once at_command_ is written, cleared by the task after sending it + std::atomic at_pending_{0}; +#endif +#ifdef USE_MODEM_URC + // Set by the UART task with urc_line_; until loop() has published it, new lines are dropped + std::atomic urc_pending_{0}; +#endif +#ifdef USE_MODEM_PIN_CODE + std::atomic pin_save_state_{PinSave::PIN_SAVE_DONE}; + PinStep pin_step_{PinStep::PIN_STEP_IDLE}; + bool pin_record_unsaved_{false}; // loop() only; the last PIN record save failed +#endif + ConnectStep connect_step_{ConnectStep::CONNECT_STEP_START}; + uint8_t init_at_count_{0}; + uint8_t uart_port_{UART_NUM_MAX}; + uint8_t attach_polls_{0}; + bool connect_timeout_armed_{false}; + bool modem_synced_{false}; // a modem that never answered has nothing to tear down + bool resync_{false}; // the attachment read failed, so CONNECTING syncs again first +#ifdef USE_MODEM_SEND_AT + char at_command_[AT_COMMAND_SIZE]; +#endif +#ifdef USE_MODEM_URC + // Fed on the UART task + LineReader urc_reader_; +#endif + + private: + // Points at a string literal from code generation, never at dynamic storage + const char *use_address_{""}; +}; + +extern ModemComponent *global_modem_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::modem + +#endif diff --git a/esphome/components/modem/text_sensor.py b/esphome/components/modem/text_sensor.py new file mode 100644 index 00000000000..6cac7b19078 --- /dev/null +++ b/esphome/components/modem/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +from esphome.components.esp32 import add_idf_sdkconfig_option +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_MODEM_ID, ModemComponent + +DEPENDENCIES = ["modem"] + +CONF_URC = "urc" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MODEM_ID): cv.use_id(ModemComponent), + cv.Optional(CONF_URC): text_sensor.text_sensor_schema(), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_MODEM_ID]) + texts = text_sensor.sub_text_sensors(config) + if await texts(CONF_URC, hub.set_urc_text_sensor) is not None: + cg.add_define("USE_MODEM_URC") + cg.add_define("USE_LINE_READER") + add_idf_sdkconfig_option("CONFIG_ESP_MODEM_URC_HANDLER", True) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index ea71d430ec9..b7472804bf2 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_ETHERNET, CONF_OPENTHREAD, CONF_PORT, + CONF_PRIORITY, CONF_USE_ADDRESS, CONF_WEB_SERVER, CONF_WIFI, @@ -767,7 +768,13 @@ class EsphomeCore: if self.config is None: raise ValueError("Config has not been loaded yet") - for network_type in (CONF_WIFI, CONF_ETHERNET, CONF_OPENTHREAD): + # Matches network::get_use_address_to(), with modem and ethernet never both present + # (CONFLICTS_WITH); a wifi-first priority list lifts wifi, the rule that emits + # USE_NETWORK_PRIMARY_INTERFACE_WIFI in network/__init__.py + priority = self.config.get("network", {}).get(CONF_PRIORITY, []) + wifi_first = bool(priority) and priority[0]["interface"] == CONF_WIFI + first = (CONF_WIFI, "modem") if wifi_first else ("modem", CONF_WIFI) + for network_type in (*first, CONF_ETHERNET, CONF_OPENTHREAD): if network_type in self.config: return self.config[network_type][CONF_USE_ADDRESS] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0f622e5202e..011672b3036 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -610,6 +610,13 @@ #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 +#ifdef USE_ESP32 +#define USE_MODEM +#define USE_MODEM_PIN_CODE +#define USE_MODEM_SEND_AT +#define USE_MODEM_URC +#endif + #ifdef USE_ESP32 #define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index cf41c3cb41c..03aa42f6bd5 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -25,6 +25,8 @@ dependencies: version: 1.1.2 espressif/esp32-camera: version: 2.1.7 + espressif/esp_modem: + version: 2.1.0 espressif/mdns: version: 1.14.0 espressif/esp_wifi_remote: diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fd60b2452c6..c0d3e67901c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -98,6 +98,7 @@ ISOLATED_COMPONENTS = { "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", + "modem": "Cellular-only node with api - grouping with wifi-bearing components hides that case", "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", diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 25c4cc4d36c..6c3e44534df 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -26,3 +26,7 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_SPIRAM=y + +# modem +CONFIG_LWIP_PPP_SUPPORT=y +CONFIG_ESP_MODEM_URC_HANDLER=y diff --git a/tests/components/modem/common.yaml b/tests/components/modem/common.yaml new file mode 100644 index 00000000000..36e8c1bfc2e --- /dev/null +++ b/tests/components/modem/common.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + priority: -100 # the modem is set up by then, so send_at reaches the DCE + then: + - modem.disable + - modem.enable + - if: + condition: + modem.enabled: + then: + modem.send_at: "AT+CGSN" + else: + modem.enable: + - if: + condition: + not: + modem.connected: + then: + logger.log: modem not connected diff --git a/tests/components/modem/test.esp32-idf.yaml b/tests/components/modem/test.esp32-idf.yaml new file mode 100644 index 00000000000..50dcdcd5428 --- /dev/null +++ b/tests/components/modem/test.esp32-idf.yaml @@ -0,0 +1,32 @@ +logger: + +# No wifi: a cellular-only node must satisfy the api network dependency +api: + +modem: + id: atmodem + rx_pin: GPIO26 + tx_pin: GPIO27 + rts_pin: GPIO25 + cts_pin: GPIO33 + baud_rate: 115200 + model: SIM7600 + apn: orange + pin_code: "0000" + init_at: + - ATI + reboot_timeout: 5min + on_connect: + logger.log: "modem connected" + on_disconnect: + logger.log: "modem disconnected" + on_sync: + logger.log: "modem synced - ready for AT commands" + +text_sensor: + - platform: modem + urc: + name: Modem URC + +packages: + modem: !include common.yaml diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index ff36f1da18e..1547155c5be 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -593,6 +593,26 @@ class TestEsphomeCore: assert target.address == "1.2.3.4" + @pytest.mark.parametrize( + ("priority", "expected"), + [ + (None, "10.0.0.1"), + ("ethernet", "10.0.0.1"), + ("wifi", "1.2.3.4"), + ], + ) + def test_address__modem( + self, target: core.EsphomeCore, priority: str | None, expected: str + ) -> None: + target.config = { + const.CONF_WIFI: {const.CONF_USE_ADDRESS: "1.2.3.4"}, + "modem": {const.CONF_USE_ADDRESS: "10.0.0.1"}, + } + if priority is not None: + target.config["network"] = {const.CONF_PRIORITY: [{"interface": priority}]} + + assert target.address == expected + def test_address__ethernet(self, target): target.config = {} target.config[const.CONF_ETHERNET] = {const.CONF_USE_ADDRESS: "4.3.2.1"} From 31a43f13d7c484d04776f64999df97031d37fd41 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:55:42 -0400 Subject: [PATCH 03/37] [ecocomfort2] Only compile the fan when a fan platform is configured (#21517) --- esphome/components/ecocomfort2/ecocomfort2_fan.cpp | 4 ++-- esphome/components/ecocomfort2/ecocomfort2_fan.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp index d41bd311b7d..e7f8fee707d 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -1,5 +1,5 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_FAN) #include "ecocomfort2_fan.h" #include "esphome/core/log.h" @@ -138,4 +138,4 @@ void Ecocomfort2Fan::on_status() { } // namespace esphome::ecocomfort2 -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_FAN diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h index 81af55699fe..eb3c60e6734 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.h +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_FAN) #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" @@ -23,4 +23,4 @@ class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Compone } // namespace esphome::ecocomfort2 -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_FAN From 55a62993c832653ea2eb1a94f2b86a757b8895fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:03 -0400 Subject: [PATCH 04/37] [ezo_pmp] Guard entity pointer uses so the hub builds without sensors (#21516) --- esphome/components/ezo_pmp/ezo_pmp.cpp | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index ef4bf30295c..4e3de1f8127 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -48,12 +48,16 @@ void EzoPMP::update() { } if (this->is_first_read_) { +#ifdef USE_TEXT_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_CALIBRATION_STATUS, 0, 0, (bool) this->calibration_status_); +#endif +#ifdef USE_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_MAX_FLOW_RATE, 0, 0, (bool) this->max_flow_rate_); this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); this->queue_command_(EZO_PMP_COMMAND_READ_TOTAL_VOLUME_DOSED, 0, 0, (bool) this->total_volume_dosed_); this->queue_command_(EZO_PMP_COMMAND_READ_ABSOLUTE_TOTAL_VOLUME_DOSED, 0, 0, (bool) this->absolute_total_volume_dosed_); +#endif this->queue_command_(EZO_PMP_COMMAND_READ_PAUSE_STATUS, 0, 0, true); this->is_first_read_ = false; } @@ -61,6 +65,7 @@ void EzoPMP::update() { if (!this->is_waiting_ && this->peek_next_command_() == EZO_PMP_COMMAND_NONE) { this->queue_command_(EZO_PMP_COMMAND_READ_DOSING, 0, 0, true); +#ifdef USE_SENSOR if (this->is_dosing_flag_) { this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); this->queue_command_(EZO_PMP_COMMAND_READ_TOTAL_VOLUME_DOSED, 0, 0, (bool) this->total_volume_dosed_); @@ -69,6 +74,7 @@ void EzoPMP::update() { } this->queue_command_(EZO_PMP_COMMAND_READ_PUMP_VOLTAGE, 0, 0, (bool) this->pump_voltage_); +#endif } else { ESP_LOGV(TAG, "Not Scheduling new Command during update()"); } @@ -192,51 +198,68 @@ void EzoPMP::read_command_result_() { if (parsed_third_parameter.has_value()) this->is_dosing_flag_ = parsed_third_parameter.value_or(0) == 1; +#ifdef USE_BINARY_SENSOR if (this->is_dosing_) this->is_dosing_->publish_state(this->is_dosing_flag_); +#endif +#ifdef USE_SENSOR if (parsed_second_parameter.has_value() && this->last_volume_requested_) { this->last_volume_requested_->publish_state(parsed_second_parameter.value_or(0)); } +#endif +#ifdef USE_TEXT_SENSOR if (!this->is_dosing_flag_ && !this->is_paused_flag_) { // If pump is not paused and not dispensing if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_NONE) this->dosing_mode_->publish_state(DOSING_MODE_NONE); } +#endif break; case EZO_PMP_COMMAND_READ_SINGLE_REPORT: // Single Report (page 53) +#ifdef USE_SENSOR if (parsed_first_parameter.has_value() && (bool) this->current_volume_dosed_) { this->current_volume_dosed_->publish_state(parsed_first_parameter.value_or(0)); } +#endif break; case EZO_PMP_COMMAND_READ_MAX_FLOW_RATE: // Constant Flow Rate (page 57) +#ifdef USE_SENSOR if (parsed_second_parameter.has_value() && this->max_flow_rate_) this->max_flow_rate_->publish_state(parsed_second_parameter.value_or(0)); +#endif break; case EZO_PMP_COMMAND_READ_PAUSE_STATUS: // Pause (page 61) if (parsed_second_parameter.has_value()) this->is_paused_flag_ = parsed_second_parameter.value_or(0) == 1; +#ifdef USE_BINARY_SENSOR if (this->is_paused_) this->is_paused_->publish_state(this->is_paused_flag_); +#endif break; case EZO_PMP_COMMAND_READ_TOTAL_VOLUME_DOSED: // Total Volume Dispensed (page 64) +#ifdef USE_SENSOR if (parsed_second_parameter.has_value() && this->total_volume_dosed_) this->total_volume_dosed_->publish_state(parsed_second_parameter.value_or(0)); +#endif break; case EZO_PMP_COMMAND_READ_ABSOLUTE_TOTAL_VOLUME_DOSED: // Total Volume Dispensed (page 64) +#ifdef USE_SENSOR if (parsed_second_parameter.has_value() && this->absolute_total_volume_dosed_) this->absolute_total_volume_dosed_->publish_state(parsed_second_parameter.value_or(0)); +#endif break; case EZO_PMP_COMMAND_READ_CALIBRATION_STATUS: // Calibration (page 65) +#ifdef USE_TEXT_SENSOR if (parsed_second_parameter.has_value() && this->calibration_status_) { if (parsed_second_parameter.value_or(0) == 1) { this->calibration_status_->publish_state("Fixed Volume"); @@ -248,41 +271,56 @@ void EzoPMP::read_command_result_() { this->calibration_status_->publish_state("Uncalibrated"); } } +#endif break; case EZO_PMP_COMMAND_READ_PUMP_VOLTAGE: // Pump Voltage (page 67) +#ifdef USE_SENSOR if (parsed_second_parameter.has_value() && this->pump_voltage_) this->pump_voltage_->publish_state(parsed_second_parameter.value_or(0)); +#endif break; // Non-Read Commands case EZO_PMP_COMMAND_DOSE_VOLUME: // Volume Dispensing (page 55) +#ifdef USE_TEXT_SENSOR if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_VOLUME) this->dosing_mode_->publish_state(DOSING_MODE_VOLUME); +#endif break; case EZO_PMP_COMMAND_DOSE_VOLUME_OVER_TIME: // Dose over time (page 56) +#ifdef USE_TEXT_SENSOR if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_VOLUME_OVER_TIME) this->dosing_mode_->publish_state(DOSING_MODE_VOLUME_OVER_TIME); +#endif break; case EZO_PMP_COMMAND_DOSE_WITH_CONSTANT_FLOW_RATE: // Constant Flow Rate (page 57) +#ifdef USE_TEXT_SENSOR if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_CONSTANT_FLOW_RATE) this->dosing_mode_->publish_state(DOSING_MODE_CONSTANT_FLOW_RATE); +#endif break; case EZO_PMP_COMMAND_DOSE_CONTINUOUSLY: // Continuous Dispensing (page 54) +#ifdef USE_TEXT_SENSOR if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_CONTINUOUS) this->dosing_mode_->publish_state(DOSING_MODE_CONTINUOUS); +#endif break; case EZO_PMP_COMMAND_STOP_DOSING: // Stop (page 62) this->is_paused_flag_ = false; +#ifdef USE_BINARY_SENSOR if (this->is_paused_) this->is_paused_->publish_state(this->is_paused_flag_); +#endif +#ifdef USE_TEXT_SENSOR if (this->dosing_mode_ && this->dosing_mode_->state != DOSING_MODE_NONE) this->dosing_mode_->publish_state(DOSING_MODE_NONE); +#endif break; case EZO_PMP_COMMAND_EXEC_ARBITRARY_COMMAND_ADDRESS: @@ -489,25 +527,33 @@ void EzoPMP::find() { this->queue_command_(EZO_PMP_COMMAND_FIND, 0, 0, true); } void EzoPMP::dose_continuously() { this->queue_command_(EZO_PMP_COMMAND_DOSE_CONTINUOUSLY, 0, 0, true); this->queue_command_(EZO_PMP_COMMAND_READ_DOSING, 0, 0, true); +#ifdef USE_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); +#endif } void EzoPMP::dose_volume(double volume) { this->queue_command_(EZO_PMP_COMMAND_DOSE_VOLUME, volume, 0, true); this->queue_command_(EZO_PMP_COMMAND_READ_DOSING, 0, 0, true); +#ifdef USE_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); +#endif } void EzoPMP::dose_volume_over_time(double volume, int duration) { this->queue_command_(EZO_PMP_COMMAND_DOSE_VOLUME_OVER_TIME, volume, duration, true); this->queue_command_(EZO_PMP_COMMAND_READ_DOSING, 0, 0, true); +#ifdef USE_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); +#endif } void EzoPMP::dose_with_constant_flow_rate(double volume, int duration) { this->queue_command_(EZO_PMP_COMMAND_DOSE_WITH_CONSTANT_FLOW_RATE, volume, duration, true); this->queue_command_(EZO_PMP_COMMAND_READ_DOSING, 0, 0, true); +#ifdef USE_SENSOR this->queue_command_(EZO_PMP_COMMAND_READ_SINGLE_REPORT, 0, 0, (bool) this->current_volume_dosed_); +#endif } void EzoPMP::set_calibration_volume(double volume) { From a72adc7573377e89e1703b509a232984352e1030 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:23:35 -0400 Subject: [PATCH 05/37] [libretiny_pwm] Reject LN882H at config time (#21498) --- esphome/components/libretiny_pwm/output.py | 20 +++++++++++-------- .../component_tests/libretiny_pwm/__init__.py | 0 .../libretiny_pwm/test_family.py | 14 +++++++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/libretiny_pwm/__init__.py create mode 100644 tests/component_tests/libretiny_pwm/test_family.py diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 3bac2241578..97895b0f71a 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import output +from esphome.components import libretiny, output +from esphome.components.libretiny.const import FAMILY_LN882H import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN from esphome.types import ConfigType @@ -12,13 +13,16 @@ LibreTinyPWM = libretinypwm_ns.class_("LibreTinyPWM", output.FloatOutput, cg.Com validate_frequency = cv.All(cv.frequency, cv.float_range(min=0, min_included=False)) -CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM), - cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = cv.All( + libretiny.only_on_family(unsupported=FAMILY_LN882H), + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + } + ).extend(cv.COMPONENT_SCHEMA), +) async def to_code(config: ConfigType) -> None: diff --git a/tests/component_tests/libretiny_pwm/__init__.py b/tests/component_tests/libretiny_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/libretiny_pwm/test_family.py b/tests/component_tests/libretiny_pwm/test_family.py new file mode 100644 index 00000000000..5f02b4825a6 --- /dev/null +++ b/tests/component_tests/libretiny_pwm/test_family.py @@ -0,0 +1,14 @@ +"""libretiny_pwm rejects LN882H: LibreTiny has no analogWrite for that family.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.libretiny.const import FAMILY_LN882H, KEY_FAMILY, KEY_LIBRETINY +from esphome.components.libretiny_pwm.output import CONFIG_SCHEMA +from esphome.core import CORE + + +def test_ln882h_is_rejected() -> None: + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: FAMILY_LN882H} + with pytest.raises(cv.Invalid, match="not available on LN882H"): + CONFIG_SCHEMA({"id": "pwm", "pin": "PA5"}) From f994fbde04df4567699782382800747baf74c99b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:51:12 -0400 Subject: [PATCH 06/37] [esphome] Reject the esphome OTA platform on targets without an OTA backend (#21515) --- esphome/components/esphome/ota/__init__.py | 33 +++++++++++++++++++ tests/component_tests/ota/test_esphome_ota.py | 21 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 7a0d03df529..8fdf2a6669b 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -30,6 +30,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, CONF_WEB_SERVER, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2, + PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -276,6 +283,31 @@ def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: ) +# Platforms with an OTA flash backend (ota_backend_factory.h) +_SUPPORTED_PLATFORMS = [ + PLATFORM_ESP8266, + PLATFORM_ESP32, + PLATFORM_RP2, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + PLATFORM_HOST, +] + + +def _validate_platform(config: ConfigType) -> ConfigType: + if CORE.target_platform in _SUPPORTED_PLATFORMS: + return config + hint = ( + f"; use '{CONF_PLATFORM}: zephyr_mcumgr' for OTA updates on nRF52" + if CORE.is_nrf52 + else "" + ) + raise cv.Invalid( + f"'{CONF_PLATFORM}: {DOMAIN}' is not supported on {CORE.target_platform}{hint}" + ) + + def _consume_ota_sockets(config: ConfigType) -> ConfigType: """Register socket needs for OTA component.""" from esphome.components import socket @@ -287,6 +319,7 @@ def _consume_ota_sockets(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( + _validate_platform, cv.Schema( { cv.GenerateID(): cv.declare_id(ESPHomeOTAComponent), diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 39920ff4363..bc4850f2e84 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -11,6 +11,7 @@ import pytest from esphome import config_validation as cv from esphome.components.esphome.ota import ( AUTO_LOAD, + CONFIG_SCHEMA, FILTER_SOURCE_FILES, _encryption_schema, _validate_no_password_with_encryption, @@ -28,10 +29,12 @@ from esphome.const import ( CONF_PLATFORM, CONF_PORT, CONF_VERSION, + PlatformFramework, ) from esphome.core import CORE, ID from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv +from tests.component_tests.types import SetCoreConfigCallable def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]: @@ -674,3 +677,21 @@ def test_merged_password_and_encryption_rejected() -> None: ota_esphome_final_validate({}) finally: fv.full_config.reset(token) + + +def test_unsupported_platform_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Platforms without an OTA backend fail validation instead of code generation.""" + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises( + cv.Invalid, + match=r"not supported on nrf52; use 'platform: zephyr_mcumgr'", + ): + CONFIG_SCHEMA({}) + + +def test_supported_platform_gets_port_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """A supported platform keeps its per-platform port default.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + assert CONFIG_SCHEMA({})[CONF_PORT] == 8266 From ed882741bc803d7386a8a6235a45b8cbe15b9ca5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 13:03:05 -1000 Subject: [PATCH 07/37] [remote_base] Let BitTimings vary the mark per bit and send the space first (#21511) --- esphome/components/remote_base/remote_base.h | 71 +++++++++---- .../remote_base/byte_items_test.cpp | 99 +++++++++++++++++++ 2 files changed, 152 insertions(+), 18 deletions(-) diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index cc3edf7dced..fdaf06954d9 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -24,12 +24,16 @@ enum class BitOrder : uint8_t { BIT_ORDER_MSB_FIRST, }; -/// A bit is bit_mark followed by one_space or zero_space. +/// A bit is a mark (one_mark or zero_mark, both bit_mark unless set) and a space (one_space or zero_space), +/// mark first unless space_first. struct BitTimings { - uint32_t bit_mark; + uint32_t bit_mark{0}; uint32_t one_space; uint32_t zero_space; BitOrder bit_order{BitOrder::BIT_ORDER_LSB_FIRST}; + uint32_t one_mark{bit_mark}; + uint32_t zero_mark{bit_mark}; + bool space_first{false}; }; class RemoteTransmitData { @@ -43,10 +47,17 @@ class RemoteTransmitData { void reserve(uint32_t len) { this->data_.reserve(len); } /// One item per bit of each byte. template void item_bytes(const uint8_t *bytes, size_t len) { + static_assert(T.one_mark != 0 && T.zero_mark != 0, "set bit_mark, or one_mark and zero_mark"); for (size_t i = 0; i < len; i++) { for (uint8_t bit = 0; bit < 8; bit++) { const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); - this->item(T.bit_mark, (bytes[i] & mask) ? T.one_space : T.zero_space); + const bool one = (bytes[i] & mask) != 0; + if constexpr (T.space_first) { + this->space(one ? T.one_space : T.zero_space); + this->mark(one ? T.one_mark : T.zero_mark); + } else { + this->item(one ? T.one_mark : T.zero_mark, one ? T.one_space : T.zero_space); + } } } } @@ -99,21 +110,12 @@ class RemoteReceiveData { bool expect_space(uint32_t length); bool expect_item(uint32_t mark, uint32_t space); bool expect_pulse_with_gap(uint32_t mark, uint32_t space); - /// False at the first item matching neither space, leaving it unread. - template bool expect_bytes(uint8_t *out, size_t len) { - for (size_t i = 0; i < len; i++) { - uint8_t byte = 0; - for (uint8_t bit = 0; bit < 8; bit++) { - const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); - if (this->expect_item(T.bit_mark, T.one_space)) { - byte |= mask; - } else if (!this->expect_item(T.bit_mark, T.zero_space)) { - return false; - } - } - out[i] = byte; - } - return true; + /// False at the first bit matching neither the one nor the zero timings, leaving it unread. + template bool expect_bytes(uint8_t *out, size_t len) { return this->expect_bytes_(out, len); } + /// As expect_bytes, but the last bit is only its mark: its space runs into the gap after the frame. + template bool expect_bytes_last_mark(uint8_t *out, size_t len) { + static_assert(!T.space_first && T.one_mark != T.zero_mark, "the last bit must be told apart by its mark"); + return this->expect_bytes_(out, len); } void advance(uint32_t amount = 1) { this->index_ += amount; } void reset() { this->index_ = 0; } @@ -126,6 +128,39 @@ class RemoteReceiveData { ToleranceMode get_tolerance_mode() { return this->tolerance_mode_; } protected: + template bool expect_bytes_(uint8_t *out, size_t len) { + static_assert(T.one_mark != 0 && T.zero_mark != 0, "set bit_mark, or one_mark and zero_mark"); + for (size_t i = 0; i < len; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); + if (LAST_MARK && i + 1 == len && bit == 7) { + if (this->expect_mark(T.one_mark)) { + byte |= mask; + } else if (!this->expect_mark(T.zero_mark)) { + return false; + } + } else if (this->expect_bit_(T.one_mark, T.one_space)) { + byte |= mask; + } else if (!this->expect_bit_(T.zero_mark, T.zero_space)) { + return false; + } + } + out[i] = byte; + } + return true; + } + /// One bit in T's order, consumed only when both its parts match. + template bool expect_bit_(uint32_t mark, uint32_t space) { + if constexpr (T.space_first) { + if (!this->peek_space(space) || !this->peek_mark(mark, 1)) + return false; + this->advance(2); + return true; + } else { + return this->expect_item(mark, space); + } + } int32_t lower_bound_(uint32_t length) const { if (this->tolerance_mode_ == TOLERANCE_MODE_TIME) { return int32_t(length - this->tolerance_); diff --git a/tests/components/remote_base/byte_items_test.cpp b/tests/components/remote_base/byte_items_test.cpp index b4f40638478..b52e9e2e842 100644 --- a/tests/components/remote_base/byte_items_test.cpp +++ b/tests/components/remote_base/byte_items_test.cpp @@ -13,6 +13,13 @@ constexpr BitTimings TIMINGS{.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero constexpr BitTimings MSB_TIMINGS{ .bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE, .bit_order = BitOrder::BIT_ORDER_MSB_FIRST}; constexpr std::array FRAME = {0xA5, 0x01, 0x80, 0xFF}; +// Mark length carries the bit (one long mark, zero short), as in roomba +constexpr BitTimings MARK_TIMINGS{.one_space = 1000, .zero_space = 3000, .one_mark = 3000, .zero_mark = 1000}; +// Space first and both lengths vary, as in Cardin S438 +constexpr BitTimings SPACE_FIRST_TIMINGS{ + .one_space = 750, .zero_space = 1460, .one_mark = 1460, .zero_mark = 750, .space_first = true}; + +static_assert(TIMINGS.one_mark == BIT_MARK && TIMINGS.zero_mark == BIT_MARK); RemoteReceiveData receive(const RawTimings &timings) { return RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE); @@ -123,4 +130,96 @@ TEST(RemoteBaseByteItemsTest, ZeroLength) { EXPECT_EQ(rx.get_index(), 0u); } +TEST(RemoteBaseByteItemsTest, PerBitMarkSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x01; + tx.item_bytes(&byte, 1); + const RawTimings &data = tx.get_data(); + ASSERT_EQ(data.size(), 16u); + EXPECT_EQ(data[0], 3000); + EXPECT_EQ(data[1], -1000); + EXPECT_EQ(data[2], 1000); + EXPECT_EQ(data[3], -3000); +} + +TEST(RemoteBaseByteItemsTest, SpaceFirstSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x01; + tx.item_bytes(&byte, 1); + const RawTimings &data = tx.get_data(); + ASSERT_EQ(data.size(), 16u); + EXPECT_EQ(data[0], -750); + EXPECT_EQ(data[1], 1460); + EXPECT_EQ(data[2], -1460); + EXPECT_EQ(data[3], 750); +} + +TEST(RemoteBaseByteItemsTest, PerBitMarkRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); +} + +TEST(RemoteBaseByteItemsTest, SpaceFirstRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); + EXPECT_EQ(rx.get_index(), tx.get_data().size()); +} + +TEST(RemoteBaseByteItemsTest, SpaceFirstLeavesABitWithTheWrongMarkUnread) { + // A one's space followed by a zero's mark is neither bit + const RawTimings timings = {-750, 750}; + auto rx = receive(timings); + uint8_t out = 0; + EXPECT_FALSE(rx.expect_bytes(&out, 1)); + EXPECT_EQ(rx.get_index(), 0u); +} + +TEST(RemoteBaseByteItemsTest, LastMarkReadsAFrameWithoutItsFinalSpace) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + RawTimings timings = tx.get_data(); + timings.pop_back(); // the last space runs into the gap + auto rx = receive(timings); + std::array out{}; + EXPECT_FALSE(rx.expect_bytes(out.data(), out.size())); + + auto last_mark_rx = receive(timings); + ASSERT_TRUE(last_mark_rx.expect_bytes_last_mark(out.data(), out.size())); + EXPECT_EQ(out, FRAME); + EXPECT_EQ(last_mark_rx.get_index(), timings.size()); +} + +TEST(RemoteBaseByteItemsTest, LastMarkAcceptsAFinalZeroBit) { + RemoteTransmitData tx; + const uint8_t byte = 0x7F; // LSB first, so the last bit sent is a zero + tx.item_bytes(&byte, 1); + RawTimings timings = tx.get_data(); + timings.pop_back(); + auto rx = receive(timings); + uint8_t out = 0; + ASSERT_TRUE(rx.expect_bytes_last_mark(&out, 1)); + EXPECT_EQ(out, byte); + EXPECT_EQ(rx.get_index(), timings.size()); +} + +TEST(RemoteBaseByteItemsTest, LastMarkRejectsAFinalMarkOfNeitherLength) { + RemoteTransmitData tx; + const uint8_t byte = 0x00; + tx.item_bytes(&byte, 1); + RawTimings timings = tx.get_data(); + timings.pop_back(); + timings.back() = 2000; + auto rx = receive(timings); + uint8_t out = 0; + EXPECT_FALSE(rx.expect_bytes_last_mark(&out, 1)); +} + } // namespace esphome::remote_base::testing From 53357c92f80a44f46c2f05900c293a3eb349012c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 13:04:50 -1000 Subject: [PATCH 08/37] [light] Keep strobe and color wipe colors in flash tables (#20222) --- esphome/codegen.py | 1 + .../light/addressable_light_effect.h | 51 ++++++++--- esphome/components/light/base_light_effects.h | 42 +++++++-- esphome/components/light/effects.py | 90 +++++++++++++------ esphome/cpp_generator.py | 19 ++-- .../light/config/effect_color_tables.yaml | 44 +++++++++ .../light/test_effect_color_tables.py | 52 +++++++++++ 7 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 tests/component_tests/light/config/effect_color_tables.yaml create mode 100644 tests/component_tests/light/test_effect_color_tables.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 1fd9379212e..0104e9bdc68 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -49,6 +49,7 @@ from esphome.cpp_generator import ( # noqa: F401 static_const_array, static_function, templatable, + unique_global_id, variable, with_local_variable, ) diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 283b037acad..2dbd31e1eb3 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/light/light_state.h" #include "esphome/components/light/addressable_light.h" @@ -101,17 +103,28 @@ class AddressableRainbowLightEffect : public AddressableLightEffect { uint16_t width_{50}; }; +/// One wipe color. Codegen keeps the colors in a flash table, so it is read with progmem_memcpy. struct AddressableColorWipeEffectColor { uint8_t r, g, b, w; - bool random; - size_t num_leds; + uint8_t random_slot; ///< index into the effect's random colors, or NO_RANDOM_SLOT bool gradient; + uint32_t num_leds; }; +static_assert(std::is_trivially_copyable_v, + "AddressableColorWipeEffectColor is copied out of flash"); +static_assert(sizeof(AddressableColorWipeEffectColor) % sizeof(uint32_t) == 0, + "AddressableColorWipeEffectColor must be whole words for ESP8266 flash reads"); +static constexpr uint8_t NO_RANDOM_SLOT = 0xFF; class AddressableColorWipeEffect : public AddressableLightEffect { public: explicit AddressableColorWipeEffect(const char *name) : AddressableLightEffect(name) {} - void set_colors(const std::initializer_list &colors) { this->colors_ = colors; } + /// Codegen only: point at the generated flash table of colors, which must outlive the effect. + /// Random colors keep their current value in a RAM array, one slot per random entry. + void set_colors(const AddressableColorWipeEffectColor *colors, size_t count, Color *random_colors) { + this->colors_ = {colors, count}; + this->random_colors_ = random_colors; + } void set_add_led_interval(uint32_t add_led_interval) { this->add_led_interval_ = add_led_interval; } void set_reverse(bool reverse) { this->reverse_ = reverse; } void apply(AddressableLight &it, const Color ¤t_color) override { @@ -124,12 +137,11 @@ class AddressableColorWipeEffect : public AddressableLightEffect { } else { it.shift_right(1); } - const AddressableColorWipeEffectColor &color = this->colors_[this->at_color_]; - Color esp_color = Color(color.r, color.g, color.b, color.w); + const AddressableColorWipeEffectColor color = this->entry_(this->at_color_); + Color esp_color = this->color_of_(color); if (color.gradient) { size_t next_color_index = (this->at_color_ + 1) % this->colors_.size(); - const AddressableColorWipeEffectColor &next_color = this->colors_[next_color_index]; - const Color next_esp_color = Color(next_color.r, next_color.g, next_color.b, next_color.w); + const Color next_esp_color = this->color_of_(this->entry_(next_color_index)); uint8_t gradient = 255 * ((float) this->leds_added_ / color.num_leds); esp_color = esp_color.gradient(next_esp_color, gradient); } @@ -141,19 +153,32 @@ class AddressableColorWipeEffect : public AddressableLightEffect { if (++this->leds_added_ >= color.num_leds) { this->leds_added_ = 0; this->at_color_ = (this->at_color_ + 1) % this->colors_.size(); - AddressableColorWipeEffectColor &new_color = this->colors_[this->at_color_]; - if (new_color.random) { + const AddressableColorWipeEffectColor new_color = this->entry_(this->at_color_); + if (new_color.random_slot != NO_RANDOM_SLOT) { Color c = Color::random_color(); - new_color.r = c.r; - new_color.g = c.g; - new_color.b = c.b; + Color &slot = this->random_colors_[new_color.random_slot]; + slot.r = c.r; + slot.g = c.g; + slot.b = c.b; } } it.schedule_show(); } protected: - FixedVector colors_; + AddressableColorWipeEffectColor entry_(size_t index) const { + AddressableColorWipeEffectColor entry; + progmem_memcpy(&entry, &this->colors_[index], sizeof(entry)); + return entry; + } + Color color_of_(const AddressableColorWipeEffectColor &entry) const { + if (entry.random_slot != NO_RANDOM_SLOT) + return this->random_colors_[entry.random_slot]; + return Color(entry.r, entry.g, entry.b, entry.w); + } + + ConstVector colors_; + Color *random_colors_{nullptr}; size_t at_color_{0}; uint32_t last_add_{0}; uint32_t add_led_interval_{}; diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index e32dbde9521..7374b65e22c 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "light_effect.h" @@ -154,11 +156,25 @@ class AutomationLightEffect : public LightEffect { Trigger<> trig_; }; +/// One strobe step. Codegen keeps the steps in a flash table, so it is read with progmem_memcpy. struct StrobeLightEffectColor { - LightColorValues color; + float state; + float brightness; + float color_brightness; + float red; + float green; + float blue; + float white; + float color_temperature; + float cold_white; + float warm_white; uint32_t duration; uint32_t transition_length; + ColorMode color_mode; }; +static_assert(std::is_trivially_copyable_v, "StrobeLightEffectColor is copied out of flash"); +static_assert(sizeof(StrobeLightEffectColor) % sizeof(uint32_t) == 0, + "StrobeLightEffectColor must be whole words for ESP8266 flash reads"); class StrobeLightEffect : public LightEffect { public: @@ -166,19 +182,22 @@ class StrobeLightEffect : public LightEffect { void start() override { // Place the cycle at the end of the last color, so the first apply() switches straight to the first color this->at_color_ = this->colors_.size() - 1; - this->last_switch_ = millis() - this->colors_.back().duration; + this->last_switch_ = millis() - this->duration_(this->at_color_); } void apply() override { const uint32_t now = millis(); - if (now - this->last_switch_ < this->colors_[this->at_color_].duration) + if (now - this->last_switch_ < this->duration_(this->at_color_)) return; // Switch to next color this->at_color_ = (this->at_color_ + 1) % this->colors_.size(); - auto color = this->colors_[this->at_color_].color; + StrobeLightEffectColor step; + progmem_memcpy(&step, &this->colors_[this->at_color_], sizeof(step)); + LightColorValues color(step.color_mode, step.state, step.brightness, step.color_brightness, step.red, step.green, + step.blue, step.white, step.color_temperature, step.cold_white, step.warm_white); auto call = this->state_->turn_on(); - call.from_light_color_values(this->colors_[this->at_color_].color); + call.from_light_color_values(color); if (!color.is_on()) { // Don't turn the light off, otherwise the light effect will be stopped @@ -187,15 +206,22 @@ class StrobeLightEffect : public LightEffect { } call.set_publish(false); call.set_save(false); - call.set_transition_length_if_supported(this->colors_[this->at_color_].transition_length); + call.set_transition_length_if_supported(step.transition_length); call.perform(); this->last_switch_ = now; } - void set_colors(const std::initializer_list &colors) { this->colors_ = colors; } + /// Codegen only: point at the generated flash table of steps, which must outlive the effect. + void set_colors(const StrobeLightEffectColor *colors, size_t count) { this->colors_ = {colors, count}; } protected: - FixedVector colors_; + uint32_t duration_(size_t index) const { + uint32_t duration; + progmem_memcpy(&duration, &this->colors_[index].duration, sizeof(duration)); + return duration; + } + + ConstVector colors_; uint32_t last_switch_{0}; size_t at_color_{0}; }; diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index dc6266b2fd2..948124446a6 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_WHITE, CONF_WIDTH, ) +from esphome.core import CORE from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType @@ -52,7 +53,6 @@ from .types import ( ColorMode, FlickerLightEffect, LambdaLightEffect, - LightColorValues, LightStateRef, PulseLightEffect, RandomLightEffect, @@ -351,28 +351,26 @@ async def strobe_effect_to_code(config, effect_id): colors = [ cg.StructInitializer( StrobeLightEffectColor, - ( - "color", - LightColorValues( - color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - color[CONF_STATE], - color[CONF_BRIGHTNESS], - color[CONF_COLOR_BRIGHTNESS], - color[CONF_RED], - color[CONF_GREEN], - color[CONF_BLUE], - color[CONF_WHITE], - color.get(CONF_COLOR_TEMPERATURE, 0.0), - color[CONF_COLD_WHITE], - color[CONF_WARM_WHITE], - ), - ), + ("state", color[CONF_STATE]), + ("brightness", color[CONF_BRIGHTNESS]), + ("color_brightness", color[CONF_COLOR_BRIGHTNESS]), + ("red", color[CONF_RED]), + ("green", color[CONF_GREEN]), + ("blue", color[CONF_BLUE]), + ("white", color[CONF_WHITE]), + ("color_temperature", color.get(CONF_COLOR_TEMPERATURE, 0.0)), + ("cold_white", color[CONF_COLD_WHITE]), + ("warm_white", color[CONF_WARM_WHITE]), ("duration", color[CONF_DURATION]), ("transition_length", color[CONF_TRANSITION_LENGTH]), + ("color_mode", color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN)), ) for color in config.get(CONF_COLORS, []) ] - cg.add(var.set_colors(colors)) + table = cg.shared_progmem_array( + "light_strobe_colors", StrobeLightEffectColor, cg.ArrayInitializer(*colors) + ) + cg.add(var.set_colors(table, len(colors))) return var @@ -431,6 +429,16 @@ async def addressable_rainbow_effect_to_code(config, effect_id): return var +# Matches NO_RANDOM_SLOT in addressable_light_effect.h +NO_RANDOM_SLOT = 0xFF + + +def _validate_random_slots(colors: list[ConfigType]) -> list[ConfigType]: + if sum(1 for color in colors if color[CONF_RANDOM]) > NO_RANDOM_SLOT: + raise cv.Invalid(f"At most {NO_RANDOM_SLOT} colors can be random") + return colors + + ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = cv.Schema( { cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage, @@ -459,7 +467,11 @@ ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = _with_color_option( { cv.Optional( CONF_COLORS, default=[{CONF_NUM_LEDS: 1, CONF_RANDOM: True}] - ): cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA), + ): cv.All( + cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA), + cv.Length(min=1), + _validate_random_slots, + ), cv.Optional( CONF_ADD_LED_INTERVAL, default="0.1s" ): cv.positive_time_period_milliseconds, @@ -471,21 +483,47 @@ async def addressable_color_wipe_effect_to_code(config, effect_id): cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL])) cg.add(var.set_reverse(config[CONF_REVERSE])) colors = [] + random_colors = [] for color in config.get(CONF_COLORS, []): color_brightness = color[CONF_COLOR_BRIGHTNESS] + rgbw = ( + int(round(color[CONF_RED] * color_brightness * 255)), + int(round(color[CONF_GREEN] * color_brightness * 255)), + int(round(color[CONF_BLUE] * color_brightness * 255)), + int(round(color[CONF_WHITE] * 255)), + ) + random_slot = cg.RawExpression("light::NO_RANDOM_SLOT") + if color[CONF_RANDOM]: + random_slot = len(random_colors) + random_colors.append(rgbw) colors.append( cg.StructInitializer( AddressableColorWipeEffectColor, - ("r", int(round(color[CONF_RED] * color_brightness * 255))), - ("g", int(round(color[CONF_GREEN] * color_brightness * 255))), - ("b", int(round(color[CONF_BLUE] * color_brightness * 255))), - ("w", int(round(color[CONF_WHITE] * 255))), - ("random", color[CONF_RANDOM]), - ("num_leds", color[CONF_NUM_LEDS]), + ("r", rgbw[0]), + ("g", rgbw[1]), + ("b", rgbw[2]), + ("w", rgbw[3]), + ("random_slot", random_slot), ("gradient", color[CONF_GRADIENT]), + ("num_leds", color[CONF_NUM_LEDS]), ) ) - cg.add(var.set_colors(colors)) + table = cg.shared_progmem_array( + "light_color_wipe_colors", + AddressableColorWipeEffectColor, + cg.ArrayInitializer(*colors), + ) + random_storage = cg.nullptr + if random_colors: + # One RAM slot per random entry; it starts at the configured color, as before + id_ = cg.unique_global_id(f"{effect_id.id}_random_colors", Color) + values = cg.ArrayInitializer( + *(Color(r, g, b, w) for r, g, b, w in random_colors) + ) + cg.add_global(cg.RawStatement(f"static {Color} {id_}[] = {values};")) + random_storage = cg.MockObj(id_, ".") + CORE.register_variable(id_, random_storage) + cg.add(var.set_colors(table, len(colors), random_storage)) return var diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d0f2a33bda8..577cf165712 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -484,6 +484,17 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +def unique_global_id(name: str, type_: "MockObjClass") -> ID: + """A declaration ID for a generated global, unique against config ids, variables and reserved names.""" + from esphome.config import iter_ids + from esphome.config_validation import RESERVED_IDS + + used = {str(i) for i, _ in iter_ids(CORE.config)} + used |= {str(i) for i in CORE.variables} + used |= set(RESERVED_IDS) | CORE.loaded_integrations + return ID(ensure_unique_string(name, used), is_declaration=True, type=type_) + + class ExternProgmemAssignmentExpression(ProgmemAssignmentExpression): __slots__ = () @@ -517,9 +528,6 @@ def shared_progmem_array( ``constexpr=False`` is for tables of generated object pointers: each top level variable element must pass ``is_static_pointer`` or ``EsphomeError`` is raised. """ - from esphome.config import iter_ids - from esphome.config_validation import RESERVED_IDS - arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) rhs = safe_exp(rhs) if not constexpr and isinstance(rhs, ArrayInitializer): @@ -532,10 +540,7 @@ def shared_progmem_array( key = f"{type_} {rhs} {constexpr}" if share and (array := arrays.get(key)) is not None: return array - used = {str(i) for i, _ in iter_ids(CORE.config)} - used |= {str(i) for i in CORE.variables} - used |= set(RESERVED_IDS) | CORE.loaded_integrations - id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) + id_ = unique_global_id(name, type_) # After the user includes, so a lambda in the table can use what their headers declare. CORE.add_global_after_includes( ProgmemAssignmentExpression(type_, id_, rhs, constexpr) diff --git a/tests/component_tests/light/config/effect_color_tables.yaml b/tests/component_tests/light/config/effect_color_tables.yaml new file mode 100644 index 00000000000..77b0b9144a0 --- /dev/null +++ b/tests/component_tests/light/config/effect_color_tables.yaml @@ -0,0 +1,44 @@ +esphome: + name: test-effect-color-tables + +esp32: + board: esp32dev + framework: + type: arduino + +light: + - platform: fastled_clockless + id: strip_a + chipset: WS2811 + pin: GPIO13 + num_leds: 10 + effects: + - strobe: + name: Strobe A + colors: + - state: true + duration: 500ms + - state: false + duration: 250ms + - addressable_color_wipe: + name: Wipe + colors: + - red: 100% + green: 0% + blue: 0% + num_leds: 2 + - random: true + num_leds: 1 + - platform: fastled_clockless + id: strip_b + chipset: WS2811 + pin: GPIO14 + num_leds: 10 + effects: + - strobe: + name: Strobe B + colors: + - state: true + duration: 500ms + - state: false + duration: 250ms diff --git a/tests/component_tests/light/test_effect_color_tables.py b/tests/component_tests/light/test_effect_color_tables.py new file mode 100644 index 00000000000..b25451bf8cb --- /dev/null +++ b/tests/component_tests/light/test_effect_color_tables.py @@ -0,0 +1,52 @@ +"""Strobe and addressable_color_wipe colors live in flash tables; random wipe colors get RAM slots.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +import esphome.config_validation as cv + + +def test_effect_colors_use_flash_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("effect_color_tables.yaml")) + + assert ( + "static constexpr light::StrobeLightEffectColor light_strobe_colors[] PROGMEM" + in main_cpp + ) + # Both strobes use the same steps, so they share one table + assert main_cpp.count("->set_colors(light_strobe_colors, 2);") == 2 + assert re.search( + r"static constexpr light::AddressableColorWipeEffectColor light_color_wipe_colors\w*\[\] PROGMEM", + main_cpp, + ) + # Only the random entry gets a RAM slot, starting at its configured color + assert re.search(r"static Color \w+_random_colors\[\] = \{Color\(", main_cpp) + assert ".random_slot = 0," in main_cpp + assert ".random_slot = light::NO_RANDOM_SLOT," in main_cpp + + +def _color_wipe_colors(colors: list[dict]) -> list[dict]: + from esphome.components.light.effects import EFFECTS_REGISTRY + + entry = EFFECTS_REGISTRY["addressable_color_wipe"] + return entry.schema({"name": "Wipe", "colors": colors})["colors"] + + +def test_color_wipe_requires_a_color() -> None: + with pytest.raises(cv.Invalid): + _color_wipe_colors([]) + + +def test_color_wipe_random_entry_limit() -> None: + random_color = {"random": True, "num_leds": 1} + assert len(_color_wipe_colors([random_color] * 255)) == 255 + with pytest.raises(cv.Invalid, match="At most 255 colors can be random"): + _color_wipe_colors([random_color] * 256) From 4a9409e2998f309da0294c4ec22f15754981710d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 13:05:11 -1000 Subject: [PATCH 09/37] [light] Add shared helpers for addressable LED strip drivers (#21445) --- esphome/components/light/addressable_light.h | 7 ++ esphome/components/light/channel_colors.h | 27 ++++++ .../light/test_led_strip_helpers.cpp | 88 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 tests/components/light/test_led_strip_helpers.cpp diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index b6446f2b9d3..97fb1419d59 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -134,6 +134,13 @@ class AddressableLight : public LightOutput, public Component { #endif } virtual ESPColorView get_view_internal(int32_t index) const = 0; + // Queues the frame for the next loop and returns true until interval_us has passed since last_us + bool throttle_show_(uint32_t now_us, uint32_t last_us, uint32_t interval_us) { + if (now_us - last_us >= interval_us) + return false; + this->schedule_show(); + return true; + } #ifdef USE_LIGHT_POWER_ESTIMATION friend class AddressableLightState; diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h index 9d8f46d5753..8bdbce17419 100644 --- a/esphome/components/light/channel_colors.h +++ b/esphome/components/light/channel_colors.h @@ -2,6 +2,9 @@ #include +#include "esp_color_view.h" +#include "light_traits.h" + namespace esphome::light { /// Which byte of an addressable LED's data carries each colour. @@ -36,6 +39,30 @@ struct ChannelColors { buf[this->bytes_per_led()] = '\0'; return buf; } + + /// Colour modes of a strip with this channel order. + LightTraits traits() const { + LightTraits traits; + if (this->has_white()) { + traits.set_supported_color_modes({ColorMode::RGB_WHITE, ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({ColorMode::RGB}); + } + return traits; + } + + /// View of LED `index` in `buf`; a scratch pixel while `buf` or `effect_data` is unallocated (failed setup()). + ESPColorView view(uint8_t *buf, uint8_t *effect_data, int32_t index, const ESPColorCorrection *correction) const { + static uint8_t scratch[4]; + if (buf == nullptr || effect_data == nullptr) { + buf = scratch; + effect_data = nullptr; + index = 0; + } + uint8_t *led = buf + index * this->bytes_per_led(); + return {led + this->r, led + this->g, led + this->b, this->has_white() ? led + this->w : nullptr, + effect_data + index, correction}; + } }; } // namespace esphome::light diff --git a/tests/components/light/test_led_strip_helpers.cpp b/tests/components/light/test_led_strip_helpers.cpp new file mode 100644 index 00000000000..eda5b30527e --- /dev/null +++ b/tests/components/light/test_led_strip_helpers.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +constexpr ChannelColors GRB{1, 0, 2, ChannelColors::NO_WHITE}; +constexpr ChannelColors GRBW{1, 0, 2, 3}; + +// Exposes the protected strip helpers +class HelperStrip : public AddressableLight { + public: + int32_t size() const override { return 1; } + void clear_effect_data() override {} + LightTraits get_traits() override { return {}; } + void write_state(LightState *state) override {} + using AddressableLight::throttle_show_; + + protected: + ESPColorView get_view_internal(int32_t index) const override { + return GRB.view(nullptr, nullptr, index, &this->correction_); + } +}; + +} // namespace + +TEST(LedStripHelpers, TraitsFollowTheWhiteChannel) { + const auto rgb = GRB.traits().get_supported_color_modes(); + EXPECT_TRUE(rgb.count(ColorMode::RGB)); + EXPECT_EQ(rgb.size(), 1u); + const auto rgbw = GRBW.traits().get_supported_color_modes(); + EXPECT_TRUE(rgbw.count(ColorMode::RGB_WHITE)); + EXPECT_TRUE(rgbw.count(ColorMode::WHITE)); + EXPECT_EQ(rgbw.size(), 2u); +} + +TEST(LedStripHelpers, ViewWritesTheChannelOrder) { + ESPColorCorrection correction; + std::array buf{}; + std::array effect{}; + GRBW.view(buf.data(), effect.data(), 1, &correction) = Color(10, 20, 30, 40); + GRBW.view(buf.data(), effect.data(), 1, &correction).set_effect_data(7); + EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 20, 10, 30, 40})); + EXPECT_EQ(effect[1], 7); + + std::array rgb_buf{}; + auto view = GRB.view(rgb_buf.data(), effect.data(), 1, &correction); + view = Color(10, 20, 30, 40); + EXPECT_EQ(rgb_buf, (std::array{0, 0, 0, 20, 10, 30})); + EXPECT_EQ(view.get_white_raw(), 0); +} + +TEST(LedStripHelpers, ViewFallsBackToScratchBeforeSetup) { + ESPColorCorrection correction; + std::array buf{}; + // Any index is safe without a buffer, as is a buffer without effect data + GRBW.view(nullptr, nullptr, 1000, &correction) = Color(1, 2, 3, 4); + auto scratch = GRBW.view(nullptr, nullptr, 1000, &correction); + scratch.set_effect_data(5); + EXPECT_EQ(scratch.get_effect_data(), 0); + GRBW.view(buf.data(), nullptr, 0, &correction) = Color(1, 2, 3, 4); + EXPECT_EQ(buf, (std::array{})); + // Effect data without a buffer is left alone too + std::array effect{}; + GRBW.view(nullptr, effect.data(), 3, &correction).set_effect_data(5); + EXPECT_EQ(effect, (std::array{})); +} + +TEST(LedStripHelpers, ThrottleShowHoldsTheFrame) { + HelperStrip strip; + LightState state(&strip); + strip.setup_state(&state); + EXPECT_FALSE(strip.throttle_show_(1000, 0, 0)); + EXPECT_TRUE(strip.throttle_show_(1000, 500, 600)); + EXPECT_FALSE(strip.throttle_show_(1100, 500, 600)); + // micros() wraps + EXPECT_TRUE(strip.throttle_show_(100, UINT32_MAX - 99, 300)); + EXPECT_FALSE(strip.throttle_show_(200, UINT32_MAX - 99, 300)); +} + +} // namespace esphome::light::testing From 6f091e4cf29084f5f228e506b8499b7ed4744fd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 13:05:11 -1000 Subject: [PATCH 10/37] [light] Use the shared LED strip helpers in five addressable drivers (#21447) --- .../beken_spi_led_strip/led_strip.cpp | 20 +--------- .../beken_spi_led_strip/led_strip.h | 20 ++++------ .../esp32_rmt_led_strip/led_strip.cpp | 20 +--------- .../esp32_rmt_led_strip/led_strip.h | 20 ++++------ .../i2s_clockless_led_strip.cpp | 38 +++---------------- .../i2s_clockless_led_strip.h | 16 ++++++-- .../ln882x_spi_led_strip/led_strip.cpp | 35 +---------------- .../ln882x_spi_led_strip/led_strip.h | 11 ++++-- .../rp2040_pio_led_strip/led_strip.cpp | 11 ------ .../rp2040_pio_led_strip/led_strip.h | 18 +++------ 10 files changed, 50 insertions(+), 159 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 77d905ac9de..3aa8d47fd48 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -293,14 +293,9 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin } void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { - // protect from refreshing too often uint32_t now = micros(); - if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 && - (now - this->last_refresh_) < *this->max_refresh_rate_) { - // try again next loop iteration, so that this change won't get lost - this->schedule_show(); + if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_)) return; - } this->last_refresh_ = now; this->mark_shown_(); @@ -369,17 +364,6 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { this->status_clear_warning(); } -light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - const light::ChannelColors &colors = this->channel_colors_; - uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); - return {led + colors.r, - led + colors.g, - led + colors.b, - colors.has_white() ? led + colors.w : nullptr, - &this->effect_data_[index], - &this->correction_}; -} - void BekenSPILEDStripLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "Beken SPI LED Strip:\n" @@ -390,7 +374,7 @@ void BekenSPILEDStripLightOutput::dump_config() { " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 1496e65d4d3..3b06ad2d18f 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -18,15 +18,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { - auto traits = light::LightTraits(); - if (this->channel_colors_.has_white()) { - traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); - } else { - traits.set_supported_color_modes({light::ColorMode::RGB}); - } - return traits; - } + light::LightTraits get_traits() override { return this->channel_colors_.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } @@ -38,14 +30,16 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); void clear_effect_data() override { - for (int i = 0; i < this->size(); i++) - this->effect_data_[i] = 0; + if (this->effect_data_ != nullptr) + memset(this->effect_data_, 0, this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override; + light::ESPColorView get_view_internal(int32_t index) const override { + return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); + } size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } @@ -62,7 +56,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; - optional max_refresh_rate_{}; + uint32_t max_refresh_rate_{0}; }; } // namespace esphome::beken_spi_led_strip diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 24f3a77a7ce..01a3d6bb521 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -157,14 +157,9 @@ void ESP32RMTLEDStripLightOutput::set_led_params(uint32_t bit0_high, uint32_t bi } void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { - // protect from refreshing too often uint32_t now = micros(); - auto rate = this->max_refresh_rate_.value_or(0); - if (rate != 0 && (now - this->last_refresh_) < rate) { - // try again next loop iteration, so that this change won't get lost - this->schedule_show(); + if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_)) return; - } this->last_refresh_ = now; this->mark_shown_(); @@ -217,17 +212,6 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } } -light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - const light::ChannelColors &colors = this->channel_colors_; - uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); - return {led + colors.r, - led + colors.g, - led + colors.b, - colors.has_white() ? led + colors.w : nullptr, - &this->effect_data_[index], - &this->correction_}; -} - void ESP32RMTLEDStripLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 RMT LED Strip:\n" @@ -239,7 +223,7 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 61aac06d76e..dbe708a54a0 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -29,15 +29,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { - auto traits = light::LightTraits(); - if (this->channel_colors_.has_white()) { - traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); - } else { - traits.set_supported_color_modes({light::ColorMode::RGB}); - } - return traits; - } + light::LightTraits get_traits() override { return this->channel_colors_.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } @@ -55,14 +47,16 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { - for (int i = 0; i < this->size(); i++) - this->effect_data_[i] = 0; + if (this->effect_data_ != nullptr) + memset(this->effect_data_, 0, this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override; + light::ESPColorView get_view_internal(int32_t index) const override { + return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); + } size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } @@ -86,7 +80,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; - optional max_refresh_rate_{}; + uint32_t max_refresh_rate_{0}; }; } // namespace esphome::esp32_rmt_led_strip diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp index bf53d96a83f..9846a74cfeb 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -149,17 +149,12 @@ bool I2SClocklessLedStrip::allocate_buffers_() { RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); this->i2s_data_ = allocator.allocate(i2s_data_bytes); if (this->i2s_data_ != nullptr) { - this->color_data_ = allocator.allocate(this->color_data_bytes_); + // The effect data follows the color data + this->color_data_ = allocator.allocate(this->color_data_bytes_ + this->num_leds_); if (this->color_data_ != nullptr) { - this->effect_data_ = allocator.allocate(this->num_leds_); - if (this->effect_data_ != nullptr) { - memset(this->i2s_data_, 0, i2s_data_bytes); - memset(this->color_data_, 0, this->color_data_bytes_); - memset(this->effect_data_, 0, this->num_leds_); - return true; - } - allocator.deallocate(this->color_data_, this->color_data_bytes_); - this->color_data_ = nullptr; + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_ + this->num_leds_); + return true; } allocator.deallocate(this->i2s_data_, i2s_data_bytes); this->i2s_data_ = nullptr; @@ -167,16 +162,6 @@ bool I2SClocklessLedStrip::allocate_buffers_() { return false; } -light::LightTraits I2SClocklessLedStrip::get_traits() { - auto traits = light::LightTraits(); - if (this->channel_colors_.has_white()) { - traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); - } else { - traits.set_supported_color_modes({light::ColorMode::RGB}); - } - return traits; -} - void I2SClocklessLedStrip::write_state(light::LightState *state) { if (this->is_failed()) return; @@ -236,19 +221,6 @@ bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t return false; } -void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } - -light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { - const light::ChannelColors &colors = this->channel_colors_; - uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); - return {led + colors.r, - led + colors.g, - led + colors.b, - colors.has_white() ? led + colors.w : nullptr, - &this->effect_data_[index], - &this->correction_}; -} - } // namespace esphome::i2s_clockless_led_strip #endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h index 0c516a6bc8d..60aa4c5c3f5 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -8,6 +8,7 @@ #include #include +#include #include "esphome/core/component.h" #include "esphome/core/log.h" @@ -27,20 +28,27 @@ class I2SClocklessLedStrip final : public light::AddressableLight { float get_setup_priority() const override; void setup() override; - light::LightTraits get_traits() override; + light::LightTraits get_traits() override { return this->channel_colors_.traits(); } int32_t size() const override { return this->num_leds_; } - void clear_effect_data() override; + void clear_effect_data() override { + if (uint8_t *effect = this->effect_data_()) + memset(effect, 0, this->num_leds_); + } void write_state(light::LightState *state) override; protected: static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); bool allocate_buffers_(); - light::ESPColorView get_view_internal(int32_t index) const override; + uint8_t *effect_data_() const { + return this->color_data_ == nullptr ? nullptr : this->color_data_ + this->color_data_bytes_; + } + light::ESPColorView get_view_internal(int32_t index) const override { + return this->channel_colors_.view(this->color_data_, this->effect_data_(), index, &this->correction_); + } const size_t color_data_bytes_; - uint8_t *effect_data_{nullptr}; uint8_t *color_data_{nullptr}; uint8_t *i2s_data_{nullptr}; size_t i2s_data_sent_{0}; diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.cpp b/esphome/components/ln882x_spi_led_strip/led_strip.cpp index f968a456f0b..5825466948c 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.cpp +++ b/esphome/components/ln882x_spi_led_strip/led_strip.cpp @@ -141,32 +141,13 @@ void LN882xSPILEDStripLightOutput::dump_config() { float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } -light::LightTraits LN882xSPILEDStripLightOutput::get_traits() { - auto traits = light::LightTraits(); - if (this->channel_colors_.has_white()) { - traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); - } else { - traits.set_supported_color_modes({light::ColorMode::RGB}); - } - return traits; -} - -void LN882xSPILEDStripLightOutput::clear_effect_data() { - if (this->effect_data_ != nullptr) - memset(this->effect_data_, 0, this->num_leds_); -} - void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) { if (this->is_failed()) return; - // protect from refreshing too often const uint32_t now = micros(); - if (this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < this->max_refresh_rate_) { - // try again next loop iteration, so that this change won't get lost - this->schedule_show(); + if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_)) return; - } if (spi_dma_busy()) { // Busy sending the last frame, try again later. @@ -191,20 +172,6 @@ void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) { spi_dma_transmit(this->dma_data_, this->dma_data_bytes_); } -light::ESPColorView LN882xSPILEDStripLightOutput::get_view_internal(int32_t index) const { - const light::ChannelColors &colors = this->channel_colors_; - // After a failed setup() effects and partitions still write pixels, so they get a scratch pixel. - static uint8_t scratch[5]; - const bool ready = this->color_data_ != nullptr; - uint8_t *led = ready ? this->color_data_ + (index * colors.bytes_per_led()) : scratch; - return {led + colors.r, - led + colors.g, - led + colors.b, - colors.has_white() ? led + colors.w : nullptr, - ready ? &this->effect_data_[index] : scratch + 4, - &this->correction_}; -} - } // namespace esphome::ln882x_spi_led_strip #endif // USE_LN882X diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.h b/esphome/components/ln882x_spi_led_strip/led_strip.h index 34246f89f57..21fa40c5d07 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.h +++ b/esphome/components/ln882x_spi_led_strip/led_strip.h @@ -23,16 +23,21 @@ class LN882xSPILEDStripLightOutput final : public light::AddressableLight { void dump_config() override; float get_setup_priority() const override; - light::LightTraits get_traits() override; + light::LightTraits get_traits() override { return this->channel_colors_.traits(); } int32_t size() const override { return this->num_leds_; } - void clear_effect_data() override; + void clear_effect_data() override { + if (this->effect_data_ != nullptr) + memset(this->effect_data_, 0, this->num_leds_); + } void write_state(light::LightState *state) override; /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } protected: - light::ESPColorView get_view_internal(int32_t index) const override; + light::ESPColorView get_view_internal(int32_t index) const override { + return this->channel_colors_.view(this->color_data_, this->effect_data_, index, &this->correction_); + } const size_t color_data_bytes_; const size_t dma_data_bytes_; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 8ea9880c7c0..9d75c6ebc62 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -141,17 +141,6 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { dma_channel_transfer_from_buffer_now(this->dma_chan_, this->buf_, this->get_buffer_size_()); } -light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - const light::ChannelColors &colors = this->channel_colors_; - uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); - return {led + colors.r, - led + colors.g, - led + colors.b, - colors.has_white() ? led + colors.w : nullptr, - &this->effect_data_[index], - &this->correction_}; -} - void RP2040PIOLEDStripLightOutput::dump_config() { char channel_colors[5]; ESP_LOGCONFIG(TAG, diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index b2162f641d2..d320761973f 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -37,13 +37,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { - auto traits = light::LightTraits(); - this->channel_colors_.has_white() - ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); - return traits; - } + light::LightTraits get_traits() override { return this->channel_colors_.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } @@ -56,15 +50,16 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; void clear_effect_data() override { - for (int i = 0; i < this->size(); i++) { - this->effect_data_[i] = 0; - } + if (this->effect_data_ != nullptr) + memset(this->effect_data_, 0, this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override; + light::ESPColorView get_view_internal(int32_t index) const override { + return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); + } size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } @@ -84,7 +79,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; - uint32_t last_refresh_{0}; float max_refresh_rate_; const pio_program_t *program_; From c464ef0cc4b6f2d55502f64924a8cc1cca5eee00 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:09:52 -0400 Subject: [PATCH 11/37] [safe_mode] Avoid a duplicated branch when partition access is enabled (#21526) --- esphome/components/safe_mode/safe_mode.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 828402e10d1..7d77b09bea7 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -64,17 +64,13 @@ void SafeModeComponent::dump_config() { this->safe_mode_num_attempts_, this->safe_mode_enable_time_ / 1000); // because milliseconds #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) - const char *state_str; - if (this->ota_state_ == ESP_OTA_IMG_NEW) { -#ifdef USE_OTA_PARTITIONS - state_str = "support unknown"; -#else + const char *state_str = "support unknown"; + if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) { + state_str = "supported"; +#ifndef USE_OTA_PARTITIONS + } else if (this->ota_state_ == ESP_OTA_IMG_NEW) { state_str = "not supported"; #endif - } else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) { - state_str = "supported"; - } else { - state_str = "support unknown"; } ESP_LOGCONFIG(TAG, " Bootloader rollback: %s", state_str); #endif From 8fa993203302bd1ed882475cf0f72f84087e250f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:10:26 -0400 Subject: [PATCH 12/37] [ota] Name the protected partition table method per convention and mark the cache statics (#21525) --- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/components/ota/ota_partitions_esp_idf.cpp | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index fd6fd7f6f15..a9bcf6b9408 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -198,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->hold_lock_if_ok_(this->update_partition_table()); + return this->hold_lock_if_ok_(this->update_partition_table_()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d166bb361fa..ab435289eb0 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,7 +54,7 @@ class IDFOTABackend final { OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, PartitionTablePlan &plan); - OTAResponseTypes update_partition_table(); + OTAResponseTypes update_partition_table_(); OTAResponseTypes register_and_validate_partition_table_part_(); // Defined in ota_bootloader_esp_idf.cpp: OTAResponseTypes register_and_validate_bootloader_part_(); diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f9ce1b49e11..9397ad34f49 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -40,7 +40,7 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min } // Validates the staged partition table and picks the post-update boot slot. All non-destructive -// checks live here; the destructive write is in update_partition_table(). +// checks live here; the destructive write is in update_partition_table_(). // Side effect: registers the live partition-table region as partition_table_part_ so the caller // can write to it; abort() releases it on error. OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, @@ -165,7 +165,7 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_OK; } -OTAResponseTypes IDFOTABackend::update_partition_table() { +OTAResponseTypes IDFOTABackend::update_partition_table_() { if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { ESP_LOGE(TAG, "Not enough data received"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -300,9 +300,9 @@ OTAResponseTypes IDFOTABackend::register_and_validate_partition_table_part_() { // Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache // must outlive a connection that called esp_partition_unload_all(), after which // esp_ota_get_running_partition() no longer returns valid data. -static bool s_running_app_initialized = false; -static uint32_t s_running_app_cached_offset = 0; -static size_t s_running_app_cached_size = 0; +static bool s_running_app_initialized = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint32_t s_running_app_cached_offset = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static size_t s_running_app_cached_size = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // Flag-gated rather than size==0 so a failed first call doesn't poison the cache. void get_running_app_position(uint32_t &offset, size_t &size) { From b415f86847885f7903d52c1949b4ec48cc61979a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:11:00 -0400 Subject: [PATCH 13/37] [lvgl] Declare the log callback's strchr result as a pointer (#21523) --- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ea557be7fbc..7d6e3dc9e5d 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -887,7 +887,7 @@ void LvglComponent::setup() { lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); #if LV_USE_LOG lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { - auto next = strchr(buf, ')'); + auto *next = strchr(buf, ')'); if (next != nullptr) buf = next + 1; while (isspace(*buf)) From ade01b2c716056cc9fb690859899047e95fea678 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:13:33 -0400 Subject: [PATCH 14/37] [midea] Clamp the follow-me temperature before rounding (#21524) --- esphome/components/midea/air_conditioner.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index fce8ab99a9e..1ea86a2e595 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -173,9 +173,8 @@ void AirConditioner::do_follow_me(float temperature, bool use_fahrenheit, bool b return; } - // Round and convert temperature to long, then clamp and convert it to uint8_t - uint8_t temp_uint8 = - static_cast(esphome::clamp(std::lroundf(temperature), 0L, static_cast(UINT8_MAX))); + // Clamp to the uint8_t range before rounding so lroundf never sees an out-of-range value + uint8_t temp_uint8 = static_cast(std::lroundf(esphome::clamp(temperature, 0.0f, 255.0f))); char temp_symbol = use_fahrenheit ? 'F' : 'C'; ESP_LOGD(Constants::TAG, "Follow me action called with temperature: %.5f °%c, rounded to: %u °%c", temperature, From fc6accb8d9026bb4886ed3c89a77dcb363746e85 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:45:14 -0400 Subject: [PATCH 15/37] [core] Declare component defines missing from defines.h so clang-tidy parses the code behind them (#21521) --- esphome/core/defines.h | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 011672b3036..f80ef76ed6e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -90,6 +90,7 @@ #define USE_EVENT #define USE_EZSP_PROXY_TAP #define USE_FAN +#define USE_FONT #define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK @@ -162,6 +163,7 @@ #define USE_LVGL_TEXTAREA #define USE_LVGL_TILEVIEW #define USE_LVGL_TOUCHSCREEN +#define LV_USE_LOG 1 #define USE_MDNS #define USE_MDNS_STORE_SERVICES #define GPS_LISTENER_COUNT 1 @@ -169,6 +171,7 @@ #define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT +#define USE_MDNS_EXTRA_SERVICES #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 #define SEN6X_STATUS_SENSOR_COUNT 1 @@ -234,6 +237,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO +#define USE_REMOTE_TRANSMITTER #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 @@ -247,6 +251,8 @@ // lwIP is built without IPv6 on ESP8266, RP2 and LibreTiny #if defined(USE_ESP32) || defined(USE_HOST) #define USE_NETWORK_IPV6 true +#define USE_NETWORK_MIN_IPV6_ADDR_COUNT 1 +#define USE_UDP_IPV6_SEND #else #define USE_NETWORK_IPV6 false #endif @@ -369,6 +375,7 @@ #define USE_SHA256 #if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT +#define USE_MQTT_ABBREVIATIONS #define USE_MQTT_COVER_JSON #define USE_WIREGUARD #endif @@ -450,6 +457,7 @@ #define OTA_TRUSTED_KEY_COUNT 1 #define OTA_TRUSTED_KEY_DIGESTS {{0}} #define USE_OTA_DOWNGRADE_PROTECTION +#define USE_OTA_PARTITIONS #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM @@ -468,6 +476,7 @@ #define USE_ESP32_BLE_UUID #define USE_ESP32_BLE_ADVERTISING #define USE_ESP32_BLE_SERVER_SET_VALUE_ACTION +#define USE_ESP32_BLE_SOFTWARE_COEXISTENCE #define USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION #define USE_ESP32_BLE_SERVER_NOTIFY_ACTION #define USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -526,6 +535,10 @@ #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING #define USE_WEBSERVER_ALLOWED_ORIGINS +#define USE_WEBSERVER_CSS_INCLUDE +#define USE_WEBSERVER_JS_INCLUDE +#define USE_WEBSERVER_PRIVATE_NETWORK_ACCESS +#define USE_WEBSERVER_VERSION 3 #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #endif @@ -625,6 +638,7 @@ #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD #define USE_OPENTHREAD_SRP +#define USE_OPENTHREAD_TLVS "0e080000000000010000" #define USE_ZIGBEE #endif #if defined(USE_ESP32_VARIANT_ESP32S3) @@ -678,6 +692,8 @@ #define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78 #define USE_ESP8266_UART_SERIAL #define USE_ESP8266_UART_SERIAL1 +#define USE_ESP8266_EARLY_PIN_INIT +#define USE_API_NOISE_PSK_FROM_YAML #define USE_HTTP_REQUEST_ESP8266_HTTPS #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -698,6 +714,10 @@ #define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT +#define USE_WEBSERVER_VERSION 2 +#define USE_WEBSERVER_LOCAL +#define USE_WEBSERVER_OTA +#define USE_WEBSERVER_OTA_DISABLED #endif // USE_RP2 is the canonical platform define for the RP2 chip family. The @@ -718,6 +738,7 @@ #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 3 #define USE_RP2040_VARIANT_RP2040 +#define USE_RP2_WATCHDOG_TIMEOUT 8000 #define USE_SPI #ifndef USE_ETHERNET #define USE_ETHERNET @@ -748,6 +769,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define USE_LIBRETINY_RTC_PREFERENCES +#define LT_GPIO_RECOVER 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER @@ -804,8 +826,13 @@ // #define USE_BSEC2 // Requires a library with proprietary license #define USE_DASHBOARD_IMPORT +#define DSMR_TEXT_SENSOR_LIST_DEFINED // Default counts for static analysis +#define DLMS_MAX_BINARY_SENSORS 1 +#define DLMS_MAX_SENSORS 1 +#define DLMS_MAX_TEXT_SENSORS 1 +#define HYDREON_RGXX_NUM_SENSORS 1 #define ESPHOME_AREA_COUNT 10 #define ESPHOME_CDC_RX_RING_BUFFER_SIZE 256 #define ESPHOME_CDC_TX_RING_BUFFER_SIZE 256 From b90e1e43a3673b567b817fd7830629cb4d453ec4 Mon Sep 17 00:00:00 2001 From: bkbartk Date: Sat, 24 Oct 2026 01:52:15 +0200 Subject: [PATCH 16/37] [tuya] fan optimistic mode (#18525) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tuya/fan/__init__.py | 9 +++- esphome/components/tuya/fan/tuya_fan.cpp | 62 +++++++++++++++++++++--- esphome/components/tuya/fan/tuya_fan.h | 6 +++ tests/components/tuya/common.yaml | 1 + 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/esphome/components/tuya/fan/__init__.py b/esphome/components/tuya/fan/__init__.py index a26344472c0..a099f97891f 100644 --- a/esphome/components/tuya/fan/__init__.py +++ b/esphome/components/tuya/fan/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT +from esphome.const import ( + CONF_ID, + CONF_OPTIMISTIC, + CONF_SPEED_COUNT, + CONF_SWITCH_DATAPOINT, +) from .. import CONF_TUYA_ID, Tuya, tuya_ns @@ -23,6 +28,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256), + cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA), @@ -41,3 +47,4 @@ async def to_code(config): cg.add_optional(var.set_switch_id, config.get(CONF_SWITCH_DATAPOINT)) cg.add_optional(var.set_oscillation_id, config.get(CONF_OSCILLATION_DATAPOINT)) cg.add_optional(var.set_direction_id, config.get(CONF_DIRECTION_DATAPOINT)) + cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) diff --git a/esphome/components/tuya/fan/tuya_fan.cpp b/esphome/components/tuya/fan/tuya_fan.cpp index 8e4694baed2..6d0f8a6284f 100644 --- a/esphome/components/tuya/fan/tuya_fan.cpp +++ b/esphome/components/tuya/fan/tuya_fan.cpp @@ -86,12 +86,38 @@ fan::FanTraits TuyaFan::get_traits() { this->speed_count_); } +// The parent skips a value equal to the last MCU report, which an MCU that does not report back leaves stale, so +// optimistic mode always sends +void TuyaFan::set_boolean_(uint8_t datapoint_id, bool value) { + if (this->optimistic_) { + this->parent_->force_set_boolean_datapoint_value(datapoint_id, value); + } else { + this->parent_->set_boolean_datapoint_value(datapoint_id, value); + } +} + +void TuyaFan::set_enum_(uint8_t datapoint_id, uint8_t value) { + if (this->optimistic_) { + this->parent_->force_set_enum_datapoint_value(datapoint_id, value); + } else { + this->parent_->set_enum_datapoint_value(datapoint_id, value); + } +} + +void TuyaFan::set_integer_(uint8_t datapoint_id, uint32_t value) { + if (this->optimistic_) { + this->parent_->force_set_integer_datapoint_value(datapoint_id, value); + } else { + this->parent_->set_integer_datapoint_value(datapoint_id, value); + } +} + void TuyaFan::control(const fan::FanCall &call) { auto switch_id = this->switch_id_; if (switch_id.has_value()) { auto state = call.get_state(); if (state.has_value()) { - this->parent_->set_boolean_datapoint_value(*switch_id, *state); + this->set_boolean_(*switch_id, *state); } } auto osc_id = this->oscillation_id_; @@ -99,9 +125,10 @@ void TuyaFan::control(const fan::FanCall &call) { auto oscillating = call.get_oscillating(); if (oscillating.has_value()) { if (this->oscillation_type_ == TuyaDatapointType::ENUM) { - this->parent_->set_enum_datapoint_value(*osc_id, *oscillating); - } else if (this->oscillation_type_ == TuyaDatapointType::BOOLEAN) { - this->parent_->set_boolean_datapoint_value(*osc_id, *oscillating); + this->set_enum_(*osc_id, *oscillating); + } else if (this->oscillation_type_ == TuyaDatapointType::BOOLEAN || this->optimistic_) { + // Until the MCU reports the type, optimistic mode assumes a boolean datapoint + this->set_boolean_(*osc_id, *oscillating); } } } @@ -110,7 +137,7 @@ void TuyaFan::control(const fan::FanCall &call) { auto direction = call.get_direction(); if (direction.has_value()) { bool enable = *direction == fan::FanDirection::REVERSE; - this->parent_->set_enum_datapoint_value(*dir_id, enable); + this->set_enum_(*dir_id, enable); } } auto spd_id = this->speed_id_; @@ -118,12 +145,31 @@ void TuyaFan::control(const fan::FanCall &call) { auto speed = call.get_speed(); if (speed.has_value()) { if (this->speed_type_ == TuyaDatapointType::ENUM) { - this->parent_->set_enum_datapoint_value(*spd_id, *speed - 1); - } else if (this->speed_type_ == TuyaDatapointType::INTEGER) { - this->parent_->set_integer_datapoint_value(*spd_id, *speed); + this->set_enum_(*spd_id, *speed - 1); + } else if (this->speed_type_ == TuyaDatapointType::INTEGER || this->optimistic_) { + // Until the MCU reports the type, optimistic mode assumes an integer datapoint + this->set_integer_(*spd_id, *speed); } } } + + if (!this->optimistic_) + return; + // FanCall::validate_() already dropped the fields this fan's traits do not support; the on/off state is kept + // even without a switch datapoint, so a speed-only fan still reports turning on + auto state = call.get_state(); + if (state.has_value()) + this->state = *state; + auto oscillating = call.get_oscillating(); + if (oscillating.has_value()) + this->oscillating = *oscillating; + auto direction = call.get_direction(); + if (direction.has_value()) + this->direction = *direction; + auto speed = call.get_speed(); + if (speed.has_value()) + this->speed = *speed; + this->publish_state(); } } // namespace esphome::tuya diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index 70b127c10e8..c148feefb21 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -15,10 +15,15 @@ class TuyaFan final : public Component, public fan::Fan { void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } void set_oscillation_id(uint8_t oscillation_id) { this->oscillation_id_ = oscillation_id; } void set_direction_id(uint8_t direction_id) { this->direction_id_ = direction_id; } + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } fan::FanTraits get_traits() override; protected: + void set_boolean_(uint8_t datapoint_id, bool value); + void set_enum_(uint8_t datapoint_id, uint8_t value); + void set_integer_(uint8_t datapoint_id, uint32_t value); + void control(const fan::FanCall &call) override; Tuya *parent_; @@ -29,6 +34,7 @@ class TuyaFan final : public Component, public fan::Fan { int speed_count_{}; TuyaDatapointType speed_type_{}; TuyaDatapointType oscillation_type_{}; + bool optimistic_{false}; }; } // namespace esphome::tuya diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index 69387086748..1cef3abec65 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -153,6 +153,7 @@ fan: oscillation_datapoint: 12 direction_datapoint: 13 speed_count: 4 + optimistic: true text_sensor: - platform: tuya From 971035d6d154fa2e980200f84c3b29f522381fad Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 23 Oct 2026 18:53:10 -0500 Subject: [PATCH 17/37] [core] Write esp-idf-sbom manifests into ESP-IDF builds (#21454) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/components/esp32/__init__.py | 2 + .../external_components/__init__.py | 38 ++ esphome/espidf/component.py | 57 +- esphome/espidf/sbom.py | 338 ++++++++++ esphome/git.py | 8 + .../external_components/test_init.py | 71 +- tests/unit_tests/test_espidf_component.py | 97 +++ tests/unit_tests/test_espidf_sbom.py | 617 ++++++++++++++++++ tests/unit_tests/test_git.py | 16 + 9 files changed, 1241 insertions(+), 3 deletions(-) create mode 100644 esphome/espidf/sbom.py create mode 100644 tests/unit_tests/test_espidf_sbom.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b6397610103..dbf65f8122b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -56,6 +56,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.espidf import sbom from esphome.espidf.component import IDFComponent, generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed @@ -3993,6 +3994,7 @@ def copy_files(): CORE.relative_build_path("version.txt"), __version__, ) + sbom.write_manifests() # Remote extra build files are fetched into the shared download cache in # one parallel batch (conditional requests skip unchanged files), then diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 8994805432c..3a58c98134a 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import logging from pathlib import Path from typing import Any @@ -24,6 +25,33 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "external_components" +@dataclass +class ExternalSource: + """Origin of the components found in one source's components directory.""" + + components_dir: Path + url: str | None = None + ref: str | None = None + # Root of the git checkout holding components_dir + repo_dir: Path | None = None + + +@dataclass +class ExternalComponentsData: + sources: list[ExternalSource] = field(default_factory=list) + + +def _get_data() -> ExternalComponentsData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ExternalComponentsData() + return CORE.data[DOMAIN] + + +def get_sources() -> list[ExternalSource]: + """Return the sources processed for this configuration.""" + return _get_data().sources + + CONFIG_SCHEMA = cv.ensure_list( { cv.Required(CONF_SOURCE): cv.SOURCE_SCHEMA, @@ -67,6 +95,15 @@ def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> P "Could not find components folder for source. Please check the source contains a 'components' or 'esphome/components' folder" ) + get_sources().append( + ExternalSource( + components_dir=components_dir, + # Ends up in SBOMs that are shared outside + url=git.strip_url_credentials(config[CONF_URL]), + ref=config.get(CONF_REF), + repo_dir=repo_dir, + ) + ) return components_dir @@ -106,6 +143,7 @@ def _process_single_config(config: dict[str, Any]) -> None: ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) + get_sources().append(ExternalSource(components_dir=components_dir)) else: raise NotImplementedError diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 23cd52199d6..1a4722dadbf 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -8,13 +8,14 @@ ESP-IDF platform/framework compatibility defaults. """ from collections.abc import Callable +import contextlib import logging import os from pathlib import Path from esphome.core import CORE, Library from esphome.espidf import variant_to_idf_target -from esphome.helpers import write_file_if_changed +from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.platformio.library import ( DEFAULT_BUILD_FLAGS, DEFAULT_BUILD_INCLUDE_DIR, @@ -35,6 +36,9 @@ from esphome.platformio.library import ( _LOGGER = logging.getLogger(__name__) +# Marks an sbom.yml ESPHome wrote, as opposed to one the library ships +SBOM_YML_HEADER = "# Generated by ESPHome\n" + ESP32_PLATFORM = "espressif32" @@ -269,6 +273,49 @@ def generate_idf_component_yml(component: IDFComponent) -> str: return yaml_util.dump(data) +def generate_sbom_yml(component: IDFComponent) -> str | None: + """Return an esp-idf-sbom manifest naming the library and its version. + + The license is left out: esp-idf-sbom aborts on an expression that is not + valid SPDX, and library manifests often hold free text. + """ + from esphome import yaml_util + + version = component.data.get("version") + if not isinstance(version, str) or not version: + return None + name = component.data.get("name") + if not isinstance(name, str) or not name: + name = component.name + return SBOM_YML_HEADER + yaml_util.dump({"name": name, "version": version}) + + +def _write_sbom_yml(component: IDFComponent) -> None: + """Use the library's own sbom.yml if it ships one, else write ESPHome's.""" + target = component.path / "sbom.yml" + shipped = component.source_dir / "sbom.yml" + if shipped.is_file() and not shipped.read_text(encoding="utf-8").startswith( + SBOM_YML_HEADER + ): + # A local library's files stay where they are; esp-idf-sbom reads path + if shipped.resolve() != target.resolve(): + copy_file_if_changed(shipped, target) + return + if (content := generate_sbom_yml(component)) is not None: + write_file_if_changed(target, content) + return + _LOGGER.debug("SBOM: library %s has no version for its sbom.yml", component.name) + if target.is_file(): + target.unlink() + + +def _remove_generated_sbom_yml(target: Path) -> None: + """Remove ``target`` if ESPHome wrote it, so a failed run leaves no stale version.""" + with contextlib.suppress(OSError, ValueError): + if target.read_text(encoding="utf-8").startswith(SBOM_YML_HEADER): + target.unlink() + + def _emit_idf_component(component: IDFComponent) -> None: """Write the ESP-IDF build files for a resolved library into its cache dir.""" from esphome.components.esp32 import get_esp32_variant @@ -287,6 +334,14 @@ def _emit_idf_component(component: IDFComponent) -> None: component.path / "idf_component.yml", generate_idf_component_yml(component), ) + try: + _write_sbom_yml(component) + except (OSError, ValueError) as err: + # Only SBOM metadata depends on it; never fail the build over it + _LOGGER.warning( + "SBOM: could not write sbom.yml for %s: %s", component.name, err + ) + _remove_generated_sbom_yml(component.path / "sbom.yml") def generate_idf_components( diff --git a/esphome/espidf/sbom.py b/esphome/espidf/sbom.py new file mode 100644 index 00000000000..5d6c89adc13 --- /dev/null +++ b/esphome/espidf/sbom.py @@ -0,0 +1,338 @@ +"""``sbom.yml`` manifests describing the code ESPHome adds to an ESP-IDF build. + +esp-idf-sbom (https://github.com/espressif/esp-idf-sbom) builds an SBOM from +``build/project_description.json`` and reads ``sbom.yml`` files from the +project and component directories. ESP-IDF and registry components already +ship theirs; these cover the project, ESPHome itself (the ``src`` component) +and the external components copied into it. + +esp-idf-sbom aborts on a malformed manifest, so only values that pass its +validation are written. +""" + +import contextlib +import importlib.metadata +import json +import logging +from pathlib import Path +import re +from typing import Any, NamedTuple +from urllib.parse import urlparse +from urllib.request import url2pathname + +from esphome import loader, yaml_util +from esphome.components.external_components import ExternalSource, get_sources +from esphome.const import ( + CONF_ESPHOME, + CONF_NAME, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, +) +from esphome.core import CORE +from esphome.git import GitException, run_git_command, strip_url_credentials +from esphome.helpers import write_file_if_changed + +_LOGGER = logging.getLogger(__name__) + +SBOM_FILENAME = "sbom.yml" + +ESPHOME_REPOSITORY = "https://github.com/esphome/esphome" +# The firmware holds only ESPHome's C++ runtime, which is GPLv3 (see LICENSE) +ESPHOME_LICENSE = "GPL-3.0-only" +ESPHOME_SUPPLIER = "Organization: Open Home Foundation" + +# PURL types esp-idf-sbom also derives itself; other hosts get no PURL +_PURL_HOSTS = {"github.com": "github", "gitlab.com": "gitlab"} + +# scp-like SSH remote: [user@]host:path +_SCP_URL_RE = re.compile(r"^(?:[^@/:]+@)?(?P[^/:]+):(?P[^/].*)$") + + +class _Source(NamedTuple): + """Where ESPHome's code came from; None where that is not known.""" + + url: str | None + # Only set when the commit can be fetched exactly as it was built + commit: str | None + + +def _is_remote_url(url: str | None) -> bool: + """Mirror esp-idf-sbom's URL check, which rejects anything else.""" + if not url: + return False + parsed = urlparse(url) + return parsed.scheme in ("git", "http", "https") and bool(parsed.netloc) + + +def _repo_purl(url: str, version: str, subpath: str | None = None) -> str | None: + """Return a github/gitlab PURL for a repository root URL, else None.""" + parsed = urlparse(url) + purl_type = _PURL_HOSTS.get(parsed.hostname or "") + parts = parsed.path.strip("/").removesuffix(".git").split("/") + if purl_type is None or len(parts) != 2 or not all(parts): + return None + purl = f"pkg:{purl_type}/{parts[0]}/{parts[1]}@{version}" + if subpath and subpath != ".": + purl += f"#{subpath}" + return purl + + +def _https_repo_url(url: str) -> str: + """Return the https form of a GitHub or GitLab SSH URL, else ``url`` unchanged. + + esp-idf-sbom accepts only git/http/https URLs, and developer clones often use SSH. + """ + if "://" in url: + parsed = urlparse(url) + if parsed.scheme not in ("ssh", "git+ssh"): + return url + host, path = parsed.hostname or "", parsed.path + elif match := _SCP_URL_RE.match(url): + host, path = match["host"], match["path"] + else: + return url + if host not in _PURL_HOSTS: + return url + return f"https://{host}/{path.lstrip('/')}" + + +def _checkout_remote(path: Path) -> tuple[str, str] | None: + """Return the name and URL of the remote the branch tracks (else origin), if any.""" + try: + branch = run_git_command( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], git_dir=path + ) + remote = run_git_command( + ["git", "config", "--get", f"branch.{branch}.remote"], git_dir=path + ) + except GitException: + # Detached HEAD or a branch that tracks nothing + remote = "origin" + try: + return remote, run_git_command( + ["git", "remote", "get-url", remote], git_dir=path + ) + except GitException as err: + _LOGGER.debug("SBOM: no URL for remote '%s' in %s: %s", remote, path, err) + return None + + +def _checkout_vcs(path: Path) -> _Source | None: + """Describe the git checkout at ``path``; None if it is not a checkout.""" + if not (path / ".git").exists(): + return None + try: + commit = run_git_command(["git", "rev-parse", "HEAD"], git_dir=path) + # Only the package is compiled in; new files there count, notes elsewhere don't + modified = run_git_command( + ["git", "status", "--porcelain", "--", "esphome"], git_dir=path + ) + except GitException as err: + _LOGGER.warning( + "SBOM: cannot read the git checkout ESPHome runs from (%s), so its " + "source is left out: %s", + path, + err, + ) + return _Source(None, None) + if (remote := _checkout_remote(path)) is None: + # Local-only clone: nothing that can be fetched to name + return _Source(None, None) + name, url = remote + try: + # Only the remote the URL comes from counts, or the PURL could name a + # commit that exists in another remote only + pushed = run_git_command( + ["git", "branch", "--remotes", "--contains", "HEAD", "--list", f"{name}/*"], + git_dir=path, + ) + except GitException as err: + _LOGGER.warning( + "SBOM: cannot check whether %s has HEAD, so no commit is named: %s", + name, + err, + ) + pushed = "" + if modified or not pushed: + # A PURL would name code other than what was built, or that no one can fetch + return _Source(strip_url_credentials(url), None) + return _Source(strip_url_credentials(url), commit) + + +def _unknown_install(reason: str) -> _Source: + _LOGGER.warning( + "SBOM: cannot tell where ESPHome was installed from (%s), so its source " + "is left out", + reason, + ) + return _Source(None, None) + + +def _esphome_vcs() -> _Source | None: + """Return the repository URL and commit ESPHome was installed from, if known. + + None means a plain package install, described by its release version. + """ + try: + direct_url = importlib.metadata.distribution("esphome").read_text( + "direct_url.json" + ) + except importlib.metadata.PackageNotFoundError: + return None + if not direct_url: + return None + # Not installed from a package index, so the release version alone may not + # describe it. The raw file is never logged: its URL can hold a token. + try: + info = json.loads(direct_url) + commit = info.get("vcs_info", {}).get("commit_id") + url = info.get("url") + except (ValueError, AttributeError): + return _unknown_install("unreadable direct_url.json") + if not isinstance(url, str): + return _unknown_install("direct_url.json has no URL") + if commit: + return _Source(strip_url_credentials(url), commit) + if "dir_info" in info and url.startswith("file:"): + # An editable or local install may be a fork or a patched clone. The + # Docker image's tree has no .git, so it keeps the release description. + return _checkout_vcs(Path(url2pathname(urlparse(url).path))) + return _unknown_install("not a git or local folder install") + + +def _project_manifest() -> dict[str, Any] | None: + if (project := CORE.config[CONF_ESPHOME].get(CONF_PROJECT)) is None: + return None + return {"name": project[CONF_NAME], "version": project[CONF_VERSION]} + + +def _source_commit(source: ExternalSource) -> str | None: + """Return the commit checked out for a git source, if it can be read.""" + if source.repo_dir is None: + return None + try: + return run_git_command(["git", "rev-parse", "HEAD"], git_dir=source.repo_dir) + except GitException as err: + # Only SBOM metadata depends on it; never fail the build over it + _LOGGER.warning("SBOM: cannot read commit in %s: %s", source.repo_dir, err) + return None + + +def _external_manifest(source: ExternalSource, name: str) -> dict[str, Any]: + manifest: dict[str, Any] = {"name": name} + if source.url is None: + manifest["description"] = "Local external component" + return manifest + location = f"{source.url}@{source.ref}" if source.ref else source.url + manifest["description"] = f"External component from {location}" + commit = _source_commit(source) + if commit: + manifest["version"] = commit + url = _https_repo_url(source.url) + if _is_remote_url(url): + manifest["repository"] = url + if commit and source.repo_dir is not None: + component_dir = (source.components_dir / name).resolve() + repo_dir = source.repo_dir.resolve() + if not component_dir.is_relative_to(repo_dir): + # An absolute or ../ path: nothing in the repository to point at + _LOGGER.warning( + "SBOM: component '%s' is outside its repository %s, so it has no PURL", + name, + source.url, + ) + elif purl := _repo_purl( + url, commit, component_dir.relative_to(repo_dir).as_posix() + ): + manifest["purl"] = purl + return manifest + + +def _external_manifests() -> list[dict[str, Any]]: + """Carve each loaded external component out of the ESPHome package.""" + sources = [(s, s.components_dir.resolve()) for s in get_sources()] + found: dict[Path, ExternalSource] = {} + for domain in CORE.loaded_integrations: + manifest = loader.get_component(domain) + module_file = getattr(getattr(manifest, "module", None), "__file__", None) + if module_file is None: + _LOGGER.warning( + "SBOM: cannot locate component '%s'; it is listed as part of ESPHome", + domain, + ) + continue + component_dir = Path(module_file).resolve().parent + for source, components_dir in sources: + if component_dir.parent == components_dir: + found[component_dir] = source + break + else: + if not component_dir.is_relative_to(loader.CORE_COMPONENTS_PATH): + _LOGGER.warning( + "SBOM: component '%s' in %s matches no external component " + "source and is listed as part of ESPHome", + domain, + component_dir, + ) + return [ + { + "dest": f"esphome/components/{component_dir.name}", + "manifest": _external_manifest(source, component_dir.name), + } + for component_dir, source in sorted(found.items(), key=lambda i: i[0].name) + ] + + +def _esphome_manifest() -> dict[str, Any]: + manifest: dict[str, Any] = { + "name": "esphome", + "version": ESPHOME_VERSION, + "description": "ESPHome firmware runtime and generated code", + "supplier": ESPHOME_SUPPLIER, + "license": ESPHOME_LICENSE, + } + if (source := _esphome_vcs()) is not None: + # Installed from git, possibly a fork, or from a checkout + url = _https_repo_url(source.url) if source.url else None + if _is_remote_url(url): + manifest["repository"] = url + if source.commit and (purl := _repo_purl(url, source.commit)): + manifest["purl"] = purl + else: + manifest["repository"] = ESPHOME_REPOSITORY + if "dev" not in ESPHOME_VERSION: + # Release versions are also the git tags + manifest["purl"] = _repo_purl(ESPHOME_REPOSITORY, ESPHOME_VERSION) + if externals := _external_manifests(): + manifest["manifests"] = externals + return manifest + + +def write_manifests() -> None: + """Write the project and ``src`` component manifests into the build tree. + + Runs on every ESP-IDF build, so a failure is logged and never stops it. + """ + try: + _write_manifests() + except (OSError, ValueError) as err: + _LOGGER.warning("SBOM: could not write sbom.yml manifests: %s", err) + # Manifests from an earlier build would describe other firmware + for path in ( + CORE.relative_build_path(SBOM_FILENAME), + CORE.relative_src_path(SBOM_FILENAME), + ): + with contextlib.suppress(OSError): + path.unlink(missing_ok=True) + + +def _write_manifests() -> None: + project_path = CORE.relative_build_path(SBOM_FILENAME) + if (project := _project_manifest()) is not None: + write_file_if_changed(project_path, yaml_util.dump(project)) + elif project_path.is_file(): + project_path.unlink() + write_file_if_changed( + CORE.relative_src_path(SBOM_FILENAME), yaml_util.dump(_esphome_manifest()) + ) diff --git a/esphome/git.py b/esphome/git.py index 14145a639bb..de91e3c8227 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -154,6 +154,14 @@ def _redact_url_credentials(text: str) -> str: return re.sub(r"://[^/@\s]+@", "://***@", text) +def strip_url_credentials(url: str) -> str: + """Return ``url`` without its userinfo (``user:token@``), for URLs that are published.""" + parsed = urllib.parse.urlsplit(url) + if "@" not in parsed.netloc: + return url + return parsed._replace(netloc=parsed.netloc.rpartition("@")[2]).geturl() + + def _is_transient_git_error(stderr: str) -> bool: """Return True when git's stderr looks like a transient network failure.""" lowered = stderr.lower() diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 78cb32af54c..d9856c18ca9 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -3,14 +3,19 @@ import logging from pathlib import Path from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from esphome.components.external_components import do_external_components_pass +from esphome.components.external_components import ( + ExternalSource, + do_external_components_pass, + get_sources, +) from esphome.const import ( CONF_EXTERNAL_COMPONENTS, CONF_PATH, + CONF_REF, CONF_REFRESH, CONF_SOURCE, CONF_URL, @@ -183,3 +188,65 @@ def test_external_components_no_override_no_log( do_external_components_pass(config) assert "are overriding built-in components" not in caplog.text + + +def test_external_components_records_git_source( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, +) -> None: + """A git source is recorded with its URL, ref and checkout; validation runs no git.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_REF] = "main" + + with patch("esphome.git.run_git_command") as run_git: + do_external_components_pass(config) + + run_git.assert_not_called() + assert get_sources() == [ + ExternalSource( + components_dir=tmp_path / "components", + url="https://github.com/test/components", + ref="main", + repo_dir=tmp_path, + ) + ] + + +def test_external_components_strips_url_credentials( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, +) -> None: + """Credentials in a git URL are not recorded, since the URL ends up in SBOMs.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_URL] = ( + "https://user:ghp_secret@github.com/test/components" + ) + + do_external_components_pass(config) + + assert get_sources()[0].url == "https://github.com/test/components" + + +def test_external_components_records_local_source( + tmp_path: Path, + mock_install_meta_finder: MagicMock, +) -> None: + """A local source is recorded without any git details.""" + components_dir = tmp_path / "my_components" + (components_dir / "foo").mkdir(parents=True) + (components_dir / "foo" / "__init__.py").write_text("# Test component") + + CORE.config_path = tmp_path / "dummy.yaml" + config = { + CONF_EXTERNAL_COMPONENTS: [ + {CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}} + ] + } + + do_external_components_pass(config) + + assert get_sources() == [ExternalSource(components_dir=components_dir)] diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 1498ff5dc90..6c9d9081d42 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -20,6 +20,7 @@ from esphome.espidf.component import ( generate_cmakelists_txt, generate_idf_component_yml, generate_idf_components, + generate_sbom_yml, ) import esphome.platformio.library from esphome.platformio.library import ( @@ -1310,6 +1311,102 @@ def test_emit_idf_component_wires_esp32_target(tmp_path, monkeypatch): assert c.data["build"]["flags"] == ["-lesp32"] +def test_generate_sbom_yml(tmp_component): + """The manifest carries the library's own name and version.""" + tmp_component.data = {"name": "Lib", "version": "2.1.0", "license": "MIT"} + assert generate_sbom_yml(tmp_component) == ( + "# Generated by ESPHome\nname: Lib\nversion: 2.1.0\n" + ) + + tmp_component.data = {"version": "2.1.0"} + assert generate_sbom_yml(tmp_component) == ( + "# Generated by ESPHome\nname: owner/name\nversion: 2.1.0\n" + ) + + tmp_component.data = {"name": "Lib"} + assert generate_sbom_yml(tmp_component) is None + + +def test_emit_idf_component_writes_sbom_yml(tmp_path, monkeypatch): + """ESPHome's own sbom.yml follows the library version and is removed without one.""" + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + (tmp_path / "src").mkdir() + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + sbom_yml = tmp_path / "sbom.yml" + + c.data = {"name": "Lib", "version": "2.1.0"} + _emit_idf_component(c) + assert sbom_yml.read_text().endswith("version: 2.1.0\n") + + # A refreshed git library updates library.json in place + c.data = {"name": "Lib", "version": "2.2.0"} + _emit_idf_component(c) + assert sbom_yml.read_text().endswith("version: 2.2.0\n") + + c.data = {"name": "Lib"} + _emit_idf_component(c) + assert not sbom_yml.exists() + + +def test_emit_idf_component_keeps_shipped_sbom_yml(tmp_path, monkeypatch): + """A library's own sbom.yml wins; a local library's is copied to its build dir.""" + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + (tmp_path / "src").mkdir() + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"name": "Lib", "version": "2.1.0"} + (tmp_path / "sbom.yml").write_text("name: shipped\n") + _emit_idf_component(c) + assert (tmp_path / "sbom.yml").read_text() == "name: shipped\n" + + local = tmp_path / "local" + (local / "src").mkdir(parents=True) + (local / "sbom.yml").write_text("name: local\n") + build = tmp_path / "build" + build.mkdir() + c.path = build + c.source_path = local + _emit_idf_component(c) + assert (build / "sbom.yml").read_text() == "name: local\n" + + +def test_emit_idf_component_sbom_yml_error_does_not_fail( + tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture +): + """A library manifest that cannot be read is logged, not fatal.""" + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + (tmp_path / "src").mkdir() + (tmp_path / "sbom.yml").write_bytes(b"\xff\xfe not utf-8") + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"name": "Lib", "version": "2.1.0"} + _emit_idf_component(c) + assert "could not write sbom.yml for owner/name" in caplog.text + + +def test_emit_idf_component_sbom_yml_error_removes_generated(tmp_path, monkeypatch): + """After a failure, ESPHome's own stale sbom.yml is removed.""" + monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32") + local = tmp_path / "local" + (local / "src").mkdir(parents=True) + (local / "sbom.yml").write_bytes(b"\xff\xfe not utf-8") + build = tmp_path / "build" + build.mkdir() + (build / "sbom.yml").write_text("# Generated by ESPHome\nname: Lib\nversion: 1.0\n") + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = build + c.source_path = local + c.data = {"name": "Lib", "version": "2.1.0"} + _emit_idf_component(c) + assert not (build / "sbom.yml").exists() + + # A manifest copied from the library is not ESPHome's to remove + (build / "sbom.yml").write_text("name: copied\n") + _emit_idf_component(c) + assert (build / "sbom.yml").read_text() == "name: copied\n" + + def test_build_flags_dangling_flag_does_not_cross_entries( tmp_path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_espidf_sbom.py b/tests/unit_tests/test_espidf_sbom.py new file mode 100644 index 00000000000..2d65f2c42ad --- /dev/null +++ b/tests/unit_tests/test_espidf_sbom.py @@ -0,0 +1,617 @@ +"""Tests for the esp-idf-sbom manifests written into the build tree.""" + +from collections.abc import Generator +import importlib.metadata +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import yaml_util +from esphome.components.external_components import ExternalSource, get_sources +from esphome.const import CONF_ESPHOME, CONF_NAME, CONF_PROJECT, CONF_VERSION +from esphome.core import CORE +from esphome.espidf import sbom +from esphome.git import GitCommandError + + +@pytest.fixture(name="build_tree") +def fixture_build_tree(tmp_path: Path) -> Generator[Path]: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.relative_src_path().mkdir(parents=True) + CORE.config = {CONF_ESPHOME: {}} + # Independent of how the test environment installed ESPHome + with patch.object(sbom, "_esphome_vcs", return_value=None): + yield CORE.build_path + + +def _read(path: Path) -> dict: + return yaml_util.parse_yaml(path, path.open(encoding="utf-8")) + + +def _add_external_component(components_dir: Path, name: str) -> None: + """Make ``name`` resolve to a module in ``components_dir``, as a meta finder would.""" + module_file = components_dir / name / "__init__.py" + module_file.parent.mkdir(parents=True) + module_file.write_text("") + CORE.loaded_integrations.add(name) + sbom.loader._COMPONENT_CACHE[name] = SimpleNamespace( + module=SimpleNamespace(__file__=str(module_file)) + ) + + +@pytest.fixture(autouse=True) +def _clear_component_cache(): + yield + sbom.loader._COMPONENT_CACHE.clear() + + +@pytest.mark.parametrize( + ("url", "subpath", "expected"), + [ + ("https://github.com/esphome/esphome", None, "pkg:github/esphome/esphome@v1"), + ( + "https://github.com/o/r.git", + "components/x", + "pkg:github/o/r@v1#components/x", + ), + ("https://gitlab.com/o/r/", ".", "pkg:gitlab/o/r@v1"), + ("https://gitlab.com/group/sub/r", None, None), + ("https://example.com/o/r", None, None), + ], +) +def test_repo_purl(url: str, subpath: str | None, expected: str | None) -> None: + assert sbom._repo_purl(url, "v1", subpath) == expected + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://github.com/o/r", True), + ("git://host/r", True), + ("git@github.com:o/r.git", False), + ("/local/path", False), + (None, False), + ], +) +def test_is_remote_url(url: str | None, expected: bool) -> None: + assert sbom._is_remote_url(url) is expected + + +def _direct_url(url: str | None, commit: str | None) -> str: + return json.dumps({"url": url, "vcs_info": {"vcs": "git", "commit_id": commit}}) + + +@pytest.mark.parametrize( + ("direct_url", "expected"), + [ + ( + _direct_url("https://github.com/esphome/esphome", "abc"), + ("https://github.com/esphome/esphome", "abc"), + ), + ( + _direct_url("https://user:token@github.com/someone/esphome", "abc"), + ("https://github.com/someone/esphome", "abc"), + ), + # Present but not understood: the source is unknown, not the release + (_direct_url(None, "abc"), (None, None)), + (_direct_url("https://github.com/esphome/esphome", None), (None, None)), + (json.dumps({"dir_info": {"editable": True}}), (None, None)), + ( + json.dumps( + { + "url": "https://github.com/someone/esphome/archive/fix.tar.gz", + "archive_info": {}, + } + ), + (None, None), + ), + (json.dumps([]), (None, None)), + ("not json", (None, None)), + # No direct_url.json: installed from a package index + (None, None), + ], +) +def test_esphome_vcs(direct_url: str | None, expected: tuple[str, str] | None) -> None: + dist = MagicMock() + dist.read_text.return_value = direct_url + with patch("importlib.metadata.distribution", return_value=dist): + assert sbom._esphome_vcs() == expected + + +def test_esphome_manifest_release(build_tree: Path) -> None: + with ( + patch.object(sbom, "ESPHOME_VERSION", "2026.10.1"), + patch.object(sbom, "_esphome_vcs", return_value=None), + ): + sbom.write_manifests() + + assert _read(build_tree / "src" / "sbom.yml") == { + "name": "esphome", + "version": "2026.10.1", + "description": "ESPHome firmware runtime and generated code", + "supplier": sbom.ESPHOME_SUPPLIER, + "license": "GPL-3.0-only", + "repository": "https://github.com/esphome/esphome", + "purl": "pkg:github/esphome/esphome@2026.10.1", + } + + +def test_esphome_manifest_dev_commit(build_tree: Path) -> None: + """A git install is identified by its commit; a dev version is not a tag.""" + with ( + patch.object(sbom, "ESPHOME_VERSION", "2026.11.0-dev"), + patch.object( + sbom, + "_esphome_vcs", + return_value=sbom._Source("https://github.com/esphome/esphome.git", "abc"), + ), + ): + sbom.write_manifests() + assert ( + _read(build_tree / "src" / "sbom.yml")["purl"] + == "pkg:github/esphome/esphome@abc" + ) + + with ( + patch.object(sbom, "ESPHOME_VERSION", "2026.11.0-dev"), + patch.object(sbom, "_esphome_vcs", return_value=None), + ): + sbom.write_manifests() + assert "purl" not in _read(build_tree / "src" / "sbom.yml") + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://github.com/someone/esphome", + { + "repository": "https://github.com/someone/esphome", + "purl": "pkg:github/someone/esphome@abc", + }, + ), + ("file:///src/esphome", {}), + ], +) +def test_esphome_manifest_fork( + build_tree: Path, url: str, expected: dict[str, str] +) -> None: + """A git install from a fork or a local checkout names that source, not upstream.""" + with patch.object(sbom, "_esphome_vcs", return_value=sbom._Source(url, "abc")): + sbom.write_manifests() + manifest = _read(build_tree / "src" / "sbom.yml") + assert {k: manifest[k] for k in ("repository", "purl") if k in manifest} == expected + + +def test_project_manifest(build_tree: Path) -> None: + """The project manifest follows esphome.project and is removed with it.""" + CORE.config[CONF_ESPHOME][CONF_PROJECT] = { + CONF_NAME: "acme.thermostat", + CONF_VERSION: "1.2.3", + } + sbom.write_manifests() + assert _read(build_tree / "sbom.yml") == { + "name": "acme.thermostat", + "version": "1.2.3", + } + + del CORE.config[CONF_ESPHOME][CONF_PROJECT] + sbom.write_manifests() + assert not (build_tree / "sbom.yml").exists() + + +def test_external_components(build_tree: Path, tmp_path: Path) -> None: + """Each loaded external component becomes its own package inside src.""" + git_dir = tmp_path / "repo" / "components" + local_dir = tmp_path / "local" + get_sources().extend( + [ + ExternalSource( + components_dir=git_dir, + url="https://github.com/acme/parts", + ref="main", + repo_dir=tmp_path / "repo", + ), + ExternalSource(components_dir=local_dir), + ] + ) + _add_external_component(git_dir, "widget") + _add_external_component(local_dir, "gadget") + # Provided by the source but not used by this configuration + (git_dir / "unused").mkdir() + + with patch.object(sbom, "run_git_command", return_value="abc") as run_git: + sbom.write_manifests() + + run_git.assert_called_once_with( + ["git", "rev-parse", "HEAD"], git_dir=tmp_path / "repo" + ) + + assert _read(build_tree / "src" / "sbom.yml")["manifests"] == [ + { + "dest": "esphome/components/gadget", + "manifest": {"name": "gadget", "description": "Local external component"}, + }, + { + "dest": "esphome/components/widget", + "manifest": { + "name": "widget", + "description": "External component from https://github.com/acme/parts@main", + "version": "abc", + "repository": "https://github.com/acme/parts", + "purl": "pkg:github/acme/parts@abc#components/widget", + }, + }, + ] + + +def test_external_component_unusual_url() -> None: + """A URL esp-idf-sbom would reject is kept only in the description.""" + source = ExternalSource( + components_dir=Path("/x"), url="git@example.com:o/r.git", repo_dir=Path("/x") + ) + with patch.object(sbom, "run_git_command", return_value="abc"): + manifest = sbom._external_manifest(source, "widget") + assert manifest == { + "name": "widget", + "description": "External component from git@example.com:o/r.git", + "version": "abc", + } + + +def test_external_component_ssh_url(tmp_path: Path) -> None: + """An SSH GitHub URL keeps its repository and PURL in https form.""" + source = ExternalSource( + components_dir=tmp_path / "components", + url="git@github.com:acme/parts.git", + repo_dir=tmp_path, + ) + with patch.object(sbom, "run_git_command", return_value="abc"): + manifest = sbom._external_manifest(source, "widget") + assert manifest["repository"] == "https://github.com/acme/parts.git" + assert manifest["purl"] == "pkg:github/acme/parts@abc#components/widget" + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("git@github.com:o/r.git", "https://github.com/o/r.git"), + ("github.com:o/r", "https://github.com/o/r"), + ("ssh://git@gitlab.com:2222/o/r.git", "https://gitlab.com/o/r.git"), + ("git+ssh://git@github.com/o/r", "https://github.com/o/r"), + ("git@example.com:o/r.git", "git@example.com:o/r.git"), + ("ssh://git@example.com/o/r", "ssh://git@example.com/o/r"), + ("https://github.com/o/r", "https://github.com/o/r"), + ("/src/esphome", "/src/esphome"), + ("C:\\src\\esphome", "C:\\src\\esphome"), + ], +) +def test_https_repo_url(url: str, expected: str) -> None: + assert sbom._https_repo_url(url) == expected + + +def test_esphome_commit_not_installed() -> None: + with patch( + "importlib.metadata.distribution", + side_effect=importlib.metadata.PackageNotFoundError, + ): + assert sbom._esphome_vcs() is None + + +def test_external_component_without_commit() -> None: + """Without a readable commit there is no version and no PURL to pin it.""" + expected = { + "name": "widget", + "description": "External component from https://github.com/o/r", + "repository": "https://github.com/o/r", + } + source = ExternalSource(components_dir=Path("/x"), url="https://github.com/o/r") + assert sbom._external_manifest(source, "widget") == expected + + source.repo_dir = Path("/x") + with patch.object( + sbom, "run_git_command", side_effect=GitCommandError("not a repo") + ): + assert sbom._external_manifest(source, "widget") == expected + + +def test_external_manifests_skip_other_components( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Only external components are carved out; unlocatable ones are reported.""" + components_dir = tmp_path / "components" + get_sources().append(ExternalSource(components_dir=components_dir)) + _add_external_component(components_dir, "widget") + _add_external_component(tmp_path / "builtin", "wifi") + CORE.loaded_integrations.update({"missing", "namespace"}) + sbom.loader._COMPONENT_CACHE["namespace"] = SimpleNamespace( + module=SimpleNamespace() + ) + + with ( + patch.object( + sbom.loader, + "get_component", + side_effect=sbom.loader._COMPONENT_CACHE.get, + ), + patch.object( + sbom.loader, "CORE_COMPONENTS_PATH", (tmp_path / "builtin").resolve() + ), + ): + manifests = sbom._external_manifests() + + assert [m["dest"] for m in manifests] == ["esphome/components/widget"] + assert "'wifi'" not in caplog.text + assert "cannot locate component 'missing'" in caplog.text + assert "cannot locate component 'namespace'" in caplog.text + + +def test_external_manifests_warn_unmatched( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A component from outside ESPHome that matches no source is not dropped silently.""" + _add_external_component(tmp_path / "stray", "widget") + + sbom._external_manifests() + + assert "SBOM: component 'widget'" in caplog.text + assert "matches no external component source" in caplog.text + + +def _editable_direct_url(path: Path) -> str: + return json.dumps({"url": path.as_uri(), "dir_info": {"editable": True}}) + + +def _fake_git(outputs: dict[str, str | Exception]): + """Answer git commands by their longest matching argument prefix.""" + + def run(cmd: list[str], git_dir: Path) -> str: + args = " ".join(cmd[1:]) + prefix = max((p for p in outputs if args.startswith(p)), key=len) + if isinstance(result := outputs[prefix], Exception): + raise result + return result + + return run + + +def _checkout_outputs(**overrides: str | Exception) -> dict[str, str | Exception]: + outputs: dict[str, str | Exception] = { + "rev-parse HEAD": "abc", + "rev-parse --abbrev-ref HEAD": "dev", + "config --get branch.dev.remote": "origin", + "remote get-url origin": "https://user:token@github.com/someone/esphome.git", + "status": "", + "branch --remotes": " origin/dev", + } + outputs.update({k.replace("_", " "): v for k, v in overrides.items()}) + return outputs + + +def test_esphome_vcs_editable_checkout(tmp_path: Path) -> None: + """An editable install from a git clone is described by that clone.""" + (tmp_path / ".git").mkdir() + dist = MagicMock() + dist.read_text.return_value = _editable_direct_url(tmp_path) + with ( + patch("importlib.metadata.distribution", return_value=dist), + patch.object( + sbom, "run_git_command", side_effect=_fake_git(_checkout_outputs()) + ) as run_git, + ): + assert sbom._esphome_vcs() == ("https://github.com/someone/esphome.git", "abc") + assert {call.kwargs["git_dir"] for call in run_git.call_args_list} == {tmp_path} + + +def test_checkout_vcs_tracked_remote(tmp_path: Path) -> None: + """The URL and the pushed check both use the remote the branch tracks.""" + (tmp_path / ".git").mkdir() + outputs = _checkout_outputs() + outputs["config --get branch.dev.remote"] = "fork" + outputs["remote get-url fork"] = "git@github.com:me/esphome.git" + with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)) as run: + assert sbom._checkout_vcs(tmp_path) == ("git@github.com:me/esphome.git", "abc") + assert ["git", "branch", "--remotes", "--contains", "HEAD", "--list", "fork/*"] in [ + call.args[0] for call in run.call_args_list + ] + + +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + # Detached HEAD: falls back to origin + ( + {"config --get branch.dev.remote": GitCommandError("unset")}, + ("https://github.com/someone/esphome.git", "abc"), + ), + # No remote at all: nothing that can be fetched + ({"remote get-url origin": GitCommandError("no such remote")}, (None, None)), + # Pushed check fails: keep the repository, drop the commit + ( + {"branch --remotes": GitCommandError("bad ref")}, + ("https://github.com/someone/esphome.git", None), + ), + ], + ids=["detached", "no-remote", "pushed-check-fails"], +) +def test_checkout_vcs_remote_fallbacks( + tmp_path: Path, overrides: dict[str, str | Exception], expected: tuple +) -> None: + (tmp_path / ".git").mkdir() + outputs = _checkout_outputs() + outputs.update(overrides) + with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)): + assert sbom._checkout_vcs(tmp_path) == expected + + +def test_esphome_vcs_editable_without_git(tmp_path: Path) -> None: + """A local tree without .git, like the Docker image's, keeps the release description.""" + dist = MagicMock() + dist.read_text.return_value = _editable_direct_url(tmp_path) + with ( + patch("importlib.metadata.distribution", return_value=dist), + patch.object(sbom, "run_git_command") as run_git, + ): + assert sbom._esphome_vcs() is None + run_git.assert_not_called() + + +@pytest.mark.parametrize( + ("status", "branch"), + [(" M esphome/core/application.cpp", " origin/dev"), ("", "")], + ids=["modified", "unpushed"], +) +def test_esphome_vcs_editable_not_exact( + tmp_path: Path, status: str, branch: str +) -> None: + """Local edits or an unpushed commit keep the repository but drop the commit.""" + (tmp_path / ".git").mkdir() + dist = MagicMock() + dist.read_text.return_value = _editable_direct_url(tmp_path) + outputs = _checkout_outputs() + outputs["remote get-url origin"] = "git@github.com:someone/esphome.git" + outputs["status"] = status + outputs["branch --remotes"] = branch + with ( + patch("importlib.metadata.distribution", return_value=dist), + patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)), + ): + assert sbom._esphome_vcs() == ("git@github.com:someone/esphome.git", None) + + +def test_esphome_vcs_editable_git_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable checkout is an unknown source, not the upstream release.""" + (tmp_path / ".git").mkdir() + dist = MagicMock() + dist.read_text.return_value = _editable_direct_url(tmp_path) + with ( + patch("importlib.metadata.distribution", return_value=dist), + patch.object( + sbom, "run_git_command", side_effect=GitCommandError("broken repo") + ), + ): + assert sbom._esphome_vcs() == (None, None) + assert "cannot read the git checkout ESPHome runs from" in caplog.text + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ( + sbom._Source("git@github.com:someone/esphome.git", "abc"), + { + "repository": "https://github.com/someone/esphome.git", + "purl": "pkg:github/someone/esphome@abc", + }, + ), + ( + sbom._Source("git@github.com:someone/esphome.git", None), + {"repository": "https://github.com/someone/esphome.git"}, + ), + (sbom._Source(None, None), {}), + ], + ids=["ssh", "not-exact", "unknown"], +) +def test_esphome_manifest_checkout( + build_tree: Path, source: sbom._Source, expected: dict[str, str] +) -> None: + with patch.object(sbom, "_esphome_vcs", return_value=source): + sbom.write_manifests() + manifest = _read(build_tree / "src" / "sbom.yml") + assert {k: manifest[k] for k in ("repository", "purl") if k in manifest} == expected + + +def test_esphome_vcs_unknown_warns_without_url( + caplog: pytest.LogCaptureFixture, +) -> None: + """An unknown install is reported without logging the URL, which may hold a token.""" + dist = MagicMock() + dist.read_text.return_value = json.dumps( + {"url": "https://user:secret@example.com/esphome.tar.gz", "archive_info": {}} + ) + with patch("importlib.metadata.distribution", return_value=dist): + assert sbom._esphome_vcs() == (None, None) + assert "cannot tell where ESPHome was installed from" in caplog.text + assert "secret" not in caplog.text + + +def test_write_manifests_never_fails_the_build( + build_tree: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed run logs and removes the earlier build's manifests.""" + for path in (build_tree / "sbom.yml", build_tree / "src" / "sbom.yml"): + path.write_text("name: stale\n") + with patch.object(sbom, "_write_manifests", side_effect=OSError("disk full")): + sbom.write_manifests() + assert "could not write sbom.yml manifests: disk full" in caplog.text + assert not (build_tree / "sbom.yml").exists() + assert not (build_tree / "src" / "sbom.yml").exists() + + +def test_source_commit_failure_warns(caplog: pytest.LogCaptureFixture) -> None: + source = ExternalSource( + components_dir=Path("/x"), url="https://github.com/o/r", repo_dir=Path("/x") + ) + with patch.object( + sbom, "run_git_command", side_effect=GitCommandError("not a repo") + ): + assert sbom._source_commit(source) is None + assert "cannot read commit in" in caplog.text + + +@pytest.mark.parametrize("absolute", [True, False], ids=["absolute", "parent"]) +def test_external_component_outside_repo( + build_tree: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture, absolute: bool +) -> None: + """A components path outside the repository loses only that component's PURL.""" + repo_dir = tmp_path / "repo" + # What repo_dir / path gives for path: /abs/components and path: ../outside + path = str(tmp_path / "elsewhere" / "components") if absolute else "../outside" + components_dir = repo_dir / path + get_sources().append( + ExternalSource( + components_dir=components_dir, + url="https://github.com/acme/parts", + repo_dir=repo_dir, + ) + ) + _add_external_component(components_dir, "widget") + + with patch.object(sbom, "run_git_command", return_value="abc"): + sbom.write_manifests() + + manifest = _read(build_tree / "src" / "sbom.yml") + assert manifest["name"] == "esphome" + widget = manifest["manifests"][0]["manifest"] + assert widget["version"] == "abc" + assert "purl" not in widget + assert "component 'widget' is outside its repository" in caplog.text + + +def test_checkout_vcs_status_scope(tmp_path: Path) -> None: + """Changes are looked for in the package only, untracked files included.""" + (tmp_path / ".git").mkdir() + with patch.object( + sbom, "run_git_command", side_effect=_fake_git(_checkout_outputs()) + ) as run: + sbom._checkout_vcs(tmp_path) + assert ["git", "status", "--porcelain", "--", "esphome"] in [ + call.args[0] for call in run.call_args_list + ] + + +def test_checkout_vcs_pushed_check_failure_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + (tmp_path / ".git").mkdir() + outputs = _checkout_outputs() + outputs["branch --remotes"] = GitCommandError("bad ref") + with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)): + assert sbom._checkout_vcs(tmp_path)[1] is None + assert "cannot check whether origin has HEAD" in caplog.text diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 027d328e65e..5adcddd1c8a 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -3302,3 +3302,19 @@ def test_git_file_from_shorthand_bad_format() -> None: def test_git_file_from_shorthand_unknown_domain() -> None: with pytest.raises(ValueError, match="Unknown git domain bitbucket"): git.GitFile.from_shorthand("bitbucket://owner/repo/file.yaml") + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://user:token@github.com/o/r.git", + "https://github.com/o/r.git", + ), + ("https://token@gitlab.com:8443/o/r", "https://gitlab.com:8443/o/r"), + ("https://github.com/o/r", "https://github.com/o/r"), + ("git@github.com:o/r.git", "git@github.com:o/r.git"), + ], +) +def test_strip_url_credentials(url: str, expected: str) -> None: + assert git.strip_url_credentials(url) == expected From 051978212c19b7e01fa6d61cfb5d8848878da893 Mon Sep 17 00:00:00 2001 From: minimicro34 Date: Sat, 24 Oct 2026 02:01:16 +0200 Subject: [PATCH 18/37] [esp32_ble] Ignore unhandled BLE key GAP events (#21457) --- esphome/components/esp32_ble/ble.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 777aac232ed..1fed99e248a 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -667,6 +667,7 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa } // Ignore these GAP events as they are not relevant for our use case + case ESP_GAP_BLE_KEY_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm From b401a8bf173674b6b5d73d5b31001591fe305343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98verli?= Date: Sat, 24 Oct 2026 02:49:19 +0200 Subject: [PATCH 19/37] [toshiba] Add support for RAS-25J2KVSG-ND (#21413) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/toshiba/__init__.py | 6 + esphome/components/toshiba/climate.py | 42 +- .../components/toshiba/ras_25j2kvsg_nd.cpp | 512 +++++++ esphome/components/toshiba/ras_25j2kvsg_nd.h | 114 ++ esphome/components/toshiba/select.py | 30 + esphome/components/toshiba/toshiba.cpp | 157 +- esphome/components/toshiba/toshiba.h | 78 +- esphome/core/defines.h | 1 + tests/component_tests/toshiba/__init__.py | 0 .../toshiba/config/ras_25j2kvsg_nd.yaml | 29 + .../toshiba/config/select_on_generic.yaml | 26 + .../toshiba/config/step_vane_on_generic.yaml | 29 + .../toshiba/test_toshiba_config.py | 59 + tests/components/toshiba/__init__.py | 8 +- .../toshiba/common_ras_25j2kvsg_nd.yaml | 50 + .../toshiba/test-ras25j2kvsgnd.esp32-idf.yaml | 4 + .../test-ras25j2kvsgnd.esp8266-ard.yaml | 4 + tests/components/toshiba/toshiba_tests.cpp | 1270 ++++++++++++++++- 18 files changed, 2268 insertions(+), 151 deletions(-) create mode 100644 esphome/components/toshiba/ras_25j2kvsg_nd.cpp create mode 100644 esphome/components/toshiba/ras_25j2kvsg_nd.h create mode 100644 esphome/components/toshiba/select.py create mode 100644 tests/component_tests/toshiba/__init__.py create mode 100644 tests/component_tests/toshiba/config/ras_25j2kvsg_nd.yaml create mode 100644 tests/component_tests/toshiba/config/select_on_generic.yaml create mode 100644 tests/component_tests/toshiba/config/step_vane_on_generic.yaml create mode 100644 tests/component_tests/toshiba/test_toshiba_config.py create mode 100644 tests/components/toshiba/common_ras_25j2kvsg_nd.yaml create mode 100644 tests/components/toshiba/test-ras25j2kvsgnd.esp32-idf.yaml create mode 100644 tests/components/toshiba/test-ras25j2kvsgnd.esp8266-ard.yaml diff --git a/esphome/components/toshiba/__init__.py b/esphome/components/toshiba/__init__.py index 1e5ec1fc278..525195e3dee 100644 --- a/esphome/components/toshiba/__init__.py +++ b/esphome/components/toshiba/__init__.py @@ -1 +1,7 @@ +from esphome.config_helpers import filter_source_files_from_defines + DOMAIN = "toshiba" + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"ras_25j2kvsg_nd.cpp": "USE_TOSHIBA_RAS_25J2KVSG_ND"} +) diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 1a17045a410..51f294febe8 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -1,14 +1,22 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import climate_ir, remote_base import esphome.config_validation as cv -from esphome.const import CONF_MODEL +from esphome.const import CONF_ID, CONF_MODEL from esphome.types import ConfigType +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import + AUTO_LOAD = ["climate_ir"] CODEOWNERS = ["@kbx81"] +MODEL_RAS_25J2KVSG_ND = "RAS-25J2KVSG-ND" + toshiba_ns = cg.esphome_ns.namespace("toshiba") ToshibaClimate = toshiba_ns.class_("ToshibaClimate", climate_ir.ClimateIR) +ToshibaRas25j2kvsgNdClimate = toshiba_ns.class_( + "ToshibaRas25j2kvsgNdClimate", ToshibaClimate +) Model = toshiba_ns.enum("Model") MODELS = Model.options( @@ -17,18 +25,42 @@ MODELS = Model.options( "RAC-PT1411HWRU-F", "RAS-2819T", "RAS-B10N3KV2", + MODEL_RAS_25J2KVSG_ND, "SEIYA", prefix="MODEL_", ) -CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( +_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate) +_RAS_25J2KVSG_ND_SCHEMA = climate_ir.climate_ir_with_receiver_schema( + ToshibaRas25j2kvsgNdClimate +) + +CONFIG_SCHEMA = cv.typed_schema( { - cv.Optional(CONF_MODEL, default="generic"): cv.enum(MODELS, upper=True), - } + model: _RAS_25J2KVSG_ND_SCHEMA if model == MODEL_RAS_25J2KVSG_ND else _SCHEMA + for model in MODELS + }, + key=CONF_MODEL, + default_type="GENERIC", + enum=MODELS, + upper=True, ) async def to_code(config: ConfigType) -> None: remote_base.request_protocol("toshiba_ac") # used from C++ var = await climate_ir.new_climate_ir(config) - cg.add(var.set_model(config[CONF_MODEL])) + if config[CONF_MODEL] == MODEL_RAS_25J2KVSG_ND: + # The subclass sets its own model. + cg.add_define("USE_TOSHIBA_RAS_25J2KVSG_ND") + else: + cg.add(var.set_model(config[CONF_MODEL])) + + +automation.register_apply_action( + "toshiba.step_vane", + automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(ToshibaRas25j2kvsgNdClimate)} + ), + automation.ApplyCall("step_vane()"), +) diff --git a/esphome/components/toshiba/ras_25j2kvsg_nd.cpp b/esphome/components/toshiba/ras_25j2kvsg_nd.cpp new file mode 100644 index 00000000000..3d5bab33027 --- /dev/null +++ b/esphome/components/toshiba/ras_25j2kvsg_nd.cpp @@ -0,0 +1,512 @@ +#include "ras_25j2kvsg_nd.h" + +#ifdef USE_TOSHIBA_RAS_25J2KVSG_ND + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::toshiba { + +ESPHOME_LOG_TAG(TAG, "toshiba.climate"); + +static constexpr const char *const CUSTOM_FANS[] PROGMEM = {"Low+", "Medium+"}; +static constexpr const char *const CUSTOM_PRESETS[] PROGMEM = { + "Fireplace 1", "Fireplace 2", "Frost protection", "HADA Care", "Sleep 1h", "Sleep 3h", "Sleep 5h", "Sleep 9h", +}; +static constexpr uint8_t SLEEP_HOURS[] PROGMEM = {1, 3, 5, 9}; +// The two copies of a press can arrive in separate receiver buffers. +static constexpr uint32_t REPEAT_MS = 200; +static constexpr uint8_t MAX_FRAME_LENGTH = 12; +static constexpr uint8_t MOTION_FRAME_LENGTH = 7; +static constexpr uint8_t SHORT_MOTION_COMMAND = 0x21; +static constexpr uint8_t FIREPLACE_1 = 0x02; +static constexpr uint8_t FIREPLACE_2 = 0x03; +static constexpr uint8_t FEATURE_FROST = 0x04; +static constexpr uint8_t SILENT_1 = 0x02; +static constexpr uint8_t SILENT_2 = 0x0A; +static constexpr uint8_t HADA_PREFIX = 0x21; +static constexpr uint8_t HADA_SUFFIX = 0x17; +static constexpr uint8_t SLEEP_TIMER_MARKER = 0x7F; +static constexpr uint8_t SLEEP_TIMER_FLAGS = 0x03; +static constexpr float FROST_TEMP_C_DEFAULT = 8.0f; +// Frost targets are encoded from 1 C, normal targets from the normal minimum. +static constexpr uint8_t FROST_TEMP_CODE_BASE = 1; + +static float target_min(bool frost) { + return frost ? TOSHIBA_RAS_25J2KVSG_ND_FROST_TEMP_C_MIN : TOSHIBA_GENERIC_TEMP_C_MIN; +} +static float target_max(bool frost) { + return frost ? TOSHIBA_RAS_25J2KVSG_ND_FROST_TEMP_C_MAX : TOSHIBA_GENERIC_TEMP_C_MAX; +} +// NaN passes through unchanged, so it never equals a valid target. +static float clamp_target(float target, bool frost) { return clamp(target, target_min(frost), target_max(frost)); } +static uint8_t temperature_code_base(bool frost) { + return frost ? FROST_TEMP_CODE_BASE : static_cast(TOSHIBA_GENERIC_TEMP_C_MIN); +} +static float decode_target(const uint8_t *message, bool frost) { + return (message[5] >> 4) + temperature_code_base(frost); +} +// Pulses encode_() writes for a frame sent repeat + 1 times: header, 8 bits per byte, footer mark and gap. +static constexpr size_t frame_pulses(uint8_t nbytes, uint8_t repeat) { return (repeat + 1u) * (16u * nbytes + 4u); } + +void ToshibaRas25j2kvsgNdClimate::setup() { + this->set_supported_custom_fan_modes_static(CUSTOM_FANS, std::size(CUSTOM_FANS)); + this->set_supported_custom_presets_static(CUSTOM_PRESETS, std::size(CUSTOM_PRESETS)); + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_ECO}; + this->set_preset_(climate::CLIMATE_PRESET_NONE); + this->power_selection_pref_ = this->make_entity_preference(POWER_SELECTION_RESTORE_VERSION); + ToshibaPowerSelection power; + if (this->power_selection_pref_.load(&power) && power < ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_COUNT) + this->power_selection_ = power; + ToshibaClimate::setup(); + // A fresh target follows the sensor over the range that includes frost targets. + this->target_temperature = + clamp_target(this->target_temperature, this->toshiba_preset_() == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION); + this->publish_power_selection_(); +} + +void ToshibaRas25j2kvsgNdClimate::sanitize_restore_state(climate::ClimateDeviceRestoreState &restore) { + if (!this->modes_.count(restore.mode)) + restore.mode = climate::CLIMATE_MODE_OFF; + auto preset = ToshibaPreset::TOSHIBA_PRESET_NONE; + if (restore.uses_custom_preset && restore.custom_preset < std::size(CUSTOM_PRESETS)) + preset = custom_preset_at(restore.custom_preset); + // A restored duration is not the remaining time; never re-arm an OFF timer after reboot. + if ((restore.uses_custom_preset && (preset == ToshibaPreset::TOSHIBA_PRESET_NONE || is_sleep_preset(preset) || + (is_heating_preset(preset) && restore.mode != climate::CLIMATE_MODE_HEAT))) || + (!restore.uses_custom_preset && !this->presets_.count(restore.preset)) || + (is_silent(this->power_selection_) && + (restore.uses_custom_preset || restore.preset != climate::CLIMATE_PRESET_NONE))) { + restore.uses_custom_preset = false; + restore.preset = climate::CLIMATE_PRESET_NONE; + preset = ToshibaPreset::TOSHIBA_PRESET_NONE; + } + if (preset == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) + restore.swing_mode = climate::CLIMATE_SWING_OFF; + const bool frost = preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + // The restore state is packed, so clamp a copy rather than bind a reference to the field. + const float target = restore.target_temperature; + restore.target_temperature = + std::isfinite(target) ? clamp_target(target, frost) : (frost ? FROST_TEMP_C_DEFAULT : 24.0f); +} + +ToshibaRas25j2kvsgNdClimate::ToshibaPreset ToshibaRas25j2kvsgNdClimate::preset_from_climate( + climate::ClimatePreset preset) { + switch (preset) { + case climate::CLIMATE_PRESET_BOOST: + return ToshibaPreset::TOSHIBA_PRESET_BOOST; + case climate::CLIMATE_PRESET_ECO: + return ToshibaPreset::TOSHIBA_PRESET_ECO; + default: + return ToshibaPreset::TOSHIBA_PRESET_NONE; + } +} + +ToshibaRas25j2kvsgNdClimate::ToshibaPreset ToshibaRas25j2kvsgNdClimate::preset_from_custom(const char *custom_preset) { + // Climate keeps a pointer into the supported table, so comparing pointers is enough. + for (size_t i = 0; i < std::size(CUSTOM_PRESETS); i++) { + if (custom_preset == progmem_read_ptr(&CUSTOM_PRESETS[i])) + return custom_preset_at(i); + } + return ToshibaPreset::TOSHIBA_PRESET_NONE; +} + +ToshibaRas25j2kvsgNdClimate::ToshibaPreset ToshibaRas25j2kvsgNdClimate::toshiba_preset_() const { + if (this->has_custom_preset()) + return preset_from_custom(this->get_custom_preset().c_str()); + return preset_from_climate(this->preset.value_or(climate::CLIMATE_PRESET_NONE)); +} + +void ToshibaRas25j2kvsgNdClimate::apply_toshiba_preset_(ToshibaPreset preset) { + switch (preset) { + case ToshibaPreset::TOSHIBA_PRESET_NONE: + this->set_preset_(climate::CLIMATE_PRESET_NONE); + break; + case ToshibaPreset::TOSHIBA_PRESET_BOOST: + this->set_preset_(climate::CLIMATE_PRESET_BOOST); + break; + case ToshibaPreset::TOSHIBA_PRESET_ECO: + this->set_preset_(climate::CLIMATE_PRESET_ECO); + break; + default: + this->set_custom_preset_(progmem_read_ptr(&CUSTOM_PRESETS[custom_preset_index(preset)])); + } +} + +void ToshibaRas25j2kvsgNdClimate::enter_preset_(ToshibaPreset previous, ToshibaPreset preset) { + if (preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION && previous != preset) + this->normal_target_temperature_ = static_cast(this->target_temperature); + if (is_sleep_preset(preset) && !is_sleep_preset(previous)) + this->sleep_fan_ = this->fan_code_(); +} + +uint8_t ToshibaRas25j2kvsgNdClimate::fan_code_() const { + if (this->get_custom_fan_mode() == progmem_read_ptr(&CUSTOM_FANS[0])) + return TOSHIBA_FAN_SPEED_2; + if (this->get_custom_fan_mode() == progmem_read_ptr(&CUSTOM_FANS[1])) + return TOSHIBA_FAN_SPEED_4; + return this->encode_fan_(); +} + +void ToshibaRas25j2kvsgNdClimate::apply_fan_code_(uint8_t code) { + switch (code) { + case TOSHIBA_FAN_SPEED_2: + this->set_custom_fan_mode_(progmem_read_ptr(&CUSTOM_FANS[0])); + break; + case TOSHIBA_FAN_SPEED_4: + this->set_custom_fan_mode_(progmem_read_ptr(&CUSTOM_FANS[1])); + break; + default: + this->set_fan_mode_(decode_fan(code)); + } +} + +void ToshibaRas25j2kvsgNdClimate::control(const climate::ClimateCall &call) { + const auto previous_preset = this->toshiba_preset_(); + auto preset = previous_preset; + if (call.get_preset().has_value()) { + preset = preset_from_climate(*call.get_preset()); + } else if (call.has_custom_preset()) { + preset = preset_from_custom(call.get_custom_preset().c_str()); + } + + auto mode = call.get_mode().value_or(this->mode); + const bool heating_preset = is_heating_preset(preset); + if (heating_preset && !this->modes_.count(climate::CLIMATE_MODE_HEAT)) { + ESP_LOGW(TAG, "This preset requires HEAT support"); + return; + } + if (heating_preset && mode != climate::CLIMATE_MODE_HEAT) { + if (call.has_custom_preset() && call.get_mode().has_value()) { + ESP_LOGW(TAG, "This preset requires HEAT mode"); + return; + } + if (call.has_custom_preset()) { + mode = climate::CLIMATE_MODE_HEAT; + } else { + preset = ToshibaPreset::TOSHIBA_PRESET_NONE; + } + } + if (call.get_mode() == climate::CLIMATE_MODE_OFF && !call.get_preset().has_value() && !call.has_custom_preset()) + preset = ToshibaPreset::TOSHIBA_PRESET_NONE; + + if (!power_preset_supported(this->power_selection_, preset)) + return; + if (call.get_swing_mode().has_value() && *call.get_swing_mode() != climate::CLIMATE_SWING_OFF && + preset == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) { + ESP_LOGW(TAG, "SWING is unavailable during HADA Care"); + return; + } + + const bool frost = preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + float temperature = this->target_temperature; + if (frost && previous_preset != preset) { + temperature = FROST_TEMP_C_DEFAULT; + } else if (!frost && previous_preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION) { + temperature = this->normal_target_temperature_; + } + temperature = call.get_target_temperature().value_or(temperature); + if (clamp_target(temperature, frost) != temperature) { + ESP_LOGW(TAG, "Target must be %s", + frost ? LOG_STR_LITERAL("5-13 C for frost protection") : LOG_STR_LITERAL("17-30 C for normal operation")); + return; + } + + this->enter_preset_(previous_preset, preset); + const bool sleep = is_sleep_preset(preset); + if (!sleep && is_sleep_preset(previous_preset)) + this->apply_fan_code_(this->sleep_fan_); + if (call.get_fan_mode().has_value()) { + this->set_fan_mode_(*call.get_fan_mode()); + } else if (call.has_custom_fan_mode()) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } + if (sleep || mode == climate::CLIMATE_MODE_DRY) + this->set_fan_mode_(climate::CLIMATE_FAN_AUTO); + + this->mode = mode; + this->target_temperature = roundf(temperature); + this->apply_toshiba_preset_(preset); + if (preset == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) + this->swing_mode = this->last_swing_mode_ = climate::CLIMATE_SWING_OFF; + if (call.get_swing_mode().has_value()) + this->swing_mode = *call.get_swing_mode(); + + const bool state_command = call.get_mode().has_value() || call.get_target_temperature().has_value() || + call.get_fan_mode().has_value() || call.has_custom_fan_mode() || + call.get_preset().has_value() || call.has_custom_preset(); + if (state_command) { + this->transmit_frame_(preset); + } else if (this->swing_mode != this->last_swing_mode_) { + this->transmit_motion_(TOSHIBA_MOTION_SWING, 1); + } + this->publish_state(); +} + +void ToshibaRas25j2kvsgNdClimate::transmit_state() { this->transmit_frame_(this->toshiba_preset_()); } + +void ToshibaRas25j2kvsgNdClimate::transmit_frame_(ToshibaPreset preset) { + uint8_t message[MAX_FRAME_LENGTH] = {0, 0, 0, 0, TOSHIBA_COMMAND_DEFAULT}; + uint8_t length = 9; + const bool frost = preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + const auto power = this->power_selection_; + message[5] = (static_cast(this->target_temperature) - temperature_code_base(frost)) << 4; + message[6] = this->encode_mode_() | this->fan_code_(); + if (power == ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_75 || + power == ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_50) + message[7] = static_cast(power) << 6; + + if (preset == ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_1) { + message[7] |= FIREPLACE_1; + } else if (preset == ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_2) { + message[7] |= FIREPLACE_2; + } else if (preset == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) { + length = 11; + message[8] = HADA_PREFIX; + message[9] = HADA_SUFFIX; + } else if (is_sleep_preset(preset)) { + length = 12; + message[4] = TOSHIBA_COMMAND_DEFAULT | TOSHIBA_COMMAND_TIMER | TOSHIBA_COMMAND_POWER; + message[8] = + progmem_read_byte( + &SLEEP_HOURS[static_cast(preset) - static_cast(ToshibaPreset::TOSHIBA_PRESET_SLEEP_1H)]) + << 2; + message[9] = SLEEP_TIMER_MARKER; + message[10] = SLEEP_TIMER_FLAGS; + } else if (preset != ToshibaPreset::TOSHIBA_PRESET_NONE || is_silent(power)) { + length = 10; + message[4] = TOSHIBA_COMMAND_DEFAULT | TOSHIBA_COMMAND_POWER; + if (preset == ToshibaPreset::TOSHIBA_PRESET_BOOST) { + message[8] = TOSHIBA_POWER_HIGH; + } else if (preset == ToshibaPreset::TOSHIBA_PRESET_ECO) { + message[8] = TOSHIBA_POWER_ECO; + } else if (frost) { + message[8] = FEATURE_FROST; + } else { + message[8] = power == ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_SILENT_1 ? SILENT_1 : SILENT_2; + } + } + finalize_frame(message, length); + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + const bool swing = this->swing_mode != this->last_swing_mode_; + data->reserve(frame_pulses(length, 1) + (swing ? frame_pulses(MOTION_FRAME_LENGTH, 1) : 0)); + this->encode_(data, message, length, 1); + if (swing) { + this->encode_motion_(data, TOSHIBA_MOTION_SWING, 1); + this->last_swing_mode_ = this->swing_mode; + } + this->perform_(transmit, swing); +} + +void ToshibaRas25j2kvsgNdClimate::transmit_motion_(uint8_t command, uint8_t repeat) { + auto transmit = this->transmitter_->transmit(); + transmit.get_data()->reserve(frame_pulses(MOTION_FRAME_LENGTH, repeat)); + this->encode_motion_(transmit.get_data(), command, repeat); + this->last_swing_mode_ = this->swing_mode; + this->perform_(transmit, command == TOSHIBA_MOTION_SWING); +} + +void ToshibaRas25j2kvsgNdClimate::encode_motion_(remote_base::RemoteTransmitData *data, uint8_t command, + uint8_t repeat) { + uint8_t message[MOTION_FRAME_LENGTH] = {0, 0, 0, 0, SHORT_MOTION_COMMAND, command}; + finalize_frame(message, sizeof(message)); + this->encode_(data, message, sizeof(message), repeat); +} + +void ToshibaRas25j2kvsgNdClimate::perform_(remote_base::RemoteTransmitterBase::TransmitCall &transmit, bool swing) { + uint32_t duration_us = 0; + if (swing) { + for (const int32_t pulse : transmit.get_data()->get_data()) + duration_us += static_cast(std::abs(pulse)); + } + transmit.perform(); + // perform() may wait for an earlier send before queuing this one, or block until this send finishes. + if (swing) + this->swing_ignore_until_ = millis() + (duration_us + 999) / 1000 + REPEAT_MS; +} + +bool ToshibaRas25j2kvsgNdClimate::receive_motion_(uint8_t command) { + if (command != TOSHIBA_MOTION_SWING) + return command == TOSHIBA_MOTION_VANE_STEP; + const uint32_t now = millis(); + // Windows last well under 65 s, so a larger remainder is a deadline that has already passed. + const uint32_t remaining = this->swing_ignore_until_ - now; + if (remaining != 0 && remaining <= UINT16_MAX) + return true; + this->swing_mode = + this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + this->last_swing_mode_ = this->swing_mode; + this->publish_state(); + return true; +} + +bool ToshibaRas25j2kvsgNdClimate::parse_state(const uint8_t *message, uint8_t length, ToshibaPreset &preset, + ToshibaPowerSelection &power) { + const uint8_t mode = message[6] & 0x0F; + const uint8_t feature = message[7] & 0x03; + if ((message[5] & 0x0F) != 0 || (mode > 4 && mode != 7) || (message[6] & 0xF0) > 0xC0 || (message[6] & 0x10) != 0 || + (message[7] & 0x3C) != 0 || (message[7] & 0xC0) == 0xC0 || feature == 1) + return false; + + preset = ToshibaPreset::TOSHIBA_PRESET_NONE; + power = static_cast(message[7] >> 6); + // Only the 9 byte frame carries the Fireplace feature bits + if (length != 9 && feature != 0) + return false; + if (length == 9) { + if (message[4] != TOSHIBA_COMMAND_DEFAULT) + return false; + if (feature == FIREPLACE_1) { + preset = ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_1; + } else if (feature == FIREPLACE_2) { + preset = ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_2; + } + } else if (length == 10 && message[4] == (TOSHIBA_COMMAND_DEFAULT | TOSHIBA_COMMAND_POWER)) { + switch (message[8]) { + case TOSHIBA_POWER_HIGH: + preset = ToshibaPreset::TOSHIBA_PRESET_BOOST; + break; + case TOSHIBA_POWER_ECO: + preset = ToshibaPreset::TOSHIBA_PRESET_ECO; + break; + case FEATURE_FROST: + preset = ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + break; + case SILENT_1: + case SILENT_2: + if (power != ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_100) + return false; + power = message[8] == SILENT_1 ? ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_SILENT_1 + : ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_SILENT_2; + break; + default: + return false; + } + } else if (length == 11 && message[4] == TOSHIBA_COMMAND_DEFAULT && message[8] == HADA_PREFIX && + message[9] == HADA_SUFFIX) { + preset = ToshibaPreset::TOSHIBA_PRESET_HADA_CARE; + } else if (length == 12 && message[4] == (TOSHIBA_COMMAND_DEFAULT | TOSHIBA_COMMAND_TIMER | TOSHIBA_COMMAND_POWER) && + message[9] == SLEEP_TIMER_MARKER && message[10] == SLEEP_TIMER_FLAGS && (message[6] & 0xF0) == 0) { + size_t i = 0; + while (i < std::size(SLEEP_HOURS) && message[8] != progmem_read_byte(&SLEEP_HOURS[i]) << 2) + i++; + if (i == std::size(SLEEP_HOURS)) + return false; + preset = static_cast(static_cast(ToshibaPreset::TOSHIBA_PRESET_SLEEP_1H) + i); + } else { + return false; + } + const bool frost = preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + const float temperature = decode_target(message, frost); + if (clamp_target(temperature, frost) != temperature) + return false; + // Heating presets only exist in HEAT mode. + return !is_heating_preset(preset) || mode == 3; +} + +bool ToshibaRas25j2kvsgNdClimate::on_receive(remote_base::RemoteReceiveData data) { + bool received = false; + do { + uint8_t message[MAX_FRAME_LENGTH]{}; + if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE) || + !this->decode_(&data, message, TOSHIBA_HEADER_LENGTH) || !frame_header_valid(message) || message[2] < 1 || + message[2] > 6 || message[2] == 2) + return received; + const uint8_t length = message[2] + 6; + if (!this->decode_(&data, message + TOSHIBA_HEADER_LENGTH, length - TOSHIBA_HEADER_LENGTH) || + !data.expect_mark(TOSHIBA_BIT_MARK) || !frame_checksum_valid(message, length)) + return received; + // The remote sends every frame twice, in one buffer or two; apply and publish each press once. + uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, length); + for (uint8_t i = 0; i < length; i++) + hash = fnv1a_hash_extend(hash, message[i]); + const uint32_t now = millis(); + if (hash != this->last_frame_hash_ || now - this->last_frame_ms_ >= REPEAT_MS) { + if (!this->receive_frame_(message, length)) + return received; + this->last_frame_hash_ = hash; + this->last_frame_ms_ = now; + } + received = true; + } while (data.expect_space(TOSHIBA_GAP_SPACE) && data.is_valid()); + return received; +} + +bool ToshibaRas25j2kvsgNdClimate::receive_frame_(const uint8_t *message, uint8_t length) { + if (length == MOTION_FRAME_LENGTH) + return message[4] == SHORT_MOTION_COMMAND && this->receive_motion_(message[5]); + ToshibaPreset preset; + ToshibaPowerSelection power; + if (!parse_state(message, length, preset, power)) + return false; + + const auto previous_preset = this->toshiba_preset_(); + this->enter_preset_(previous_preset, preset); + this->decode_mode_(message); + this->apply_fan_code_(message[6] & 0xF0); + // FAN ONLY has no displayed setpoint; its temperature byte is not a new target. + if (this->mode != climate::CLIMATE_MODE_FAN_ONLY) { + this->target_temperature = decode_target(message, preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION); + } else if (previous_preset == ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION) { + this->target_temperature = this->normal_target_temperature_; + } + this->apply_toshiba_preset_(preset); + if (preset == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) + this->swing_mode = this->last_swing_mode_ = climate::CLIMATE_SWING_OFF; + if (this->store_power_selection_(power)) + this->publish_power_selection_(); + this->publish_state(); + return true; +} + +void ToshibaRas25j2kvsgNdClimate::step_vane() { + if (this->toshiba_preset_() == ToshibaPreset::TOSHIBA_PRESET_HADA_CARE) { + ESP_LOGW(TAG, "Vane step is unavailable during HADA Care"); + return; + } + this->transmit_motion_(TOSHIBA_MOTION_VANE_STEP, 0); +} + +void ToshibaRas25j2kvsgNdClimate::set_power_selection(size_t index) { + const auto power = static_cast(index); + const auto preset = this->toshiba_preset_(); + if (!power_preset_supported(power, preset)) + return; + const bool changed = this->store_power_selection_(power); + this->transmit_frame_(preset); + // Published after sending, so a preset change from the select's on_value goes out as its own frame. + if (changed) + this->publish_power_selection_(); +} + +bool ToshibaRas25j2kvsgNdClimate::power_preset_supported(ToshibaPowerSelection power, ToshibaPreset preset) { + if (is_silent(power) && preset != ToshibaPreset::TOSHIBA_PRESET_NONE) { + ESP_LOGW(TAG, "Outdoor Silent with a preset is not supported"); + return false; + } + return true; +} + +bool ToshibaRas25j2kvsgNdClimate::store_power_selection_(ToshibaPowerSelection power) { + if (this->power_selection_ == power) + return false; + this->power_selection_ = power; + this->power_selection_pref_.save(&this->power_selection_); + return true; +} + +void ToshibaRas25j2kvsgNdClimate::publish_power_selection_() { +#ifdef USE_SELECT + if (this->power_selection_select_ != nullptr) + this->power_selection_select_->publish_state(static_cast(this->power_selection_)); +#endif +} + +} // namespace esphome::toshiba + +#endif // USE_TOSHIBA_RAS_25J2KVSG_ND diff --git a/esphome/components/toshiba/ras_25j2kvsg_nd.h b/esphome/components/toshiba/ras_25j2kvsg_nd.h new file mode 100644 index 00000000000..76ece592697 --- /dev/null +++ b/esphome/components/toshiba/ras_25j2kvsg_nd.h @@ -0,0 +1,114 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_TOSHIBA_RAS_25J2KVSG_ND + +#include "esphome/core/preferences.h" +#include "toshiba.h" +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif + +namespace esphome::toshiba { + +static constexpr float TOSHIBA_RAS_25J2KVSG_ND_FROST_TEMP_C_MIN = 5.0f; +static constexpr float TOSHIBA_RAS_25J2KVSG_ND_FROST_TEMP_C_MAX = 13.0f; + +/// RAS-25J2KVSG-ND, driven like its WH-TA15PE remote +class ToshibaRas25j2kvsgNdClimate final : public ToshibaClimate { + public: + static constexpr uint32_t POWER_SELECTION_RESTORE_VERSION = 0x5371E24D; + + ToshibaRas25j2kvsgNdClimate() { this->set_model(MODEL_RAS_25J2KVSG_ND); } + + void setup() override; + void step_vane(); + void set_power_selection(size_t index); +#ifdef USE_SELECT + SUB_SELECT(power_selection) +#endif + + protected: + enum class ToshibaPreset : uint8_t { + TOSHIBA_PRESET_NONE, + TOSHIBA_PRESET_BOOST, + TOSHIBA_PRESET_ECO, + TOSHIBA_PRESET_FIREPLACE_1, + TOSHIBA_PRESET_FIREPLACE_2, + TOSHIBA_PRESET_FROST_PROTECTION, + TOSHIBA_PRESET_HADA_CARE, + TOSHIBA_PRESET_SLEEP_1H, + TOSHIBA_PRESET_SLEEP_3H, + TOSHIBA_PRESET_SLEEP_5H, + TOSHIBA_PRESET_SLEEP_9H, + }; + // In the order of the select's options; 75% and 50% are also their byte 7 power bits. + enum class ToshibaPowerSelection : uint8_t { + TOSHIBA_POWER_SELECTION_100, + TOSHIBA_POWER_SELECTION_75, + TOSHIBA_POWER_SELECTION_50, + TOSHIBA_POWER_SELECTION_SILENT_1, + TOSHIBA_POWER_SELECTION_SILENT_2, + TOSHIBA_POWER_SELECTION_COUNT, + }; + + static bool is_heating_preset(ToshibaPreset preset) { + return preset >= ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_1 && + preset <= ToshibaPreset::TOSHIBA_PRESET_FROST_PROTECTION; + } + static bool is_sleep_preset(ToshibaPreset preset) { return preset >= ToshibaPreset::TOSHIBA_PRESET_SLEEP_1H; } + static bool is_silent(ToshibaPowerSelection power) { + return power >= ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_SILENT_1; + } + static ToshibaPreset custom_preset_at(size_t index) { + return static_cast(index + static_cast(ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_1)); + } + static size_t custom_preset_index(ToshibaPreset preset) { + return static_cast(preset) - static_cast(ToshibaPreset::TOSHIBA_PRESET_FIREPLACE_1); + } + static ToshibaPreset preset_from_climate(climate::ClimatePreset preset); + static ToshibaPreset preset_from_custom(const char *custom_preset); + static bool power_preset_supported(ToshibaPowerSelection power, ToshibaPreset preset); + // Validates a 9 to 12 byte state frame and reads its preset and power selection. + static bool parse_state(const uint8_t *message, uint8_t length, ToshibaPreset &preset, ToshibaPowerSelection &power); + + void control(const climate::ClimateCall &call) override; + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + // control() enforces the separate normal range. + float temperature_min() override { return TOSHIBA_RAS_25J2KVSG_ND_FROST_TEMP_C_MIN; } + void transmit_current_temperature() override {} + void sanitize_restore_state(climate::ClimateDeviceRestoreState &restore) override; + + void transmit_frame_(ToshibaPreset preset); + void transmit_motion_(uint8_t command, uint8_t repeat); + void encode_motion_(remote_base::RemoteTransmitData *data, uint8_t command, uint8_t repeat); + void perform_(remote_base::RemoteTransmitterBase::TransmitCall &transmit, bool swing); + bool receive_frame_(const uint8_t *message, uint8_t length); + bool receive_motion_(uint8_t command); + ToshibaPreset toshiba_preset_() const; + void apply_toshiba_preset_(ToshibaPreset preset); + // Saves what leaving the preset restores; call before the target and fan change. + void enter_preset_(ToshibaPreset previous, ToshibaPreset preset); + uint8_t fan_code_() const; + void apply_fan_code_(uint8_t code); + // Returns true when the selection changed. + bool store_power_selection_(ToshibaPowerSelection power); + void publish_power_selection_(); + + ESPPreferenceObject power_selection_pref_; + // millis() until which a received SWING is an echo of our own send + uint32_t swing_ignore_until_{0}; + // FNV-1a hash and millis() of the last applied frame, to skip its repeated copy + uint32_t last_frame_hash_{0}; + uint32_t last_frame_ms_{0}; + // The target to return to when frost protection ends + uint8_t normal_target_temperature_{24}; + ToshibaPowerSelection power_selection_{ToshibaPowerSelection::TOSHIBA_POWER_SELECTION_100}; + uint8_t sleep_fan_{0}; +}; + +} // namespace esphome::toshiba + +#endif // USE_TOSHIBA_RAS_25J2KVSG_ND diff --git a/esphome/components/toshiba/select.py b/esphome/components/toshiba/select.py new file mode 100644 index 00000000000..bec0e9c5821 --- /dev/null +++ b/esphome/components/toshiba/select.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .climate import ToshibaRas25j2kvsgNdClimate + +DEPENDENCIES = ["climate.toshiba"] + +CONF_TOSHIBA_ID = "toshiba_id" +CONF_POWER_SELECTION = "power_selection" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TOSHIBA_ID): cv.use_id(ToshibaRas25j2kvsgNdClimate), + cv.Required(CONF_POWER_SELECTION): select.select_schema(select.ApplySelect), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await cg.get_variable(config[CONF_TOSHIBA_ID]) + selects = select.sub_selects(config, parent=var) + await selects( + CONF_POWER_SELECTION, + var.set_power_selection_select, + options=["100%", "75%", "50%", "Silent 1", "Silent 2"], + apply="set_power_selection(index)", + ) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 2cf9419ef8e..516a4300d75 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -12,19 +12,11 @@ struct RacPt1411hwruFanSpeed { ESPHOME_LOG_TAG(TAG, "toshiba.climate"); // Timings for IR bits/data -const uint16_t TOSHIBA_HEADER_MARK = 4380; -const uint16_t TOSHIBA_HEADER_SPACE = 4370; -const uint16_t TOSHIBA_GAP_SPACE = 5480; const uint16_t TOSHIBA_PACKET_SPACE = 10500; -const uint16_t TOSHIBA_BIT_MARK = 540; const uint16_t TOSHIBA_ZERO_SPACE = 540; const uint16_t TOSHIBA_ONE_SPACE = 1620; const uint16_t TOSHIBA_CARRIER_FREQUENCY = 38000; -const uint8_t TOSHIBA_HEADER_LENGTH = 4; // Generic Toshiba commands/flags -const uint8_t TOSHIBA_COMMAND_DEFAULT = 0x01; -const uint8_t TOSHIBA_COMMAND_TIMER = 0x02; -const uint8_t TOSHIBA_COMMAND_POWER = 0x08; const uint8_t TOSHIBA_COMMAND_MOTION = 0x02; const uint8_t TOSHIBA_MODE_AUTO = 0x00; @@ -34,20 +26,6 @@ const uint8_t TOSHIBA_MODE_HEAT = 0x03; const uint8_t TOSHIBA_MODE_FAN_ONLY = 0x04; const uint8_t TOSHIBA_MODE_OFF = 0x07; -const uint8_t TOSHIBA_FAN_SPEED_AUTO = 0x00; -const uint8_t TOSHIBA_FAN_SPEED_QUIET = 0x20; -const uint8_t TOSHIBA_FAN_SPEED_1 = 0x40; -const uint8_t TOSHIBA_FAN_SPEED_2 = 0x60; -const uint8_t TOSHIBA_FAN_SPEED_3 = 0x80; -const uint8_t TOSHIBA_FAN_SPEED_4 = 0xa0; -const uint8_t TOSHIBA_FAN_SPEED_5 = 0xc0; - -const uint8_t TOSHIBA_POWER_HIGH = 0x01; -const uint8_t TOSHIBA_POWER_ECO = 0x03; - -const uint8_t TOSHIBA_MOTION_SWING = 0x04; -const uint8_t TOSHIBA_MOTION_FIX = 0x00; - // RAC-PT1411HWRU temperature code flag bits const uint8_t RAC_PT1411HWRU_FLAG_FAH = 0x01; const uint8_t RAC_PT1411HWRU_FLAG_FRAC = 0x20; @@ -422,10 +400,13 @@ static bool is_valid_ras_2819t_command(uint64_t rc_code_1, uint64_t rc_code_2 = } void ToshibaClimate::setup() { + this->minimum_temperature_ = this->temperature_min(); + this->maximum_temperature_ = this->temperature_max_(); + this->swing_modes_ = this->toshiba_swing_modes_(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { this->current_temperature = state; - this->transmit_rac_pt1411hwru_temp_(); + this->transmit_current_temperature(); // current temperature changed, publish state this->publish_state(); }); @@ -436,6 +417,11 @@ void ToshibaClimate::setup() { // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { + this->sanitize_restore_state(*restore); + // apply() publishes synchronously, so on_state automations need the swing cache ready. apply() skips an + // unsupported swing mode, leaving OFF, which also stands for "not yet commanded" on models without a stop. + if (this->swing_modes_.count(restore->swing_mode)) + this->last_swing_mode_ = restore->swing_mode; restore->apply(this); } else { // restore from defaults @@ -444,22 +430,8 @@ void ToshibaClimate::setup() { this->target_temperature = roundf(clamp(this->current_temperature, this->minimum_temperature_, this->maximum_temperature_)); this->fan_mode = climate::CLIMATE_FAN_AUTO; - this->swing_mode = climate::CLIMATE_SWING_OFF; + this->swing_mode = this->last_swing_mode_ = climate::CLIMATE_SWING_OFF; } - // Set supported modes & temperatures based on model - this->minimum_temperature_ = this->temperature_min_(); - this->maximum_temperature_ = this->temperature_max_(); - this->swing_modes_ = this->toshiba_swing_modes_(); - - // Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet - // commanded" on models that cannot switch the swing off, such as Seiya. - if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { - this->swing_mode = climate::CLIMATE_SWING_OFF; - } - - // A restored swing mode is already in effect, so it must not go out as a fresh swing command - this->last_swing_mode_ = this->swing_mode; - // Ensure mode is valid - ESPHome should only use standard climate modes if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && @@ -469,7 +441,7 @@ void ToshibaClimate::setup() { } // Ensure fan mode is valid - if (!this->fan_mode.has_value()) { + if (!this->fan_mode.has_value() && !this->has_custom_fan_mode()) { ESP_LOGW(TAG, "Fan mode not set during setup, defaulting to AUTO"); this->fan_mode = climate::CLIMATE_FAN_AUTO; } @@ -505,16 +477,6 @@ void ToshibaClimate::transmit_generic_() { uint8_t message[16] = {0}; const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9; - // Header - message[0] = 0xf2; - message[1] = 0x0d; - - // Message length - message[2] = message_length - 6; - - // First checksum - message[3] = message[0] ^ message[1] ^ message[2]; - // Command message[4] = TOSHIBA_COMMAND_DEFAULT; @@ -543,8 +505,7 @@ void ToshibaClimate::transmit_generic_() { } } - // The last byte is the xor of all bytes from [4] - message[message_length - 1] = xor_bytes(message + 4, message_length - 5); + finalize_frame(message, message_length); // Transmit auto transmit = this->transmitter_->transmit(); @@ -555,58 +516,38 @@ void ToshibaClimate::transmit_generic_() { transmit.perform(); } -uint8_t ToshibaClimate::encode_mode_fan_() const { - uint8_t mode; +uint8_t ToshibaClimate::encode_mode_() const { switch (this->mode) { case climate::CLIMATE_MODE_OFF: - mode = TOSHIBA_MODE_OFF; - break; - + return TOSHIBA_MODE_OFF; case climate::CLIMATE_MODE_HEAT: - mode = TOSHIBA_MODE_HEAT; - break; - + return TOSHIBA_MODE_HEAT; case climate::CLIMATE_MODE_COOL: - mode = TOSHIBA_MODE_COOL; - break; - + return TOSHIBA_MODE_COOL; case climate::CLIMATE_MODE_DRY: - mode = TOSHIBA_MODE_DRY; - break; - + return TOSHIBA_MODE_DRY; case climate::CLIMATE_MODE_FAN_ONLY: - mode = TOSHIBA_MODE_FAN_ONLY; - break; - + return TOSHIBA_MODE_FAN_ONLY; case climate::CLIMATE_MODE_HEAT_COOL: default: - mode = TOSHIBA_MODE_AUTO; + return TOSHIBA_MODE_AUTO; } +} - uint8_t fan; +uint8_t ToshibaClimate::encode_fan_() const { switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_QUIET: - fan = TOSHIBA_FAN_SPEED_QUIET; - break; - + return TOSHIBA_FAN_SPEED_QUIET; case climate::CLIMATE_FAN_LOW: - fan = TOSHIBA_FAN_SPEED_1; - break; - + return TOSHIBA_FAN_SPEED_1; case climate::CLIMATE_FAN_MEDIUM: - fan = TOSHIBA_FAN_SPEED_3; - break; - + return TOSHIBA_FAN_SPEED_3; case climate::CLIMATE_FAN_HIGH: - fan = TOSHIBA_FAN_SPEED_5; - break; - + return TOSHIBA_FAN_SPEED_5; case climate::CLIMATE_FAN_AUTO: default: - fan = TOSHIBA_FAN_SPEED_AUTO; - break; + return TOSHIBA_FAN_SPEED_AUTO; } - return fan | mode; } uint8_t ToshibaClimate::seiya_swing_code_() const { @@ -1182,8 +1123,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { if (this->is_valid_rac_pt1411hwru_header_(message)) { // We already received four bytes message_length = RAC_PT1411HWRU_MESSAGE_LENGTH - 4; - } else if ((message[0] ^ message[1] ^ message[2]) != message[3]) { - // Return false if first checksum was not valid + } else if (!frame_header_valid(message)) { return false; } else { // First checksum was valid so continue receiving the remaining bits @@ -1336,9 +1276,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { // Add back the length of the header (we pruned it above) message_length += TOSHIBA_HEADER_LENGTH; // Validate the second checksum before trusting any more of the message - const uint8_t checksum = xor_bytes(message + TOSHIBA_HEADER_LENGTH, message_length - 1 - TOSHIBA_HEADER_LENGTH); - // Did our computed checksum and the provided checksum match? - if (checksum != message[message_length - 1]) { + if (!frame_checksum_valid(message, message_length)) { return false; } // Check if this is a short swing/fix message @@ -1358,6 +1296,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return true; } +void ToshibaClimate::finalize_frame(uint8_t *message, uint8_t length) { + message[0] = TOSHIBA_HEADER_BYTE_0; + message[1] = TOSHIBA_HEADER_BYTE_1; + message[2] = length - 6; + message[3] = ~message[2]; + message[length - 1] = xor_bytes(message + 4, length - 5); +} + void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes, const uint8_t repeat) { const ToshibaTimings timings = timings_for(this->model_); @@ -1400,6 +1346,12 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess } void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { + this->decode_mode_(message); + this->fan_mode = decode_fan(message[6] & 0xF0); + this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; +} + +void ToshibaClimate::decode_mode_(const uint8_t *message) { switch (message[6] & 0x0F) { case TOSHIBA_MODE_OFF: this->mode = climate::CLIMATE_MODE_OFF; @@ -1425,34 +1377,25 @@ void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { default: this->mode = climate::CLIMATE_MODE_HEAT_COOL; } +} +climate::ClimateFanMode ToshibaClimate::decode_fan(uint8_t code) { // Speeds 2 and 4 are not exposed; report the nearest mode - switch (message[6] & 0xF0) { + switch (code) { case TOSHIBA_FAN_SPEED_QUIET: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - + return climate::CLIMATE_FAN_QUIET; case TOSHIBA_FAN_SPEED_1: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - + return climate::CLIMATE_FAN_LOW; case TOSHIBA_FAN_SPEED_2: case TOSHIBA_FAN_SPEED_3: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - + return climate::CLIMATE_FAN_MEDIUM; case TOSHIBA_FAN_SPEED_4: case TOSHIBA_FAN_SPEED_5: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - + return climate::CLIMATE_FAN_HIGH; case TOSHIBA_FAN_SPEED_AUTO: default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; + return climate::CLIMATE_FAN_AUTO; } - - this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; } void ToshibaClimate::seiya_decode_swing_(uint8_t code) { diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 799f690f54c..518b247f352 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -1,10 +1,35 @@ #pragma once +#include +#include + #include "esphome/components/climate_ir/climate_ir.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" namespace esphome::toshiba { +static constexpr uint16_t TOSHIBA_HEADER_MARK = 4380; +static constexpr uint16_t TOSHIBA_HEADER_SPACE = 4370; +static constexpr uint16_t TOSHIBA_GAP_SPACE = 5480; +static constexpr uint16_t TOSHIBA_BIT_MARK = 540; +static constexpr uint8_t TOSHIBA_HEADER_LENGTH = 4; +static constexpr uint8_t TOSHIBA_HEADER_BYTE_0 = 0xF2; +static constexpr uint8_t TOSHIBA_HEADER_BYTE_1 = 0x0D; +static constexpr uint8_t TOSHIBA_COMMAND_DEFAULT = 0x01; +static constexpr uint8_t TOSHIBA_COMMAND_TIMER = 0x02; +static constexpr uint8_t TOSHIBA_COMMAND_POWER = 0x08; +static constexpr uint8_t TOSHIBA_FAN_SPEED_AUTO = 0x00; +static constexpr uint8_t TOSHIBA_FAN_SPEED_QUIET = 0x20; +static constexpr uint8_t TOSHIBA_FAN_SPEED_1 = 0x40; +static constexpr uint8_t TOSHIBA_FAN_SPEED_2 = 0x60; +static constexpr uint8_t TOSHIBA_FAN_SPEED_3 = 0x80; +static constexpr uint8_t TOSHIBA_FAN_SPEED_4 = 0xA0; +static constexpr uint8_t TOSHIBA_FAN_SPEED_5 = 0xC0; +static constexpr uint8_t TOSHIBA_POWER_HIGH = 0x01; +static constexpr uint8_t TOSHIBA_POWER_ECO = 0x03; +static constexpr uint8_t TOSHIBA_MOTION_SWING = 0x04; +static constexpr uint8_t TOSHIBA_MOTION_VANE_STEP = 0x00; + // Simple enum to represent models. enum Model { MODEL_GENERIC = 0, // Temperature range is from 17 to 30 @@ -13,6 +38,7 @@ enum Model { MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte MODEL_RAS_B10N3KV2 = 5, // RAS-B13 remote family, generic frame with IRremoteESP8266's TOSHIBA_AC timings + MODEL_RAS_25J2KVSG_ND = 6, // WH-TA15PE remote }; // Supported temperature ranges @@ -25,7 +51,7 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; -class ToshibaClimate final : public climate_ir::ClimateIR { +class ToshibaClimate : public climate_ir::ClimateIR { public: ToshibaClimate() : climate_ir::ClimateIR(TOSHIBA_GENERIC_TEMP_C_MIN, TOSHIBA_GENERIC_TEMP_C_MAX, 1.0f, true, true, @@ -51,20 +77,46 @@ class ToshibaClimate final : public climate_ir::ClimateIR { bool compare_rac_pt1411hwru_packets_(const uint8_t *message1, const uint8_t *message2); bool on_receive(remote_base::RemoteReceiveData data) override; - private: - // Last swing mode sent or received, for models whose swing is a one-shot command - climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; - climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; - optional last_fan_mode_{}; - float last_target_temperature_{24.0f}; - - float temperature_min_() { + // Hooks for model subclasses + virtual float temperature_min() { if (this->model_ == MODEL_RAC_PT1411HWRU_C || this->model_ == MODEL_RAC_PT1411HWRU_F) return TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; if (this->model_ == MODEL_RAS_2819T) return TOSHIBA_RAS_2819T_TEMP_C_MIN; return TOSHIBA_GENERIC_TEMP_C_MIN; // Default to GENERIC for unknown models } + // Called from the sensor callback after current_temperature is updated + virtual void transmit_current_temperature() { this->transmit_rac_pt1411hwru_temp_(); } + // Sanitizes the saved state before swing and apply() use it + virtual void sanitize_restore_state(climate::ClimateDeviceRestoreState &restore) {} + + void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); + bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); + // Writes the F2 0D header, the length byte and its inverse, and the trailing XOR of an F2 0D frame. + static void finalize_frame(uint8_t *message, uint8_t length); + static bool frame_header_valid(const uint8_t *message) { + return message[0] == TOSHIBA_HEADER_BYTE_0 && message[1] == TOSHIBA_HEADER_BYTE_1 && + (message[2] ^ message[3]) == 0xFF; + } + static bool frame_checksum_valid(const uint8_t *message, uint8_t length) { + return xor_bytes(message + 4, length - 5) == message[length - 1]; + } + + // Shared by every model that uses the F2 0D frame + uint8_t encode_mode_() const; + uint8_t encode_fan_() const; + void decode_mode_(const uint8_t *message); + static climate::ClimateFanMode decode_fan(uint8_t code); + + // Last swing mode sent or received, for models whose swing is a one-shot command + climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; + Model model_; + + private: + climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; + optional last_fan_mode_{}; + float last_target_temperature_{24.0f}; + float temperature_max_() { if (this->model_ == MODEL_RAC_PT1411HWRU_C || this->model_ == MODEL_RAC_PT1411HWRU_F) return TOSHIBA_RAC_PT1411HWRU_TEMP_C_MAX; @@ -82,16 +134,10 @@ class ToshibaClimate final : public climate_ir::ClimateIR { } return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } - void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); - bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); - - // Shared by every model that uses the F2 0D frame - uint8_t encode_mode_fan_() const; + uint8_t encode_mode_fan_() const { return this->encode_fan_() | this->encode_mode_(); } void decode_mode_fan_temperature_(const uint8_t *message); uint8_t seiya_swing_code_() const; void seiya_decode_swing_(uint8_t code); - - Model model_; }; } // namespace esphome::toshiba diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f80ef76ed6e..4972d28139a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -314,6 +314,7 @@ #define USE_TMP102_CONFIGURE #define USE_TMP102_NUMBER #define USE_TMP102_TEXT_SENSOR +#define USE_TOSHIBA_RAS_25J2KVSG_ND #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/toshiba/__init__.py b/tests/component_tests/toshiba/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/toshiba/config/ras_25j2kvsg_nd.yaml b/tests/component_tests/toshiba/config/ras_25j2kvsg_nd.yaml new file mode 100644 index 00000000000..87fd8622bfb --- /dev/null +++ b/tests/component_tests/toshiba/config/ras_25j2kvsg_nd.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: toshiba-test + on_boot: + then: + - toshiba.step_vane: ras + +esp32: + board: esp32dev + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + non_blocking: true + +climate: + - platform: toshiba + id: ras + name: RAS + model: RAS-25J2KVSG-ND + - platform: toshiba + id: generic + name: Generic + +select: + - platform: toshiba + toshiba_id: ras + power_selection: + name: Power selection diff --git a/tests/component_tests/toshiba/config/select_on_generic.yaml b/tests/component_tests/toshiba/config/select_on_generic.yaml new file mode 100644 index 00000000000..8acba1e7704 --- /dev/null +++ b/tests/component_tests/toshiba/config/select_on_generic.yaml @@ -0,0 +1,26 @@ +--- +esphome: + name: toshiba-test + +esp32: + board: esp32dev + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + non_blocking: true + +climate: + - platform: toshiba + id: ras + name: RAS + model: RAS-25J2KVSG-ND + - platform: toshiba + id: generic + name: Generic + +select: + - platform: toshiba + toshiba_id: generic + power_selection: + name: Power selection diff --git a/tests/component_tests/toshiba/config/step_vane_on_generic.yaml b/tests/component_tests/toshiba/config/step_vane_on_generic.yaml new file mode 100644 index 00000000000..ecf7a7182ab --- /dev/null +++ b/tests/component_tests/toshiba/config/step_vane_on_generic.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: toshiba-test + on_boot: + then: + - toshiba.step_vane: generic + +esp32: + board: esp32dev + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + non_blocking: true + +climate: + - platform: toshiba + id: ras + name: RAS + model: RAS-25J2KVSG-ND + - platform: toshiba + id: generic + name: Generic + +select: + - platform: toshiba + toshiba_id: ras + power_selection: + name: Power selection diff --git a/tests/component_tests/toshiba/test_toshiba_config.py b/tests/component_tests/toshiba/test_toshiba_config.py new file mode 100644 index 00000000000..63ca18b9791 --- /dev/null +++ b/tests/component_tests/toshiba/test_toshiba_config.py @@ -0,0 +1,59 @@ +"""Tests for Toshiba model selection and the RAS-25J2KVSG-ND class binding.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.toshiba import climate +from esphome.config import read_config +from esphome.const import CONF_ID, CONF_MODEL, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("model", list(climate.MODELS)) +def test_model_schema(model: str, set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = climate.CONFIG_SCHEMA({"name": "Toshiba", CONF_MODEL: model.lower()}) + assert config[CONF_MODEL] == model + expected = ( + climate.ToshibaRas25j2kvsgNdClimate + if model == climate.MODEL_RAS_25J2KVSG_ND + else climate.ToshibaClimate + ) + assert config[CONF_ID].type is expected + + +def test_default_model_is_unchanged(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = climate.CONFIG_SCHEMA({"name": "Toshiba"}) + assert config[CONF_MODEL] == "GENERIC" + assert config[CONF_ID].type is climate.ToshibaClimate + + +def test_ras_25j2kvsg_nd_uses_its_class( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ras_25j2kvsg_nd.yaml")) + assert "new(ras) toshiba::ToshibaRas25j2kvsgNdClimate();" in main_cpp + assert "new(generic) toshiba::ToshibaClimate();" in main_cpp + assert "generic->set_model(toshiba::MODEL_GENERIC);" in main_cpp + assert "ras->set_model(" not in main_cpp + assert "USE_TOSHIBA_RAS_25J2KVSG_ND" in {d.name for d in CORE.defines} + + +@pytest.mark.parametrize( + "file_name", ["select_on_generic.yaml", "step_vane_on_generic.yaml"] +) +def test_ras_25j2kvsg_nd_features_reject_other_models( + file_name: str, + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path(file_name) + assert read_config({}) is None + assert "ID 'generic' of type toshiba::ToshibaClimate doesn't inherit from" in ( + capsys.readouterr().out + ) diff --git a/tests/components/toshiba/__init__.py b/tests/components/toshiba/__init__.py index 8a083978ece..b487b591839 100644 --- a/tests/components/toshiba/__init__.py +++ b/tests/components/toshiba/__init__.py @@ -1,4 +1,6 @@ import esphome.codegen as cg +from esphome.core import CORE +from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride @@ -6,10 +8,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The harness walks dependencies from the component manifest, not from the # climate platform, so declare the platform's auto-load here. Otherwise the # transmitter's code generation runs and the host build cannot link it. - manifest.auto_load = ["climate_ir", "climate"] + manifest.auto_load = ["climate_ir", "climate", "select"] - async def to_code_testing(config): + async def to_code_testing(config: ConfigType) -> None: # on_receive decodes the generic frame through the shared protocol class cg.add_define("USE_REMOTE_PROTOCOL_TOSHIBA_AC") + cg.add_define("USE_TOSHIBA_RAS_25J2KVSG_ND") + CORE.platform_counts["select"] += 1 manifest.to_code = to_code_testing diff --git a/tests/components/toshiba/common_ras_25j2kvsg_nd.yaml b/tests/components/toshiba/common_ras_25j2kvsg_nd.yaml new file mode 100644 index 00000000000..57548847913 --- /dev/null +++ b/tests/components/toshiba/common_ras_25j2kvsg_nd.yaml @@ -0,0 +1,50 @@ +climate: + - platform: toshiba + id: toshiba_ras_25j2kvsg_nd + name: RAS-25J2KVSG-ND Climate + model: RAS-25J2KVSG-ND + transmitter_id: xmitr + receiver_id: rcvr + - platform: toshiba + name: Generic Climate alongside RAS-25J2KVSG-ND + model: GENERIC + transmitter_id: xmitr + +select: + - platform: toshiba + toshiba_id: toshiba_ras_25j2kvsg_nd + power_selection: + name: Toshiba Power Selection + id: toshiba_power_selection + +esphome: + on_boot: + then: + - climate.control: + id: toshiba_ras_25j2kvsg_nd + mode: HEAT + target_temperature: 24 + custom_fan_mode: Low+ + preset: BOOST + - climate.control: + id: toshiba_ras_25j2kvsg_nd + custom_fan_mode: Medium+ + custom_preset: Fireplace 1 + - climate.control: + id: toshiba_ras_25j2kvsg_nd + custom_preset: Frost protection + target_temperature: 5 + - climate.control: + id: toshiba_ras_25j2kvsg_nd + custom_preset: HADA Care + target_temperature: 24 + - climate.control: + id: toshiba_ras_25j2kvsg_nd + custom_preset: Sleep 1h + - climate.control: + id: toshiba_ras_25j2kvsg_nd + preset: NONE + - select.set: + id: toshiba_power_selection + option: Silent 1 + - toshiba.step_vane: toshiba_ras_25j2kvsg_nd diff --git a/tests/components/toshiba/test-ras25j2kvsgnd.esp32-idf.yaml b/tests/components/toshiba/test-ras25j2kvsgnd.esp32-idf.yaml new file mode 100644 index 00000000000..0e5fbd6dd1a --- /dev/null +++ b/tests/components/toshiba/test-ras25j2kvsgnd.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + toshiba: !include common_ras_25j2kvsg_nd.yaml diff --git a/tests/components/toshiba/test-ras25j2kvsgnd.esp8266-ard.yaml b/tests/components/toshiba/test-ras25j2kvsgnd.esp8266-ard.yaml new file mode 100644 index 00000000000..fd610741a8b --- /dev/null +++ b/tests/components/toshiba/test-ras25j2kvsgnd.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + toshiba: !include common_ras_25j2kvsg_nd.yaml diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 3c0ce6cd6ce..5425195f830 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -1,11 +1,19 @@ #include +#include #include #include +#include #include #include #include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/toshiba/ras_25j2kvsg_nd.h" #include "esphome/components/toshiba/toshiba.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" namespace esphome::toshiba::testing { @@ -23,9 +31,9 @@ static constexpr Timings GENERIC{4380, 4370, 540, 540, 1620, 5480, true}; static constexpr Timings SEIYA{4630, 4450, 625, 490, 1570, 5830, false}; /// The waveform the component must produce for a frame: two copies, MSB first -static remote_base::RawTimings waveform(const std::vector &bytes, const Timings &t) { +static remote_base::RawTimings waveform(const std::vector &bytes, const Timings &t, int copies = 2) { remote_base::RawTimings raw; - for (int copy = 0; copy < 2; copy++) { + for (int copy = 0; copy < copies; copy++) { raw.push_back(t.header_mark); raw.push_back(-static_cast(t.header_space)); for (uint8_t byte : bytes) { @@ -35,7 +43,7 @@ static remote_base::RawTimings waveform(const std::vector &bytes, const } } raw.push_back(t.bit_mark); - if (copy == 0 || t.gap_after_last) { + if (copy + 1 < copies || t.gap_after_last) { raw.push_back(-static_cast(t.gap_space)); } } @@ -46,25 +54,36 @@ class CapturingTransmitter : public remote_base::RemoteTransmitterBase { public: CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} std::vector frames; + uint32_t send_delay_ms{0}; protected: void send_internal(uint32_t send_times, uint32_t send_wait) override { + if (this->send_delay_ms != 0) + delay(this->send_delay_ms); this->frames.push_back(this->temp_.get_data()); } }; -struct Fixture { - CapturingTransmitter transmitter; - ToshibaClimate climate; +class PowerSelection : public select::Select { + public: + PowerSelection() { this->traits.set_options({"100%", "75%", "50%", "Silent 1", "Silent 2"}); } - explicit Fixture(Model model) { - this->climate.set_model(model); + protected: + void control(size_t) override {} +}; + +template struct BasicFixture { + CapturingTransmitter transmitter; + C climate; + + explicit BasicFixture(bool setup = true) { this->climate.set_transmitter(&this->transmitter); - this->climate.setup(); + if (setup) + this->climate.setup(); } void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, - optional swing = {}) { + optional swing = {}, const char *preset = nullptr) { auto call = this->climate.make_call(); call.set_mode(mode); call.set_target_temperature(temperature); @@ -72,12 +91,39 @@ struct Fixture { if (swing.has_value()) { call.set_swing_mode(*swing); } + if (preset != nullptr) + call.set_preset(preset); call.perform(); } + void preset(const char *preset) { this->climate.make_call().set_preset(preset).perform(); } + void fan(const char *fan) { this->climate.make_call().set_fan_mode(fan).perform(); } + void target(float temperature) { this->climate.make_call().set_target_temperature(temperature).perform(); } + void mode(climate::ClimateMode mode) { this->climate.make_call().set_mode(mode).perform(); } + void swing(climate::ClimateSwingMode swing) { this->climate.make_call().set_swing_mode(swing).perform(); } + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } }; +struct Fixture : BasicFixture { + explicit Fixture(Model model, bool setup = true) : BasicFixture(false) { + this->climate.set_model(model); + if (setup) + this->climate.setup(); + } +}; + +using Ras = ToshibaRas25j2kvsgNdClimate; + +struct RasFixture : BasicFixture { + using BasicFixture::BasicFixture; + // Configures the power selection select before setup(), like the generated code. + explicit RasFixture(PowerSelection &power) : BasicFixture(false) { + this->climate.set_power_selection_select(&power); + this->climate.setup(); + } +}; + /// Decodes the first copy of a frame back into bytes so a mismatch is readable static std::string frame_bytes(const remote_base::RawTimings &raw, size_t nbytes) { std::string out; @@ -101,6 +147,28 @@ static void expect_frame(const remote_base::RawTimings &actual, const remote_bas << frame_bytes(expected, nbytes); } +static bool receive_raw(ToshibaClimate &climate, const remote_base::RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(climate).on_receive(data); +} + +static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { + return receive_raw(climate, waveform(bytes, t)); +} + +/// Runs setup() on an unstarted fixture, checks every publication and that nothing was sent +template static void expect_restore(R &restored, F check) { + size_t publications = 0; + restored.climate.add_on_state_callback([&](climate::Climate &state) { + publications++; + check(state); + }); + restored.climate.setup(); + EXPECT_GT(publications, 0u); + EXPECT_TRUE(restored.transmitter.frames.empty()); +} + TEST(ToshibaSeiya, EncodesCapturedFrames) { Fixture f(MODEL_SEIYA); @@ -162,8 +230,7 @@ TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; Fixture rx(model); - remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); - ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + ASSERT_TRUE(receive_raw(rx.climate, main_frame)); EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); } @@ -178,20 +245,12 @@ TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); Fixture rx(MODEL_RAS_2819T); - remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); - ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + ASSERT_TRUE(receive_raw(rx.climate, tx.last_frame())); EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); } } -static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { - const remote_base::RawTimings raw = waveform(bytes, t); - remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); - // The receiver calls through the listener interface, where on_receive is public - return static_cast(climate).on_receive(data); -} - TEST(ToshibaSeiya, DecodesStateAndKeepsSwingOnNeutralByte) { Fixture f(MODEL_SEIYA); f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); @@ -217,4 +276,1173 @@ TEST(ToshibaSeiya, RejectsBadLengthByte) { EXPECT_FALSE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x50, 0x02}, SEIYA)); } +struct CapturedState { + std::vector bytes; + float temperature; + climate::ClimateMode mode; + const char *fan; + const char *preset; + size_t power{0}; +}; + +// WH-TA15PE handheld captures, 2026-10-21. Expected bytes are independent of the encoder. +static const CapturedState CAPTURED_STATES[] = { + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x43, 0x00, 0x32}, 24, climate::CLIMATE_MODE_HEAT, "LOW", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x00, 0x12}, 24, climate::CLIMATE_MODE_HEAT, "Low+", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x83, 0x00, 0xF2}, 24, climate::CLIMATE_MODE_HEAT, "MEDIUM", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0xA3, 0x00, 0xD2}, 24, climate::CLIMATE_MODE_HEAT, "Medium+", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0xC3, 0x00, 0xB2}, 24, climate::CLIMATE_MODE_HEAT, "HIGH", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x23, 0x00, 0x52}, 24, climate::CLIMATE_MODE_HEAT, "QUIET", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x03, 0x00, 0x72}, 24, climate::CLIMATE_MODE_HEAT, "AUTO", "NONE"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x03, 0x00, 0x01, 0x7B}, 24, climate::CLIMATE_MODE_HEAT, "AUTO", "BOOST"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x60, 0x03, 0x00, 0x01, 0x6B}, 23, climate::CLIMATE_MODE_HEAT, "AUTO", "BOOST"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x00, 0x03, 0x19}, 24, climate::CLIMATE_MODE_HEAT, "Low+", "ECO"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x43, 0x02, 0x20}, 23, climate::CLIMATE_MODE_HEAT, "LOW", "Fireplace 1"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x03, 0x11}, 24, climate::CLIMATE_MODE_HEAT, "Low+", "Fireplace 2"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x00, 0x04, 0x1E}, + 8, + climate::CLIMATE_MODE_HEAT, + "Low+", + "Frost protection"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x40, 0x63, 0x00, 0x04, 0x2E}, + 5, + climate::CLIMATE_MODE_HEAT, + "Low+", + "Frost protection"}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0xC0, 0x63, 0x00, 0x04, 0xAE}, + 13, + climate::CLIMATE_MODE_HEAT, + "Low+", + "Frost protection"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x40, 0x52}, 24, climate::CLIMATE_MODE_HEAT, "Low+", "NONE", 1}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x63, 0x80, 0x82}, 23, climate::CLIMATE_MODE_HEAT, "Low+", "NONE", 2}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x00, 0x02, 0x18}, 24, climate::CLIMATE_MODE_HEAT, "Low+", "NONE", 3}, + {{0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x60, 0x63, 0x00, 0x0A, 0x00}, 23, climate::CLIMATE_MODE_HEAT, "Low+", "NONE", 4}, + {{0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x63, 0x00, 0x21, 0x17, 0x24}, + 24, + climate::CLIMATE_MODE_HEAT, + "Low+", + "HADA Care"}, + {{0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x63, 0x00, 0x21, 0x17, 0x34}, + 23, + climate::CLIMATE_MODE_HEAT, + "Low+", + "HADA Care"}, + {{0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x70, 0x03, 0x00, 0x04, 0x7F, 0x03, 0x00}, + 24, + climate::CLIMATE_MODE_HEAT, + "AUTO", + "Sleep 1h"}, + {{0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x70, 0x03, 0x00, 0x0C, 0x7F, 0x03, 0x08}, + 24, + climate::CLIMATE_MODE_HEAT, + "AUTO", + "Sleep 3h"}, + {{0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x70, 0x03, 0x00, 0x14, 0x7F, 0x03, 0x10}, + 24, + climate::CLIMATE_MODE_HEAT, + "AUTO", + "Sleep 5h"}, + {{0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x70, 0x03, 0x00, 0x24, 0x7F, 0x03, 0x20}, + 24, + climate::CLIMATE_MODE_HEAT, + "AUTO", + "Sleep 9h"}, + {{0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x60, 0x03, 0x00, 0x04, 0x7F, 0x03, 0x10}, + 23, + climate::CLIMATE_MODE_HEAT, + "AUTO", + "Sleep 1h"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x60, 0x00, 0x31}, 22, climate::CLIMATE_MODE_HEAT_COOL, "Low+", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x00, 0x61, 0x00, 0x60}, 17, climate::CLIMATE_MODE_COOL, "Low+", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x02, 0x00, 0x53}, 22, climate::CLIMATE_MODE_DRY, "AUTO", "NONE"}, + {{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x63, 0x00, 0x02}, 23, climate::CLIMATE_MODE_HEAT, "Low+", "NONE"}, +}; + +template static void apply_state(R &f, const CapturedState &state) { + f.climate.make_call() + .set_mode(state.mode) + .set_target_temperature(state.temperature) + .set_fan_mode(state.fan) + .set_preset(state.preset) + .perform(); +} + +TEST(ToshibaWhTa15pe, EncodesCapturedStates) { + for (const auto &state : CAPTURED_STATES) { + SCOPED_TRACE(frame_bytes(waveform(state.bytes, GENERIC), state.bytes.size())); + global_preferences->reset(); + RasFixture f; + apply_state(f, state); + if (state.power != 0) + f.climate.set_power_selection(state.power); + ASSERT_FALSE(f.transmitter.frames.empty()); + expect_frame(f.last_frame(), waveform(state.bytes, GENERIC), state.bytes.size()); + } +} + +TEST(ToshibaWhTa15pe, DecodesCapturedStatesAndPublishesPowerSelection) { + PowerSelection power; + for (const auto &state : CAPTURED_STATES) { + SCOPED_TRACE(frame_bytes(waveform(state.bytes, GENERIC), state.bytes.size())); + global_preferences->reset(); + RasFixture expected; + RasFixture f(power); + ASSERT_TRUE(receive(f.climate, state.bytes, GENERIC)); + EXPECT_EQ(f.climate.mode, state.mode); + EXPECT_FLOAT_EQ(f.climate.target_temperature, state.temperature); + EXPECT_EQ(power.active_index(), state.power); + apply_state(expected, state); + EXPECT_EQ(f.climate.fan_mode, expected.climate.fan_mode); + EXPECT_EQ(f.climate.get_custom_fan_mode(), expected.climate.get_custom_fan_mode()); + EXPECT_EQ(f.climate.preset, expected.climate.preset); + EXPECT_EQ(f.climate.get_custom_preset(), expected.climate.get_custom_preset()); + EXPECT_TRUE(f.transmitter.frames.empty()); + } +} + +// Just past the 200 ms window in which a received copy of a frame is ignored +static constexpr uint32_t SWING_WINDOW_PASSED_MS = 220; + +TEST(ToshibaWhTa15pe, ShortCommandsPreserveStateAndSwingCopiesToggleOnce) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_MEDIUM); + f.fan("Low+"); + f.preset("ECO"); + const auto transmissions = f.transmitter.frames.size(); + const std::vector swing{0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}; + const std::vector fix{0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x00, 0x21}; + ASSERT_TRUE(receive(f.climate, swing, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // A copy in a separate buffer within the repeat window + ASSERT_TRUE(receive(f.climate, swing, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + ASSERT_TRUE(receive(f.climate, fix, GENERIC)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Low+"); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + delay(SWING_WINDOW_PASSED_MS); + ASSERT_TRUE(receive(f.climate, swing, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(ToshibaWhTa15pe, DecodesMeasuredSwingBurstIncludingItsRepeatedCopy) { + // S04, WH-TA15PE, 2026-10-21 20:08:32 +02:00. Original timings, including both footer marks. + const remote_base::RawTimings raw{ + 4460, -4399, 581, -1590, 585, -1581, 582, -1581, 581, -1581, 586, -502, 582, -506, 581, -1585, 581, -507, + 581, -507, 581, -512, 580, -507, 581, -512, 580, -1582, 580, -1595, 581, -511, 581, -1581, 581, -507, + 581, -507, 581, -502, 581, -511, 581, -511, 555, -533, 581, -520, 581, -1581, 555, -1621, 585, -1581, + 582, -1585, 581, -1590, 581, -1582, 581, -1594, 581, -1582, 581, -507, 581, -506, 555, -533, 581, -1581, + 525, -576, 582, -510, 582, -519, 581, -511, 581, -1586, 581, -507, 551, -532, 581, -507, 581, -507, + 555, -532, 582, -1589, 582, -507, 580, -516, 581, -507, 581, -506, 582, -1598, 555, -537, 577, -515, + 577, -1586, 555, -533, 576, -1581, 560, -5617, 4456, -4399, 581, -1581, 556, -1611, 638, -1537, 525, -1651, + 612, -480, 608, -475, 612, -1551, 616, -476, 612, -476, 611, -476, 616, -477, 611, -481, 637, -1525, + 612, -1551, 611, -477, 637, -1533, 613, -475, 612, -475, 612, -480, 612, -481, 637, -450, 616, -481, + 611, -481, 611, -1555, 529, -1651, 529, -1646, 612, -1550, 612, -1559, 581, -1590, 612, -1555, 612, -1550, + 612, -472, 616, -471, 638, -450, 638, -1524, 612, -489, 612, -480, 612, -480, 612, -480, 638, -1533, + 555, -555, 611, -476, 612, -476, 613, -479, 612, -481, 612, -1554, 612, -476, 638, -445, 612, -476, + 611, -476, 612, -1555, 616, -471, 612, -481, 612, -1550, 612, -475, 612, -1551, 612, + }; + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 23, climate::CLIMATE_FAN_LOW); + ASSERT_TRUE(receive_raw(f.climate, raw)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); +} + +TEST(ToshibaWhTa15pe, SendsOnlyMotionForSwingAndVaneStep) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.swing(climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC), 7); + f.climate.step_vane(); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x00, 0x21}, GENERIC, 1), 7); + f.swing(climate::CLIMATE_SWING_OFF); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC), 7); +} + +static uint32_t frame_duration_ms(const remote_base::RawTimings &raw) { + uint32_t duration_us = 0; + for (const int32_t pulse : raw) + duration_us += static_cast(std::abs(pulse)); + return (duration_us + 999) / 1000; +} + +TEST(ToshibaWhTa15pe, SwingEchoesDoNotReverseStateOrPreventStopping) { + for (const bool state_command : {false, true}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + auto swing = f.climate.make_call(); + swing.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + if (state_command) + swing.set_target_temperature(23); + swing.perform(); + const auto echo = f.last_frame(); + const auto transmissions = f.transmitter.frames.size(); + ASSERT_TRUE(receive_raw(f.climate, echo)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // Copies can also be delivered in separate receiver buffers. + for (int copy = 0; copy < 2; copy++) { + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + } + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + f.swing(climate::CLIMATE_SWING_OFF); + ASSERT_EQ(f.transmitter.frames.size(), transmissions + 1); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC), 7); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + } +} + +TEST(ToshibaWhTa15pe, SwingEchoWindowCoversTimeSpentWaitingForTheTransmitter) { + for (const bool state_command : {false, true}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + auto expected = waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC); + if (state_command) { + const auto state = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC); + expected.insert(expected.begin(), state.begin(), state.end()); + } + f.transmitter.send_delay_ms = frame_duration_ms(expected) + SWING_WINDOW_PASSED_MS; + auto swing = f.climate.make_call(); + swing.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + if (state_command) + swing.set_target_temperature(23); + swing.perform(); + expect_frame(f.last_frame(), expected, state_command ? 9 : 7); + ASSERT_TRUE(receive_raw(f.climate, f.last_frame())); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + } +} + +TEST(ToshibaWhTa15pe, HandheldSwingWorksAgainAfterTheEchoWindow) { + for (const bool state_command : {false, true}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + auto swing = f.climate.make_call(); + swing.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + if (state_command) + swing.set_target_temperature(23); + swing.perform(); + const auto transmissions = f.transmitter.frames.size(); + delay(frame_duration_ms(f.last_frame()) + SWING_WINDOW_PASSED_MS); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + delay(SWING_WINDOW_PASSED_MS); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + } +} + +TEST(ToshibaWhTa15pe, ReceivesEveryCommandInACompleteStateAndMotionBurst) { + for (const bool final_space : {true, false}) { + global_preferences->reset(); + RasFixture tx; + tx.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + auto call = tx.climate.make_call(); + call.set_target_temperature(23); + call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + call.perform(); + auto burst = tx.last_frame(); + if (!final_space) + burst.pop_back(); + global_preferences->reset(); + RasFixture rx; + ASSERT_EQ(rx.climate.swing_mode, climate::CLIMATE_SWING_OFF); + ASSERT_TRUE(receive_raw(rx.climate, burst)); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, 23); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(rx.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_TRUE(rx.transmitter.frames.empty()); + } +} + +TEST(ToshibaWhTa15pe, BothCopiesOfAPressApplyAndPublishOnce) { + RasFixture f; + size_t publications = 0; + f.climate.add_on_state_callback([&](climate::Climate &) { publications++; }); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC)); + EXPECT_EQ(publications, 1u); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + // A different frame after the pair is still applied. + auto burst = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC); + const auto swing = waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC); + burst.insert(burst.end(), swing.begin(), swing.end()); + publications = 0; + delay(SWING_WINDOW_PASSED_MS); + ASSERT_TRUE(receive_raw(f.climate, burst)); + EXPECT_EQ(publications, 2u); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(ToshibaWhTa15pe, CopiesInSeparateReceiverBuffersApplyAndPublishOnce) { + RasFixture f; + size_t publications = 0; + f.climate.add_on_state_callback([&](climate::Climate &) { publications++; }); + const std::vector state{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}; + // With a short receiver idle, each copy ends its own buffer. + ASSERT_TRUE(receive(f.climate, state, GENERIC)); + delay(6); + ASSERT_TRUE(receive(f.climate, state, GENERIC)); + EXPECT_EQ(publications, 1u); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + // A motion frame following the state frame is still applied, once. + for (int copy = 0; copy < 2; copy++) + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + EXPECT_EQ(publications, 2u); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(ToshibaWhTa15pe, IdenticalPressAfterTheRepeatWindowIsAppliedAgain) { + RasFixture f; + size_t publications = 0; + f.climate.add_on_state_callback([&](climate::Climate &) { publications++; }); + const std::vector state{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}; + ASSERT_TRUE(receive(f.climate, state, GENERIC)); + EXPECT_EQ(publications, 1u); + f.target(25); + publications = 0; + delay(SWING_WINDOW_PASSED_MS); + ASSERT_TRUE(receive(f.climate, state, GENERIC)); + EXPECT_EQ(publications, 1u); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); +} + +TEST(ToshibaWhTa15pe, ReceivedFramesPublishThePowerSelectionOnlyWhenItChanges) { + PowerSelection power; + RasFixture f(power); + size_t publications = 0; + power.add_on_state_callback([&](size_t) { publications++; }); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC)); + EXPECT_EQ(publications, 0u); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x40, 0x52}, GENERIC)); + EXPECT_EQ(publications, 1u); + EXPECT_EQ(power.active_index(), 1u); +} + +TEST(ToshibaWhTa15pe, DoesNotApplyAnInvalidFrameAfterAValidFrame) { + auto burst = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC, 1); + auto invalid = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x03, 0x00, 0xFF}, GENERIC, 1); + burst.insert(burst.end(), invalid.begin(), invalid.end()); + RasFixture f; + ASSERT_TRUE(receive_raw(f.climate, burst)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_TRUE(f.transmitter.frames.empty()); +} + +TEST(ToshibaWhTa15pe, ReceivesDifferentStateFramesInTheSameBuffer) { + auto burst = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC); + const auto update = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x61, 0x00, 0x30}, GENERIC); + burst.insert(burst.end(), update.begin(), update.end()); + RasFixture f; + ASSERT_TRUE(receive_raw(f.climate, burst)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Low+"); + EXPECT_TRUE(f.transmitter.frames.empty()); +} + +TEST(ToshibaWhTa15pe, FreshNormalTargetIsValidEvenWithAColdSensor) { + for (const float temperature : {4.0f, 10.0f, 16.0f, 17.0f, 22.0f, 35.0f, NAN}) { + global_preferences->reset(); + sensor::Sensor sensor; + sensor.publish_state(temperature); + RasFixture f(false); + f.climate.set_sensor(&sensor); + f.climate.setup(); + const float expected = std::isnan(temperature) ? 24 : clamp(temperature, 17.0f, 30.0f); + EXPECT_FLOAT_EQ(f.climate.target_temperature, expected); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FLOAT_EQ(f.climate.get_traits().get_visual_min_temperature(), 5); + EXPECT_TRUE(f.transmitter.frames.empty()); + + // FAN ONLY must also use the normal target initialized at startup. + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x64, 0x00, 0x35}, GENERIC)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, expected); + f.climate.set_power_selection(1); + std::vector bytes{ + 0xF2, 0x0D, 0x03, 0xFC, 0x01, static_cast((static_cast(expected) - 17) << 4), 0x64, 0x40, 0}; + bytes.back() = xor_bytes(bytes.data() + 4, bytes.size() - 5); + expect_frame(f.last_frame(), waveform(bytes, GENERIC), 9); + f.mode(climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.transmitter.frames.size(), 2u); + f.mode(climate::CLIMATE_MODE_OFF); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_EQ(f.transmitter.frames.size(), 3u); + } +} + +TEST(ToshibaWhTa15pe, OrdinaryFramesPreserveSwingAndDoNotEchoReceivedCommands) { + RasFixture f; + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x63, 0x00, 0x02}, GENERIC)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + f.target(24); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x00, 0x12}, GENERIC), 9); +} + +TEST(ToshibaWhTa15pe, FrostProtectionRangeAndNormalTargetRestoration) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.fan("Low+"); + f.preset("Frost protection"); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 8); + static constexpr uint8_t CHECKSUMS[] = {0x2E, 0x3E, 0x0E, 0x1E, 0xEE, 0xFE, 0xCE, 0xDE, 0xAE}; + for (uint8_t temperature = 5; temperature <= 13; temperature++) { + f.target(temperature); + expect_frame(f.last_frame(), + waveform({0xF2, 0x0D, 0x04, 0xFB, 0x09, static_cast((temperature - 1) << 4), 0x63, 0x00, 0x04, + CHECKSUMS[temperature - 5]}, + GENERIC), + 10); + } + const auto transmissions = f.transmitter.frames.size(); + for (float temperature : {4.0f, 14.0f, 16.0f, 17.0f, INFINITY}) { + f.target(temperature); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 13); + } + f.preset("NONE"); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Low+"); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x00, 0x12}, GENERIC), 9); + const auto normal_transmissions = f.transmitter.frames.size(); + for (float temperature : {5.0f, 13.0f, 14.0f, 16.0f, 31.0f}) { + f.target(temperature); + EXPECT_EQ(f.transmitter.frames.size(), normal_transmissions); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + } +} + +TEST(ToshibaWhTa15pe, SleepForcesAutoAndCancellationRestoresCustomFan) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.fan("Low+"); + for (const char *preset : {"Sleep 1h", "Sleep 3h", "Sleep 5h", "Sleep 9h"}) { + f.preset(preset); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); + EXPECT_FALSE(f.climate.has_custom_fan_mode()); + } + f.target(23); + EXPECT_EQ(f.climate.get_custom_preset(), "Sleep 9h"); + f.preset("NONE"); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Low+"); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x63, 0x00, 0x02}, GENERIC), 9); +} + +TEST(ToshibaWhTa15pe, PresetReplacementAndHadaMotionRestrictions) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.preset("Fireplace 2"); + f.preset("ECO"); + EXPECT_FALSE(f.climate.has_custom_preset()); + f.preset("HADA Care"); + EXPECT_EQ(f.climate.get_custom_preset(), "HADA Care"); + const auto transmissions = f.transmitter.frames.size(); + f.climate.step_vane(); + f.swing(climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + f.preset("NONE"); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FALSE(f.climate.has_custom_preset()); + f.climate.step_vane(); + EXPECT_EQ(f.transmitter.frames.size(), transmissions + 2); +} + +TEST(ToshibaWhTa15pe, FanOnlyDoesNotOverwriteRememberedTarget) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 23, climate::CLIMATE_FAN_LOW); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x64, 0x00, 0x35}, GENERIC)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Low+"); +} + +TEST(ToshibaWhTa15pe, RejectsEveryTruncatedCaptureAndBadChecksumWithoutPartialState) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.fan("Medium+"); + f.preset("ECO"); + for (const auto &state : CAPTURED_STATES) { + SCOPED_TRACE(frame_bytes(waveform(state.bytes, GENERIC), state.bytes.size())); + auto raw = waveform(state.bytes, GENERIC, 1); + raw.pop_back(); // Captured frames may end on the footer mark, without an idle space. + for (size_t count = 0; count < raw.size(); count++) { + remote_base::RawTimings truncated(raw.begin(), raw.begin() + count); + EXPECT_FALSE(receive_raw(f.climate, truncated)); + } + auto corrupt = state.bytes; + corrupt.back() ^= 1; + EXPECT_FALSE(receive(f.climate, corrupt, GENERIC)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Medium+"); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + } +} + +TEST(ToshibaWhTa15pe, RejectsUnknownFormatsAndDoesNotChangeOtherModels) { + RasFixture f; + const std::vector> invalid = { + {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x04, 0x05}, + {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x08, 0x29}, + {0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x00, 0xFF, 0xE5}, + {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x63, 0x00, 0x21, 0x08, 0x3B}, + {0xF2, 0x0D, 0x06, 0xF9, 0x0B, 0x70, 0x03, 0x00, 0x08, 0x7F, 0x03, 0x0C}, + }; + for (const auto &bytes : invalid) + EXPECT_FALSE(receive(f.climate, bytes, GENERIC)); + Fixture generic(MODEL_GENERIC); + EXPECT_FALSE(receive(generic.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC)); + EXPECT_FALSE(generic.climate.get_traits().get_supports_presets()); + EXPECT_TRUE(generic.climate.get_traits().get_supported_custom_fan_modes().empty()); +} + +TEST(ToshibaWhTa15pe, RebootDoesNotRearmSleepAndRestoresCustomFan) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 23, climate::CLIMATE_FAN_LOW); + f.fan("Medium+"); + { + RasFixture restored; + EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(restored.climate.get_custom_fan_mode(), "Medium+"); + EXPECT_FALSE(restored.climate.fan_mode.has_value()); + EXPECT_FLOAT_EQ(restored.climate.target_temperature, 23); + EXPECT_TRUE(restored.transmitter.frames.empty()); + } + f.preset("Sleep 3h"); + RasFixture restored; + EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(restored.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FALSE(restored.climate.has_custom_preset()); + EXPECT_TRUE(restored.transmitter.frames.empty()); + restored.target(24); + expect_frame(restored.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x03, 0x00, 0x72}, GENERIC), 9); +} + +TEST(ToshibaWhTa15pe, RestoresSelectedPowerBeforePublishingAndSendingTarget) { + for (size_t selection = 0; selection <= 4; selection++) { + for (const bool eco : {false, true}) { + if (eco && selection >= 3) + continue; + SCOPED_TRACE(::testing::Message() << "selection " << selection << " eco " << eco); + global_preferences->reset(); + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.climate.set_power_selection(selection); + if (eco) + f.preset("ECO"); + + PowerSelection power; + size_t publications = 0; + power.add_on_state_callback([&](size_t index) { + publications++; + EXPECT_EQ(index, selection); + }); + RasFixture restored(power); + EXPECT_EQ(publications, 1u); + EXPECT_EQ(power.active_index(), selection); + EXPECT_EQ(restored.climate.preset, eco ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE); + EXPECT_TRUE(restored.transmitter.frames.empty()); + + restored.target(23); + ASSERT_EQ(restored.transmitter.frames.size(), 1u); + // Derived regression frames: a new target must keep the saved power bits or Silent extension. + const uint8_t bits = selection <= 2 ? selection << 6 : 0; + std::vector bytes{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x43, bits}; + if (eco || selection >= 3) { + bytes[2] = 0x04; + bytes[3] = 0xFB; + bytes[4] = 0x09; + bytes.push_back(eco ? 0x03 : (selection == 3 ? 0x02 : 0x0A)); + } + bytes.push_back(xor_bytes(bytes.data() + 4, bytes.size() - 4)); + expect_frame(restored.last_frame(), waveform(bytes, GENERIC), bytes.size()); + } + } +} + +TEST(ToshibaWhTa15pe, RestoresReceivedPowerWithoutASelectConfiguredAtReception) { + for (const auto &state : CAPTURED_STATES) { + if (state.power == 0) + continue; + SCOPED_TRACE(state.power); + global_preferences->reset(); + RasFixture f; + ASSERT_TRUE(receive(f.climate, state.bytes, GENERIC)); + PowerSelection power; + RasFixture restored(power); + EXPECT_EQ(power.active_index(), state.power); + EXPECT_TRUE(restored.transmitter.frames.empty()); + restored.target(state.temperature); + ASSERT_EQ(restored.transmitter.frames.size(), 1u); + expect_frame(restored.last_frame(), waveform(state.bytes, GENERIC), state.bytes.size()); + } +} + +TEST(ToshibaWhTa15pe, PublishesDefaultPowerAndIgnoresInvalidSavedSelections) { + for (const int saved : {-1, 5, 255}) { + global_preferences->reset(); + PowerSelection power; + RasFixture f(false); + if (saved >= 0) { + const uint8_t value = saved; + auto pref = f.climate.make_entity_preference(Ras::POWER_SELECTION_RESTORE_VERSION); + ASSERT_TRUE(pref.save(&value)); + } + f.climate.set_power_selection_select(&power); + f.climate.setup(); + EXPECT_EQ(power.active_index(), 0u); + EXPECT_TRUE(f.transmitter.frames.empty()); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO); + ASSERT_EQ(f.transmitter.frames.size(), 1u); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x03, 0x00, 0x72}, GENERIC), 9); + } +} + +TEST(ToshibaWhTa15pe, KeepsSavedSilentAndClearsConflictingPresetsBeforePublishing) { + for (const uint8_t silent : {3, 4}) { + for (const auto &state : CAPTURED_STATES) { + if (strcmp(state.preset, "NONE") == 0) + continue; + SCOPED_TRACE(::testing::Message() << "Silent " << silent << " preset " << state.preset); + global_preferences->reset(); + RasFixture f; + ASSERT_TRUE(receive(f.climate, state.bytes, GENERIC)); + // Simulate inconsistent climate and power preferences after an interrupted save. + auto pref = f.climate.make_entity_preference(Ras::POWER_SELECTION_RESTORE_VERSION); + ASSERT_TRUE(pref.save(&silent)); + PowerSelection power; + RasFixture restored(false); + restored.climate.set_power_selection_select(&power); + expect_restore(restored, [&](climate::Climate &climate) { + EXPECT_EQ(climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FALSE(climate.has_custom_preset()); + EXPECT_GE(climate.target_temperature, 17); + }); + EXPECT_EQ(power.active_index(), silent); + } + } +} + +TEST(ToshibaRestore, ExistingModelsRestoreTheirTraitsBeforePublishing) { + for (const auto model : {MODEL_GENERIC, MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F, MODEL_RAS_2819T, MODEL_SEIYA, + MODEL_RAS_B10N3KV2}) { + SCOPED_TRACE(model); + global_preferences->reset(); + Fixture f(model); + const auto traits = f.climate.get_traits(); + const auto swing = traits.get_supports_swing_modes() ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const float temperature = traits.get_visual_min_temperature(); + f.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO, swing); + Fixture restored(model, false); + expect_restore(restored, [&](climate::Climate &climate) { + EXPECT_EQ(climate.swing_mode, swing); + EXPECT_FLOAT_EQ(climate.target_temperature, temperature); + EXPECT_FLOAT_EQ(climate.get_traits().get_visual_min_temperature(), temperature); + }); + } +} + +TEST(ToshibaWhTa15pe, RestoresSwingBeforePublishingAndDecidingWhetherToToggle) { + for (const auto swing : {climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_OFF}) { + RasFixture f; + f.preset("NONE"); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, swing); + + RasFixture restored(false); + size_t publications = 0; + bool restoring = true; + restored.climate.add_on_state_callback([&](climate::Climate &state) { + publications++; + if (restoring) + EXPECT_EQ(state.swing_mode, swing); + }); + restored.climate.setup(); + EXPECT_GT(publications, 0u); + EXPECT_EQ(restored.climate.swing_mode, swing); + EXPECT_TRUE(restored.transmitter.frames.empty()); + restoring = false; + + restored.swing(climate::CLIMATE_SWING_OFF); + ASSERT_EQ(restored.transmitter.frames.size(), swing == climate::CLIMATE_SWING_VERTICAL ? 1u : 0u); + if (swing == climate::CLIMATE_SWING_VERTICAL) + expect_frame(restored.last_frame(), waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC), 7); + } +} + +TEST(ToshibaWhTa15pe, RestoreCallbacksChangingTargetDoNotToggleSwing) { + for (const auto saved : {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) { + SCOPED_TRACE(saved); + global_preferences->reset(); + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO); + // Include saved directions that are no longer supported by this model. + f.climate.swing_mode = saved; + f.climate.publish_state(); + + RasFixture restored(false); + const auto swing = saved == climate::CLIMATE_SWING_VERTICAL ? saved : climate::CLIMATE_SWING_OFF; + size_t publications = 0; + bool changed = false; + bool restoring = true; + restored.climate.add_on_state_callback([&](climate::Climate &state) { + publications++; + if (restoring) + EXPECT_EQ(state.swing_mode, swing); + if (!changed) { + changed = true; + restored.target(23); + } + }); + restored.climate.setup(); + restoring = false; + EXPECT_EQ(publications, 2u); + EXPECT_FLOAT_EQ(restored.climate.target_temperature, 23); + ASSERT_EQ(restored.transmitter.frames.size(), 1u); + expect_frame(restored.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC), 9); + + restored.target(22); + ASSERT_EQ(restored.transmitter.frames.size(), 2u); + expect_frame(restored.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x03, 0x00, 0x52}, GENERIC), 9); + restored.swing(climate::CLIMATE_SWING_OFF); + EXPECT_EQ(restored.transmitter.frames.size(), swing == climate::CLIMATE_SWING_VERTICAL ? 3u : 2u); + EXPECT_EQ(restored.climate.swing_mode, climate::CLIMATE_SWING_OFF); + } +} + +TEST(ToshibaWhTa15pe, RestoreCallbacksCanDeliberatelyChangeSwing) { + for (const auto saved : {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) { + SCOPED_TRACE(saved); + global_preferences->reset(); + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, saved); + RasFixture restored(false); + const auto swing = + saved == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + bool changed = false; + restored.climate.add_on_state_callback([&](climate::Climate &state) { + if (!changed) { + changed = true; + EXPECT_EQ(state.swing_mode, saved); + auto call = restored.climate.make_call(); + call.set_target_temperature(23); + call.set_swing_mode(swing); + call.perform(); + } + }); + restored.climate.setup(); + ASSERT_TRUE(changed); + EXPECT_EQ(restored.climate.swing_mode, swing); + auto expected = waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x03, 0x00, 0x62}, GENERIC); + const auto motion = waveform({0xF2, 0x0D, 0x01, 0xFE, 0x21, 0x04, 0x25}, GENERIC); + expected.insert(expected.end(), motion.begin(), motion.end()); + ASSERT_EQ(restored.transmitter.frames.size(), 1u); + EXPECT_EQ(restored.last_frame(), expected); + + restored.target(22); + ASSERT_EQ(restored.transmitter.frames.size(), 2u); + expect_frame(restored.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x03, 0x00, 0x52}, GENERIC), 9); + } +} + +TEST(ToshibaWhTa15pe, ClearsRestoredSleepBeforeAnyPublication) { + for (const char *preset : {"Sleep 1h", "Sleep 3h", "Sleep 5h", "Sleep 9h"}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 23, climate::CLIMATE_FAN_AUTO); + f.preset(preset); + + RasFixture restored(false); + expect_restore(restored, [&](climate::Climate &state) { + EXPECT_FALSE(state.has_custom_preset()); + EXPECT_EQ(state.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FLOAT_EQ(state.target_temperature, 23); + }); + } +} + +TEST(ToshibaWhTa15pe, SanitizesInvalidRestoredHeatingPresetsBeforePublishing) { + for (const char *preset : {"Fireplace 1", "Fireplace 2", "Frost protection"}) { + for (const bool heat_supported : {true, false}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.preset(preset); + // Save a state from a configuration that is no longer valid at startup. + if (heat_supported) + f.climate.mode = climate::CLIMATE_MODE_COOL; + f.climate.target_temperature = NAN; + f.climate.publish_state(); + + RasFixture restored(false); + restored.climate.set_supports_heat(heat_supported); + expect_restore(restored, [&](climate::Climate &state) { + EXPECT_FALSE(state.has_custom_preset()); + EXPECT_EQ(state.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_EQ(state.mode, heat_supported ? climate::CLIMATE_MODE_COOL : climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(state.target_temperature, 24); + }); + } + } +} + +TEST(ToshibaWhTa15pe, RestoresValidPresetsAndTargetsBeforePublishing) { + for (const auto &state : CAPTURED_STATES) { + if (strcmp(state.preset, "NONE") == 0 || strncmp(state.preset, "Sleep", 5) == 0) + continue; + RasFixture f; + ASSERT_TRUE(receive(f.climate, state.bytes, GENERIC)); + RasFixture restored(false); + expect_restore(restored, [&](climate::Climate &climate) { + EXPECT_EQ(climate.preset, f.climate.preset); + EXPECT_EQ(climate.get_custom_preset(), f.climate.get_custom_preset()); + EXPECT_EQ(climate.mode, f.climate.mode); + EXPECT_FLOAT_EQ(climate.target_temperature, state.temperature); + }); + } +} + +TEST(ToshibaWhTa15pe, ClearsUnsupportedRestoredPresetAndClampsNormalTargetBeforePublishing) { + for (const float temperature : {5.0f, 31.0f, NAN}) { + RasFixture f; + f.preset("NONE"); + f.climate.preset = climate::CLIMATE_PRESET_HOME; + f.climate.target_temperature = temperature; + f.climate.publish_state(); + + RasFixture restored(false); + expect_restore(restored, [&](climate::Climate &state) { + EXPECT_EQ(state.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FALSE(state.has_custom_preset()); + EXPECT_FLOAT_EQ(state.target_temperature, std::isnan(temperature) ? 24 : (temperature < 17 ? 17 : 30)); + }); + } +} + +TEST(ToshibaWhTa15pe, HadaAllowsOffSwingButRejectsIncompatibleMovementAtomically) { + for (const auto swing : {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) { + RasFixture f; + f.preset("NONE"); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW, swing); + const auto transmissions = f.transmitter.frames.size(); + auto hada = f.climate.make_call(); + hada.set_preset("HADA Care"); + hada.set_swing_mode(climate::CLIMATE_SWING_OFF); + hada.perform(); + EXPECT_EQ(f.climate.get_custom_preset(), "HADA Care"); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + ASSERT_EQ(f.transmitter.frames.size(), transmissions + 1); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x43, 0x00, 0x21, 0x17, 0x04}, GENERIC), + 11); + + auto update = f.climate.make_call(); + update.set_target_temperature(23); + update.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + update.perform(); + EXPECT_EQ(f.transmitter.frames.size(), transmissions + 1); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + EXPECT_EQ(f.climate.get_custom_preset(), "HADA Care"); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + } +} + +TEST(ToshibaWhTa15pe, PreservesExplicitEcoAndBoostWhileOffButCancelsOnPowerOff) { + for (const auto preset : {climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}) { + for (const bool explicit_off : {true, false}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_AUTO); + f.climate.set_power_selection(1); + const auto transmissions = f.transmitter.frames.size(); + auto call = f.climate.make_call(); + call.set_preset(preset); + if (explicit_off) + call.set_mode(climate::CLIMATE_MODE_OFF); + call.perform(); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_EQ(f.climate.preset, preset); + ASSERT_EQ(f.transmitter.frames.size(), transmissions + 1); + + RasFixture rx; + ASSERT_TRUE(receive_raw(rx.climate, f.last_frame())); + EXPECT_EQ(rx.climate.preset, preset); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_OFF); + + f.target(23); + EXPECT_EQ(f.climate.preset, preset); + f.mode(climate::CLIMATE_MODE_OFF); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x60, 0x07, 0x40, 0x26}, GENERIC), 9); + } + } +} + +TEST(ToshibaWhTa15pe, RejectsOnlyUnverifiedSilentPresetCombinationsWithoutChangingState) { + for (const auto &state : CAPTURED_STATES) { + if (strcmp(state.preset, "NONE") == 0) + continue; + for (const size_t silent : {3u, 4u}) { + for (const bool silent_first : {true, false}) { + SCOPED_TRACE(::testing::Message() << state.preset << " Silent " << silent << " first " << silent_first); + global_preferences->reset(); + PowerSelection power; + RasFixture f(power); + f.preset("NONE"); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.climate.set_power_selection(0); + if (silent_first) { + f.climate.set_power_selection(silent); + } else { + f.preset(state.preset); + } + const auto transmissions = f.transmitter.frames.size(); + const auto preset = f.climate.preset; + const auto custom_preset = f.climate.get_custom_preset(); + const auto temperature = f.climate.target_temperature; + if (silent_first) { + auto call = f.climate.make_call(); + call.set_preset(state.preset); + call.set_target_temperature(23); + call.set_fan_mode(climate::CLIMATE_FAN_HIGH); + call.perform(); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + } else { + f.climate.set_power_selection(silent); + } + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + EXPECT_EQ(f.climate.preset, preset); + EXPECT_EQ(f.climate.get_custom_preset(), custom_preset); + EXPECT_FLOAT_EQ(f.climate.target_temperature, temperature); + EXPECT_EQ(power.active_index(), silent_first ? silent : 0u); + + PowerSelection rx_power; + RasFixture rx(rx_power); + ASSERT_TRUE(receive_raw(rx.climate, f.last_frame())); + EXPECT_EQ(rx_power.active_index(), power.active_index()); + EXPECT_EQ(rx.climate.preset, preset); + EXPECT_EQ(rx.climate.get_custom_preset(), custom_preset); + + // Either cancel the preset or leave Silent before selecting the other feature. + if (silent_first) { + f.climate.set_power_selection(1); + f.preset(state.preset); + EXPECT_EQ(power.active_index(), 1u); + } else { + f.preset("NONE"); + f.climate.set_power_selection(silent); + EXPECT_EQ(power.active_index(), silent); + } + EXPECT_EQ(f.transmitter.frames.size(), transmissions + 2); + } + } + } +} + +TEST(ToshibaWhTa15pe, ModeChangeExitsHeatingPresetAndPreservesNormalTarget) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.preset("Frost protection"); + f.mode(climate::CLIMATE_MODE_COOL); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + + const auto transmissions = f.transmitter.frames.size(); + f.climate.make_call().set_mode(climate::CLIMATE_MODE_COOL).set_preset("Fireplace 1").perform(); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + + f.climate.set_supports_heat(false); + f.preset("Frost protection"); + EXPECT_EQ(f.transmitter.frames.size(), transmissions); +} + +TEST(ToshibaWhTa15pe, PresetsPreservePowerLimitsInBothControlOrders) { + // The power limit occupies byte 7 independently of the preset's feature bytes. + // Combined payloads are regression expectations, not additional handheld captures. + for (const auto &state : CAPTURED_STATES) { + if (strcmp(state.preset, "NONE") == 0) + continue; + for (const size_t power : {1u, 2u}) { + SCOPED_TRACE(::testing::Message() << "preset " << state.preset << " power " << power); + auto expected = state.bytes; + expected[7] |= power << 6; + expected.back() ^= power << 6; + + for (const bool power_first : {true, false}) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO, {}, "NONE"); + if (power_first) + f.climate.set_power_selection(power); + const auto transmissions = f.transmitter.frames.size(); + apply_state(f, state); + ASSERT_EQ(f.transmitter.frames.size(), transmissions + 1); + if (!power_first) + f.climate.set_power_selection(power); + expect_frame(f.last_frame(), waveform(expected, GENERIC), expected.size()); + + // A subsequent ordinary target update must retain both settings. + const auto updates = f.transmitter.frames.size(); + f.target(state.temperature); + ASSERT_EQ(f.transmitter.frames.size(), updates + 1); + expect_frame(f.last_frame(), waveform(expected, GENERIC), expected.size()); + } + } + } +} + +TEST(ToshibaWhTa15pe, ReceivesCombinedPresetsAndPowerLimitsWithoutLosingEither) { + PowerSelection power_select; + for (const auto &state : CAPTURED_STATES) { + if (strcmp(state.preset, "NONE") == 0) + continue; + for (const size_t power : {1u, 2u}) { + SCOPED_TRACE(::testing::Message() << "preset " << state.preset << " power " << power); + auto combined = state.bytes; + combined[7] |= power << 6; + combined.back() ^= power << 6; + RasFixture f; + f.climate.set_power_selection_select(&power_select); + ASSERT_TRUE(receive(f.climate, combined, GENERIC)); + EXPECT_EQ(power_select.active_index(), power); + EXPECT_FLOAT_EQ(f.climate.target_temperature, state.temperature); + const auto preset = f.climate.preset; + const auto custom_preset = f.climate.get_custom_preset(); + + // Setting the same preset after receiving it must transmit and keep the limit. + const auto transmissions = f.transmitter.frames.size(); + f.preset(state.preset); + ASSERT_EQ(f.transmitter.frames.size(), transmissions + 1); + EXPECT_EQ(f.climate.preset, preset); + EXPECT_EQ(f.climate.get_custom_preset(), custom_preset); + expect_frame(f.last_frame(), waveform(combined, GENERIC), combined.size()); + } + } +} + +TEST(ToshibaWhTa15pe, CancellingPresetPreservesPowerLimitAndPublishesSelection) { + PowerSelection power_select; + RasFixture f; + f.climate.set_power_selection_select(&power_select); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO); + f.fan("Low+"); + f.climate.set_power_selection(1); + f.preset("ECO"); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_EQ(power_select.active_index(), 1u); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x40, 0x03, 0x59}, GENERIC), 10); + f.climate.set_power_selection(2); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_EQ(power_select.active_index(), 2u); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x04, 0xFB, 0x09, 0x70, 0x63, 0x80, 0x03, 0x99}, GENERIC), 10); + f.preset("NONE"); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_EQ(power_select.active_index(), 2u); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x80, 0x92}, GENERIC), 9); +} + +TEST(ToshibaWhTa15pe, SelectingTheCurrentPowerSendsButDoesNotPublish) { + PowerSelection power; + RasFixture f(power); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO); + size_t publications = 0; + power.add_on_state_callback([&](size_t) { publications++; }); + const auto transmissions = f.transmitter.frames.size(); + f.climate.set_power_selection(0); + EXPECT_EQ(f.transmitter.frames.size(), transmissions + 1); + EXPECT_EQ(publications, 0u); + f.climate.set_power_selection(1); + EXPECT_EQ(f.transmitter.frames.size(), transmissions + 2); + EXPECT_EQ(publications, 1u); + EXPECT_EQ(power.active_index(), 1u); +} + +TEST(ToshibaWhTa15pe, PowerSelectionCallbacksKeepPresetAndWireInSync) { + for (const size_t selection : {1u, 2u}) { + for (const bool enable_eco : {false, true}) { + SCOPED_TRACE(::testing::Message() << "selection " << selection << " enable ECO " << enable_eco); + global_preferences->reset(); + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_AUTO); + f.fan("Low+"); + f.preset(enable_eco ? "NONE" : "ECO"); + PowerSelection power; + f.climate.set_power_selection_select(&power); + size_t publications = 0; + power.add_on_state_callback([&](size_t index) { + publications++; + EXPECT_EQ(index, selection); + f.preset(enable_eco ? "ECO" : "NONE"); + }); + f.transmitter.frames.clear(); + f.climate.set_power_selection(selection); + EXPECT_EQ(publications, 1u); + EXPECT_EQ(power.active_index(), selection); + EXPECT_EQ(f.climate.preset, enable_eco ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE); + ASSERT_EQ(f.transmitter.frames.size(), 2u); + + // Derived regression frames: the selection goes out with the preset it was made under, then the + // callback's preset follows with the same limit, so the last frame matches the final state. + const auto frame = [&](bool eco) { + std::vector bytes{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, static_cast(selection << 6)}; + if (eco) { + bytes[2] = 0x04; + bytes[3] = 0xFB; + bytes[4] = 0x09; + bytes.push_back(0x03); + } + bytes.push_back(xor_bytes(bytes.data() + 4, bytes.size() - 4)); + return bytes; + }; + const auto first = frame(!enable_eco); + const auto last = frame(enable_eco); + expect_frame(f.transmitter.frames[0], waveform(first, GENERIC), first.size()); + expect_frame(f.transmitter.frames[1], waveform(last, GENERIC), last.size()); + } + } +} + +TEST(ToshibaWhTa15pe, SensorUpdatesDoNotTransmitARacComfortSenseCommand) { + sensor::Sensor sensor; + RasFixture f(false); + f.climate.set_sensor(&sensor); + f.climate.setup(); + sensor.publish_state(21); + EXPECT_FLOAT_EQ(f.climate.current_temperature, 21); + EXPECT_TRUE(f.transmitter.frames.empty()); +} + +TEST(ToshibaWhTa15pe, RejectsInvalidStateFieldsEvenWithValidChecksums) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 23, climate::CLIMATE_FAN_LOW); + for (const auto &field : std::vector>{ + {4, 0x05}, {5, 0x61}, {5, 0xE0}, {6, 0x65}, {6, 0xE3}, {6, 0x73}, {7, 0x10}, {7, 0xC0}, {7, 0x01}}) { + auto bytes = std::vector{0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x00, 0x12}; + bytes[field.first] = field.second; + bytes.back() = xor_bytes(bytes.data() + 4, bytes.size() - 5); + EXPECT_FALSE(receive(f.climate, bytes, GENERIC)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + } + EXPECT_FALSE(receive(f.climate, {0x00, 0xFF, 0x03, 0xFC, 0x01, 0x70, 0x63, 0x00, 0x12}, GENERIC)); +} + +TEST(ToshibaWhTa15pe, FanOnlyAfterFrostRestoresNormalTargetWithoutDecodingItsTemperatureByte) { + RasFixture f; + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x04, 0xFB, 0x09, 0xC0, 0x63, 0x00, 0x04, 0xAE}, GENERIC)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 13); + ASSERT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x50, 0x64, 0x00, 0x35}, GENERIC)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); +} + } // namespace esphome::toshiba::testing From db16d86ee242ce482469a8614bae7dfce13e51e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 15:16:12 -1000 Subject: [PATCH 20/37] [mqtt] Reject subscribed topics longer than the ESP8266 and LibreTiny MQTT client accepts (#20768) --- esphome/components/mqtt/__init__.py | 239 +++++++++++++-- .../components/test_mqtt_topic_length.py | 274 ++++++++++++++++++ 2 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 tests/unit_tests/components/test_mqtt_topic_length.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 5c5a49f78aa..275c77a4178 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + from esphome import automation import esphome.codegen as cg from esphome.components import logger, network, socket @@ -10,6 +12,7 @@ from esphome.components.esp32 import ( request_tls, require_certificate_bundle, ) +from esphome.config import iter_ids from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -26,6 +29,8 @@ from esphome.const import ( CONF_CLIENT_ID, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, + CONF_DEVICE_ID, + CONF_DEVICES, CONF_DISCOVER_IP, CONF_DISCOVERY, CONF_DISCOVERY_OBJECT_ID_GENERATOR, @@ -33,11 +38,17 @@ from esphome.const import ( CONF_DISCOVERY_RETAIN, CONF_DISCOVERY_UNIQUE_ID_GENERATOR, CONF_ENABLE_ON_BOOT, + CONF_ESPHOME, CONF_ID, + CONF_INTERNAL, CONF_KEEPALIVE, CONF_LEVEL, CONF_LOG_TOPIC, + CONF_MODE_COMMAND_TOPIC, CONF_MQTT, + CONF_MQTT_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_ON_JSON_MESSAGE, @@ -46,6 +57,7 @@ from esphome.const import ( CONF_PAYLOAD, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, + CONF_PLATFORM, CONF_PORT, CONF_PUBLISH_NAN_AS_NONE, CONF_QOS, @@ -55,6 +67,9 @@ from esphome.const import ( CONF_SKIP_CERT_CN_CHECK, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, + CONF_TARGET_TEMPERATURE_COMMAND_TOPIC, + CONF_TARGET_TEMPERATURE_HIGH_COMMAND_TOPIC, + CONF_TARGET_TEMPERATURE_LOW_COMMAND_TOPIC, CONF_TOPIC, CONF_TOPIC_PREFIX, CONF_USE_ABBREVIATIONS, @@ -69,6 +84,9 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.helpers import sanitize, snake_case from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -133,28 +151,76 @@ MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) -MQTTAlarmControlPanelComponent = mqtt_ns.class_( - "MQTTAlarmControlPanelComponent", MQTTComponent +# A topic the firmware builds from a suffix, unless the config key replaces the whole topic +_Topic = tuple[str, str] +# The firmware subscribes to exactly one of these alternatives, chosen by entity traits +_Subscription = tuple[tuple[_Topic, ...], ...] +# The subscriptions each entity MQTT class always makes, keyed by class: component type, +# then the subscriptions +_COMMAND_TOPICS: dict[str, tuple[str, tuple[_Subscription, ...]]] = {} + + +def _single_topic(suffix: str, custom_key: str) -> _Subscription: + return (((suffix, custom_key),),) + + +_COMMAND = _single_topic("command", CONF_COMMAND_TOPIC) + + +def _entity_class( + name: str, + component_type: str | None = None, + subscriptions: tuple[_Subscription, ...] = (_COMMAND,), +) -> MockObjClass: + """Declare an entity MQTT class; a component_type means it subscribes to command topics.""" + cls = mqtt_ns.class_(name, MQTTComponent) + if component_type is not None: + _COMMAND_TOPICS[str(cls)] = (component_type, subscriptions) + return cls + + +MQTTAlarmControlPanelComponent = _entity_class( + "MQTTAlarmControlPanelComponent", "alarm_control_panel" ) -MQTTBinarySensorComponent = mqtt_ns.class_("MQTTBinarySensorComponent", MQTTComponent) -MQTTClimateComponent = mqtt_ns.class_("MQTTClimateComponent", MQTTComponent) -MQTTCoverComponent = mqtt_ns.class_("MQTTCoverComponent", MQTTComponent) -MQTTFanComponent = mqtt_ns.class_("MQTTFanComponent", MQTTComponent) -MQTTJSONLightComponent = mqtt_ns.class_("MQTTJSONLightComponent", MQTTComponent) -MQTTSensorComponent = mqtt_ns.class_("MQTTSensorComponent", MQTTComponent) -MQTTSwitchComponent = mqtt_ns.class_("MQTTSwitchComponent", MQTTComponent) -MQTTTextSensor = mqtt_ns.class_("MQTTTextSensor", MQTTComponent) -MQTTNumberComponent = mqtt_ns.class_("MQTTNumberComponent", MQTTComponent) -MQTTDateComponent = mqtt_ns.class_("MQTTDateComponent", MQTTComponent) -MQTTTimeComponent = mqtt_ns.class_("MQTTTimeComponent", MQTTComponent) -MQTTDateTimeComponent = mqtt_ns.class_("MQTTDateTimeComponent", MQTTComponent) -MQTTTextComponent = mqtt_ns.class_("MQTTTextComponent", MQTTComponent) -MQTTSelectComponent = mqtt_ns.class_("MQTTSelectComponent", MQTTComponent) -MQTTButtonComponent = mqtt_ns.class_("MQTTButtonComponent", MQTTComponent) -MQTTLockComponent = mqtt_ns.class_("MQTTLockComponent", MQTTComponent) -MQTTEventComponent = mqtt_ns.class_("MQTTEventComponent", MQTTComponent) -MQTTUpdateComponent = mqtt_ns.class_("MQTTUpdateComponent", MQTTComponent) -MQTTValveComponent = mqtt_ns.class_("MQTTValveComponent", MQTTComponent) +MQTTBinarySensorComponent = _entity_class("MQTTBinarySensorComponent") +MQTTClimateComponent = _entity_class( + "MQTTClimateComponent", + "climate", + ( + _single_topic("mode/command", CONF_MODE_COMMAND_TOPIC), + # One target temperature topic, or the _low and _high pair on two-point devices + ( + (("target_temperature/command", CONF_TARGET_TEMPERATURE_COMMAND_TOPIC),), + ( + ( + "target_temperature_low/command", + CONF_TARGET_TEMPERATURE_LOW_COMMAND_TOPIC, + ), + ( + "target_temperature_high/command", + CONF_TARGET_TEMPERATURE_HIGH_COMMAND_TOPIC, + ), + ), + ), + ), +) +MQTTCoverComponent = _entity_class("MQTTCoverComponent", "cover") +MQTTFanComponent = _entity_class("MQTTFanComponent", "fan") +MQTTJSONLightComponent = _entity_class("MQTTJSONLightComponent", "light") +MQTTSensorComponent = _entity_class("MQTTSensorComponent") +MQTTSwitchComponent = _entity_class("MQTTSwitchComponent", "switch") +MQTTTextSensor = _entity_class("MQTTTextSensor") +MQTTNumberComponent = _entity_class("MQTTNumberComponent", "number") +MQTTDateComponent = _entity_class("MQTTDateComponent", "date") +MQTTTimeComponent = _entity_class("MQTTTimeComponent", "time") +MQTTDateTimeComponent = _entity_class("MQTTDateTimeComponent", "datetime") +MQTTTextComponent = _entity_class("MQTTTextComponent", "text") +MQTTSelectComponent = _entity_class("MQTTSelectComponent", "select") +MQTTButtonComponent = _entity_class("MQTTButtonComponent", "button") +MQTTLockComponent = _entity_class("MQTTLockComponent", "lock") +MQTTEventComponent = _entity_class("MQTTEventComponent") +MQTTUpdateComponent = _entity_class("MQTTUpdateComponent", "update") +MQTTValveComponent = _entity_class("MQTTValveComponent", "valve") MQTTDiscoveryUniqueIdGenerator = mqtt_ns.enum("MQTTDiscoveryUniqueIdGenerator") MQTT_DISCOVERY_UNIQUE_ID_GENERATOR_OPTIONS = { @@ -398,6 +464,137 @@ CONFIG_SCHEMA = cv.All( network.require_ipv4(CONF_MQTT), ) +# AsyncMqttClient, the ESP8266 and LibreTiny backend, drops any inbound message whose +# topic is longer than this many bytes (its default maxTopicLength) +ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN = 128 +# Keep in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h +OBJECT_ID_MAX_LEN = 128 +# name_add_mac_suffix appends "-" plus 6 hex digits to the name at runtime +MAC_SUFFIX = "-xxxxxx" + + +def _entity_object_id( + config: ConfigType, mac_suffix: bool, devices: dict[str, str] +) -> str: + """Build the object id the way EntityBase::write_object_id_to() does.""" + # The name falls back the way EntityBase::configure_entity_() does + name = config.get(CONF_NAME) or "" + if not name: + if (device := config.get(CONF_DEVICE_ID)) is not None: + name = devices[device.id] + elif mac_suffix: + # Only the friendly name gains the MAC suffix, and an empty one stays empty + name = f"{CORE.friendly_name}{MAC_SUFFIX}" if CORE.friendly_name else "" + else: + name = CORE.friendly_name or CORE.name + # The firmware sanitizes each UTF-8 byte to one character and caps the result + raw = name.encode()[: OBJECT_ID_MAX_LEN - 1] + return sanitize(snake_case(raw.decode("ascii", "replace"))) + + +def _entity_topic( + config: ConfigType, default_topic: str, suffix: str, custom_key: str +) -> str | None: + """Return the topic an entity subscribes to, or None when only known at runtime.""" + custom = config.get(custom_key) + # An empty specialized topic falls back to the default (MQTT_COMPONENT_CUSTOM_TOPIC_) + # while an empty command_topic stays empty + if custom == "" and custom_key != CONF_COMMAND_TOPIC: + custom = None + if custom is not None: + # A lambda topic is only known at runtime + return custom if isinstance(custom, str) else None + return f"{default_topic}/{suffix}" if default_topic else None + + +def _entity_subscriptions( + config: ConfigType, topic_prefix: str, mac_suffix: bool, devices: dict[str, str] +) -> Iterator[tuple[tuple[str, ...], ...]]: + """Yield the subscriptions an entity's MQTT component always makes, as known topics.""" + component_type, subscriptions = _COMMAND_TOPICS[str(config[CONF_MQTT_ID].type)] + # Mirrors MQTTComponent::compute_is_internal_(): internal components never subscribe + if CONF_STATE_TOPIC in config: + internal = config[CONF_STATE_TOPIC] == "" + elif CONF_COMMAND_TOPIC in config: + internal = config[CONF_COMMAND_TOPIC] == "" + else: + internal = not topic_prefix or config.get(CONF_INTERNAL, False) + if internal: + return + default_topic = "" + if topic_prefix: + object_id = _entity_object_id(config, mac_suffix, devices) + default_topic = f"{topic_prefix}/{component_type}/{object_id}" + for subscription in subscriptions: + yield tuple( + tuple( + topic + for suffix, custom_key in alternative + if (topic := _entity_topic(config, default_topic, suffix, custom_key)) + is not None + ) + for alternative in subscription + ) + + +def _subscriptions(config: ConfigType) -> Iterator[tuple[tuple[str, ...], ...]]: + """Yield every subscription known at build time, as alternatives of topics.""" + full_config = fv.full_config.get() + esphome_config = full_config[CONF_ESPHOME] + mac_suffix = esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False) + devices = { + device[CONF_ID].id: device[CONF_NAME] + for device in esphome_config.get(CONF_DEVICES, []) + } + topic_prefix = config[CONF_TOPIC_PREFIX] + if mac_suffix and topic_prefix == CORE.name: + topic_prefix += MAC_SUFFIX + # Entities can sit at any depth (a platform can declare its own switches), so find them by mqtt_id + for id_, path in iter_ids(full_config): + if path[-1] != CONF_MQTT_ID or str(id_.type) not in _COMMAND_TOPICS: + continue + entity = full_config + for key in path[:-1]: + entity = entity[key] + yield from _entity_subscriptions(entity, topic_prefix, mac_suffix, devices) + for key in (CONF_ON_MESSAGE, CONF_ON_JSON_MESSAGE): + for trigger in config.get(key, []): + yield ((trigger[CONF_TOPIC],),) + for domain in ("sensor", "text_sensor"): + for entity in full_config.get(domain) or []: + if entity[CONF_PLATFORM] == "mqtt_subscribe": + yield ((entity[CONF_TOPIC],),) + + +def _too_long(topic: str) -> bool: + # A wildcard filter matches topics of any length, so only literals are checked + if "+" in topic or "#" in topic: + return False + return len(topic.encode()) > ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN + + +def _validate_topic_lengths(config: ConfigType) -> ConfigType: + """Reject topics that the ESP8266 and LibreTiny MQTT client would silently drop.""" + if not (CORE.is_esp8266 or CORE.is_libretiny): + return config + for subscription in _subscriptions(config): + # The config cannot tell which alternative the firmware picks, so only a + # subscription that is too long whichever way is rejected + too_long = [ + next((topic for topic in alternative if _too_long(topic)), None) + for alternative in subscription + ] + if all(too_long): + topic = too_long[0] + raise cv.Invalid( + f"MQTT topic '{topic}' is {len(topic.encode())} bytes; the MQTT client " + f"on this platform drops topics longer than {ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN}" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_topic_lengths + def exp_mqtt_message(config): if config is None: diff --git a/tests/unit_tests/components/test_mqtt_topic_length.py b/tests/unit_tests/components/test_mqtt_topic_length.py new file mode 100644 index 00000000000..fd823c469d0 --- /dev/null +++ b/tests/unit_tests/components/test_mqtt_topic_length.py @@ -0,0 +1,274 @@ +"""Tests for the mqtt check that subscribed topics fit the AsyncMqttClient limit.""" + +import pytest + +from esphome.components.mqtt import ( + ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN, + OBJECT_ID_MAX_LEN, + MQTTClimateComponent, + MQTTSwitchComponent, + _validate_topic_lengths, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICES, + CONF_ESPHOME, + CONF_ID, + CONF_MQTT_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +# "dev/switch//command" is 19 bytes around the object id +FITS = "a" * (ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN - 19) +TOO_LONG = FITS + "a" +LONG_LITERAL = "t" * (ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN + 1) +# Each byte of a two-byte character sanitizes to one underscore on the device +FITS_MULTIBYTE = "é" * ((ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN - 19) // 2) +TOO_LONG_MULTIBYTE = FITS_MULTIBYTE + "é" +LONG_MULTIBYTE_LITERAL = "é" * (ASYNC_MQTT_CLIENT_MAX_TOPIC_LEN // 2 + 1) + + +def _mqtt_id(cls: MockObjClass) -> ID: + return ID("mqtt_id", is_declaration=True, type=cls) + + +def _switch(name: str, **extra) -> dict: + return { + "switch": [ + {CONF_NAME: name, CONF_MQTT_ID: _mqtt_id(MQTTSwitchComponent), **extra} + ] + } + + +def _run( + monkeypatch: pytest.MonkeyPatch, + full_config: dict, + mqtt: ConfigType | None = None, + platform: str = PLATFORM_ESP8266, +) -> ConfigType: + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) + monkeypatch.setattr(CORE, "name", "dev") + monkeypatch.setattr(CORE, "friendly_name", None) + config = {"topic_prefix": "dev", **(mqtt or {})} + token = fv.full_config.set({CONF_ESPHOME: {}, **full_config}) + try: + return _validate_topic_lengths(config) + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize( + ("full_config", "mqtt", "platform"), + [ + pytest.param(_switch(TOO_LONG), None, PLATFORM_ESP8266, id="command_topic"), + pytest.param(_switch(TOO_LONG), None, PLATFORM_BK72XX, id="libretiny"), + pytest.param( + {CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, **_switch(FITS)}, + None, + PLATFORM_ESP8266, + id="mac_suffix", + ), + pytest.param( + _switch("s", command_topic=LONG_LITERAL), + None, + PLATFORM_ESP8266, + id="custom_command_topic", + ), + pytest.param( + _switch(TOO_LONG_MULTIBYTE), None, PLATFORM_ESP8266, id="multibyte_name" + ), + pytest.param( + _switch("s", command_topic=LONG_MULTIBYTE_LITERAL), + None, + PLATFORM_ESP8266, + id="multibyte_literal", + ), + pytest.param( + { + "climate": [ + # "dev/climate//mode/command" is 25 characters around it + {CONF_NAME: "a" * 104, CONF_MQTT_ID: _mqtt_id(MQTTClimateComponent)} + ] + }, + None, + PLATFORM_ESP8266, + id="climate_mode_command", + ), + pytest.param( + # An empty specialized topic means the default, unlike an empty command_topic + { + "climate": [ + { + CONF_NAME: "a" * 104, + CONF_MQTT_ID: _mqtt_id(MQTTClimateComponent), + "mode_command_topic": "", + "target_temperature_command_topic": "short/temp", + } + ] + }, + None, + PLATFORM_ESP8266, + id="climate_empty_mode_command_topic", + ), + pytest.param( + {}, + {"on_message": [{"topic": LONG_LITERAL}]}, + PLATFORM_ESP8266, + id="trigger", + ), + pytest.param( + {"sensor": [{"platform": "mqtt_subscribe", "topic": LONG_LITERAL}]}, + None, + PLATFORM_ESP8266, + id="mqtt_subscribe", + ), + pytest.param( + { + CONF_ESPHOME: { + CONF_DEVICES: [{CONF_ID: ID("sub"), CONF_NAME: TOO_LONG}] + }, + **_switch("", device_id=ID("sub")), + }, + None, + PLATFORM_ESP8266, + id="unnamed_on_sub_device", + ), + pytest.param( + _switch("s", command_topic=LONG_LITERAL), + {"topic_prefix": ""}, + PLATFORM_ESP8266, + id="custom_topic_without_prefix", + ), + pytest.param( + # A platform that declares its own switches, like sprinkler valves + {"sprinkler": [{"valves": [_switch(TOO_LONG)["switch"][0]]}]}, + None, + PLATFORM_ESP8266, + id="nested_entity", + ), + pytest.param( + # "dev/climate//target_temperature/command" is 39 around it, + # while mode/command still fits + { + "climate": [ + {CONF_NAME: "a" * 90, CONF_MQTT_ID: _mqtt_id(MQTTClimateComponent)} + ] + }, + None, + PLATFORM_ESP8266, + id="climate_target_temperature", + ), + ], +) +def test_rejected( + monkeypatch: pytest.MonkeyPatch, full_config: dict, mqtt: dict | None, platform: str +) -> None: + with pytest.raises(cv.Invalid, match="bytes"): + _run(monkeypatch, full_config, mqtt, platform) + + +@pytest.mark.parametrize( + ("full_config", "mqtt", "platform"), + [ + pytest.param(_switch(FITS), None, PLATFORM_ESP8266, id="at_the_limit"), + pytest.param( + _switch(FITS_MULTIBYTE), None, PLATFORM_ESP8266, id="multibyte_at_the_limit" + ), + pytest.param( + _switch("s", state_topic="dev/s", command_topic=""), + None, + PLATFORM_ESP8266, + id="empty_command_topic_stays_empty", + ), + pytest.param( + # The firmware subscribes to the short _low and _high overrides on a two-point + # device, so the unused default target_temperature topic must not count + { + "climate": [ + { + CONF_NAME: "a" * 90, + CONF_MQTT_ID: _mqtt_id(MQTTClimateComponent), + "target_temperature_low_command_topic": "short/low", + "target_temperature_high_command_topic": "short/high", + } + ] + }, + None, + PLATFORM_ESP8266, + id="climate_two_point_overrides", + ), + pytest.param(_switch(TOO_LONG), None, PLATFORM_ESP32, id="esp32"), + pytest.param( + _switch(TOO_LONG, command_topic="short/cmd"), + None, + PLATFORM_ESP8266, + id="short_custom_topic", + ), + pytest.param( + _switch(TOO_LONG, command_topic=Lambda('return "x";')), + None, + PLATFORM_ESP8266, + id="lambda_topic", + ), + pytest.param( + _switch(TOO_LONG, internal=True), None, PLATFORM_ESP8266, id="internal" + ), + pytest.param( + {"switch": [{CONF_NAME: TOO_LONG}]}, None, PLATFORM_ESP8266, id="no_mqtt" + ), + pytest.param( + _switch("s", state_topic="", command_topic=LONG_LITERAL), + None, + PLATFORM_ESP8266, + id="internal_by_empty_state_topic", + ), + pytest.param( + # The MAC suffix goes on the friendly name, never on a sub-device's name + { + CONF_ESPHOME: { + CONF_NAME_ADD_MAC_SUFFIX: True, + CONF_DEVICES: [{CONF_ID: ID("sub"), CONF_NAME: FITS}], + }, + **_switch("", device_id=ID("sub")), + }, + {"topic_prefix": "abc"}, + PLATFORM_ESP8266, + id="sub_device_has_no_mac_suffix", + ), + pytest.param( + {}, + {"on_message": [{"topic": LONG_LITERAL + "/#"}]}, + PLATFORM_ESP8266, + id="wildcard", + ), + ], +) +def test_accepted( + monkeypatch: pytest.MonkeyPatch, full_config: dict, mqtt: dict | None, platform: str +) -> None: + _run(monkeypatch, full_config, mqtt, platform) + + +def test_message_names_the_topic_and_length(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises( + cv.Invalid, match=rf"'dev/switch/{TOO_LONG}/command' is 129 bytes" + ): + _run(monkeypatch, _switch(TOO_LONG)) + + +def test_object_id_is_capped_like_the_firmware(monkeypatch: pytest.MonkeyPatch) -> None: + capped = "a" * (OBJECT_ID_MAX_LEN - 1) + with pytest.raises( + cv.Invalid, match=rf"'dev/switch/{capped}/command' is 146 bytes" + ): + _run(monkeypatch, _switch("a" * 300)) From c2911a51bd60f45b6cef72c27f8d878e4981b2d8 Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:19:12 +1000 Subject: [PATCH 21/37] [tas58xx] Add an activate switch (#21458) Co-authored-by: Claude Opus 5.5 --- esphome/components/tas58xx/switch/__init__.py | 41 ++++++++++++++ esphome/components/tas58xx/tas58xx.cpp | 21 +++++++- esphome/components/tas58xx/tas58xx.h | 16 ++++++ .../tas58xx/test_tas58xx_activate_switch.py | 53 +++++++++++++++++++ .../tas58xx/test_tas58xx_activate_switch.yaml | 27 ++++++++++ tests/components/tas58xx/common.yaml | 7 +++ 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 esphome/components/tas58xx/switch/__init__.py create mode 100644 tests/component_tests/tas58xx/test_tas58xx_activate_switch.py create mode 100644 tests/component_tests/tas58xx/test_tas58xx_activate_switch.yaml diff --git a/esphome/components/tas58xx/switch/__init__.py b/esphome/components/tas58xx/switch/__init__.py new file mode 100644 index 00000000000..80d50335e93 --- /dev/null +++ b/esphome/components/tas58xx/switch/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INITIAL_STATE, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + ENTITY_CATEGORY_CONFIG, +) +from esphome.types import ConfigType + +from ..audio_dac import CONF_TAS58XX_ID, TAS58xx + +CONF_ACTIVATE = "activate" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TAS58XX_ID): cv.use_id(TAS58xx), + # On activates the amplifier (play), off puts it into deep sleep + cv.Optional(CONF_ACTIVATE): switch.switch_schema( + switch.ApplySwitch, + default_restore_mode="DISABLED", + entity_category=ENTITY_CATEGORY_CONFIG, + ).extend( + { + # The switch shows the state the amplifier publishes, so the restore options have no effect + cv.Optional(key): cv.invalid( + "Not supported: the activate switch shows the amplifier's state, which is ON after every boot" + ) + for key in (CONF_RESTORE_MODE, CONF_RESTORE_STATE, CONF_INITIAL_STATE) + } + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TAS58XX_ID]) + switches = switch.sub_switches(config, parent=hub) + # The amplifier publishes the state (see TAS58xx::publish_active_()), so the switch does not publish it + await switches(CONF_ACTIVATE, hub.set_activate_switch, apply="set_active(state)") diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 28d0ce22bdb..bca694d97eb 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -70,7 +70,9 @@ void TAS58xx::setup() { } if (!this->init_()) { this->mark_failed(); + return; } + this->publish_active_(true); // init_() leaves the device in play } bool TAS58xx::select_book_page_(uint8_t book, uint8_t page) { @@ -136,7 +138,8 @@ void TAS58xx::activate() { this->write_ctrl_state_(TAS58XX_CTRL_STATE_HIZ, muted))) { return; } - this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, muted); + if (this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, muted)) + this->publish_active_(true); } void TAS58xx::deactivate() { @@ -148,7 +151,18 @@ void TAS58xx::deactivate() { return; } // The DSP, and with it the mixer, stays active in deep sleep - this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, this->is_muted_); + if (this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, this->is_muted_)) + this->publish_active_(false); +} + +// The activate switch shows whether the device is active (play) or in deep sleep. The state is published here, not +// by the switch, so it also follows the tas58xx.activate and tas58xx.deactivate actions, and only after the write +// to the device succeeded. +void TAS58xx::publish_active_(bool active) { +#ifdef USE_SWITCH + if (this->activate_switch_ != nullptr) + this->activate_switch_->publish_state(active); +#endif } bool TAS58xx::write_ctrl_state_(uint8_t state, bool muted) { @@ -273,6 +287,9 @@ void TAS58xx::dump_config() { LOG_BINARY_SENSOR(" ", "Fault", sensor); } #endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Activate", this->activate_switch_); +#endif } bool TAS58xx::set_mute_(bool muted) { diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h index d0f97a5cd9f..1d28a322922 100644 --- a/esphome/components/tas58xx/tas58xx.h +++ b/esphome/components/tas58xx/tas58xx.h @@ -11,6 +11,9 @@ #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif namespace esphome::tas58xx { @@ -134,8 +137,21 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c: this->fault_binary_sensors_[fault] = sensor; } #endif +#ifdef USE_SWITCH + SUB_SWITCH(activate) + /// The activate switch: on activates the amplifier (play), off puts it into deep sleep + void set_active(bool active) { + if (active) { + this->activate(); + } else { + this->deactivate(); + } + } +#endif protected: + /// Publish the activate switch state, if there is one + void publish_active_(bool active); bool select_book_page_(uint8_t book, uint8_t page); bool init_(); bool write_ctrl_state_(uint8_t state, bool muted); diff --git a/tests/component_tests/tas58xx/test_tas58xx_activate_switch.py b/tests/component_tests/tas58xx/test_tas58xx_activate_switch.py new file mode 100644 index 00000000000..cdd94b0c89c --- /dev/null +++ b/tests/component_tests/tas58xx/test_tas58xx_activate_switch.py @@ -0,0 +1,53 @@ +"""Tests for the TAS58xx activate switch.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.tas58xx.switch import CONFIG_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_activate_switch_is_given_to_its_amplifier_only( + generate_main: Callable[[str | Path], str], +) -> None: + """The switch's amplifier publishes to it; an amplifier without a switch gets none.""" + main_cpp = generate_main( + "tests/component_tests/tas58xx/test_tas58xx_activate_switch.yaml" + ) + + # Turning the switch on or off calls its amplifier, which publishes the state itself + assert "::amp_with_switch->set_active(state);" in main_cpp + assert "entity->publish_state(state);" not in main_cpp + assert "amp_with_switch->set_activate_switch(activate);" in main_cpp + assert "amp_without_switch->set_activate_switch(" not in main_cpp + # The switch shows the state the amplifier publishes, so its restore mode is disabled + assert "activate->set_restore_mode(switch_::SWITCH_RESTORE_DISABLED);" in main_cpp + # A configuration entity by default, as the pcm5122 switch + assert "category:config" in main_cpp + + +@pytest.mark.parametrize( + "option", + [ + {"restore_mode": "ALWAYS_ON"}, + {"restore_mode": "RESTORE_DEFAULT_OFF"}, + {"restore_state": {"state": "KEEP"}}, + {"initial_state": "OFF"}, + ], +) +def test_activate_switch_rejects_restore_options( + set_core_config: SetCoreConfigCallable, option: dict +) -> None: + """The switch shows the amplifier's state, so the restore options have no effect and are rejected.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises( + cv.Invalid, match="the activate switch shows the amplifier's state" + ): + cv.Schema(CONFIG_SCHEMA)({"activate": {"name": "Amplifier", **option}}) diff --git a/tests/component_tests/tas58xx/test_tas58xx_activate_switch.yaml b/tests/component_tests/tas58xx/test_tas58xx_activate_switch.yaml new file mode 100644 index 00000000000..2a8fbb205d4 --- /dev/null +++ b/tests/component_tests/tas58xx/test_tas58xx_activate_switch.yaml @@ -0,0 +1,27 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + id: i2c_bus + sda: 21 + scl: 22 + +audio_dac: + - platform: tas58xx + model: tas5805m + id: amp_with_switch + ignore_enable_pin_warning: true + - platform: tas58xx + model: tas5825m + id: amp_without_switch + ignore_enable_pin_warning: true + +switch: + - platform: tas58xx + tas58xx_id: amp_with_switch + activate: + id: activate + name: Amplifier diff --git a/tests/components/tas58xx/common.yaml b/tests/components/tas58xx/common.yaml index afe38fef21f..60c0ba734a1 100644 --- a/tests/components/tas58xx/common.yaml +++ b/tests/components/tas58xx/common.yaml @@ -72,3 +72,10 @@ binary_sensor: name: Over Temperature 146C Warning over_temp_warning: name: Over Temperature 134C Warning + +# tas58xx_amp_2 has no switch, so its activate() and deactivate() publish nothing +switch: + - platform: tas58xx + tas58xx_id: tas58xx_amp + activate: + name: Amplifier From 1546ff022c759615e61e193df41f4f4373d30fb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 15:59:18 -1000 Subject: [PATCH 22/37] [gpio_expander] Add a shared component base for cached expanders with an interrupt pin (#21468) --- esphome/components/gpio_expander/__init__.py | 6 ++ .../gpio_expander/cached_gpio_component.cpp | 29 +++++ .../gpio_expander/cached_gpio_component.h | 50 +++++++++ .../test_cached_gpio_component.cpp | 102 ++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 esphome/components/gpio_expander/cached_gpio_component.cpp create mode 100644 esphome/components/gpio_expander/cached_gpio_component.h create mode 100644 tests/components/gpio_expander/test_cached_gpio_component.cpp diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index cf6be6e8e0e..389cd4e49b9 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -22,3 +22,9 @@ def validate_interrupt_pin(value: ConfigType) -> ConfigType: f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." ) return value + + +# Schema of a CachedGpioExpanderComponent; to_code sets the pin with cg.add_pin(). +INTERRUPT_EXPANDER_SCHEMA = cv.COMPONENT_SCHEMA.extend( + {cv.Optional(CONF_INTERRUPT_PIN): validate_interrupt_pin} +) diff --git a/esphome/components/gpio_expander/cached_gpio_component.cpp b/esphome/components/gpio_expander/cached_gpio_component.cpp new file mode 100644 index 00000000000..7f32ce07d67 --- /dev/null +++ b/esphome/components/gpio_expander/cached_gpio_component.cpp @@ -0,0 +1,29 @@ +#include "cached_gpio_component.h" + +namespace esphome::gpio_expander { + +// Not a member of the template: GCC 10 (ESP8266) ignores section attributes on template instantiations. +static void IRAM_ATTR interrupt_pin_isr(Component *arg) { arg->enable_loop_soon_any_context(); } + +bool InterruptExpanderComponent::attach_interrupt_pin_() { + // Polling mode enables the loop in pin_mode() for the first input; interrupt mode from the ISR. + this->disable_loop(); + if (this->interrupt_pin_ == nullptr) + return false; + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&interrupt_pin_isr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); + // An INT line already low at attach gives no falling edge, so read the inputs now + if (!this->interrupt_pin_->digital_read()) + this->enable_loop(); + return true; +} + +// Input changes that straddle the read leave INT asserted without a new falling edge, so keep looping +// until the line is released or the expander would keep stale state forever. +void InterruptExpanderComponent::disable_loop_when_released_() { + if (this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +} // namespace esphome::gpio_expander diff --git a/esphome/components/gpio_expander/cached_gpio_component.h b/esphome/components/gpio_expander/cached_gpio_component.h new file mode 100644 index 00000000000..1d748c9bbeb --- /dev/null +++ b/esphome/components/gpio_expander/cached_gpio_component.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::gpio_expander { + +/// Component side of a cached GPIO expander with an optional active-low INT line. Not a template, so the +/// ISR and the shared code are emitted once for every expander. +class InterruptExpanderComponent : public Component { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + float get_setup_priority() const override { return setup_priority::IO; } + + protected: + /// Attach the falling-edge ISR when there is an interrupt pin, then disable the loop until an input + /// needs it. Returns whether the interrupt pin is in use. + bool attach_interrupt_pin_(); + /// The loop runs again after the INT line falls; stop it once the line is released (HIGH). + /// Only called with an interrupt pin. + void disable_loop_when_released_(); + /// Call from pin_mode() for an input pin: without an interrupt pin the loop polls instead. + void enable_polling_loop_() { + if (this->interrupt_pin_ == nullptr) + this->enable_loop(); + } + + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +/// Base for expanders that cache reads per loop. With an interrupt pin the cache stays valid until INT fires. +template +class CachedGpioExpanderComponent : public InterruptExpanderComponent, public CachedGpioExpander { + public: + void loop() override { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) + this->disable_loop_when_released_(); + } + + protected: + /// Call at the end of setup(). + void setup_interrupt_pin_() { + if (this->attach_interrupt_pin_()) + this->set_invalidate_on_read_(false); + } +}; + +} // namespace esphome::gpio_expander diff --git a/tests/components/gpio_expander/test_cached_gpio_component.cpp b/tests/components/gpio_expander/test_cached_gpio_component.cpp new file mode 100644 index 00000000000..5f357e0730e --- /dev/null +++ b/tests/components/gpio_expander/test_cached_gpio_component.cpp @@ -0,0 +1,102 @@ +#include + +#include "esphome/components/gpio_expander/cached_gpio_component.h" + +namespace esphome::gpio_expander::testing { + +class FakeComponent : public CachedGpioExpanderComponent { + public: + void setup() override { this->setup_interrupt_pin_(); } + void pin_mode(uint8_t pin, gpio::Flags flags) { + if (flags & gpio::FLAG_INPUT) + this->enable_polling_loop_(); + } + + unsigned reads{0}; + + protected: + bool digital_read_hw(uint8_t pin) override { + this->reads++; + return true; + } + bool digital_read_cache(uint8_t pin) override { return false; } + void digital_write_hw(uint8_t pin, bool value) override {} +}; + +class CachedGpioComponentTest : public ::testing::Test { + protected: + void SetUp() override { this->int_pin_.set_inverted(true); } // INT released + + FakeComponent component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(CachedGpioComponentTest, InputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_TRUE(this->component_.is_idle()); + this->component_.pin_mode(0, gpio::FLAG_INPUT); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + FakeComponent with_interrupt; + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + with_interrupt.pin_mode(0, gpio::FLAG_INPUT); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +TEST_F(CachedGpioComponentTest, WithoutAnInterruptPinEveryReadHitsTheChip) { + this->component_.setup(); + this->component_.digital_read(0); + this->component_.digital_read(0); + EXPECT_EQ(this->component_.reads, 2u); +} + +TEST_F(CachedGpioComponentTest, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.digital_read(0); + this->component_.digital_read(0); + EXPECT_EQ(this->component_.reads, 1u); + + this->component_.loop(); + this->component_.digital_read(0); + EXPECT_EQ(this->component_.reads, 2u); +} + +TEST_F(CachedGpioComponentTest, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +TEST_F(CachedGpioComponentTest, IntAlreadyLowAtSetupRunsTheLoop) { + this->int_pin_.set_inverted(false); // INT asserted before the ISR is attached + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +TEST_F(CachedGpioComponentTest, PollingLoopNeverDisablesItself) { + this->component_.setup(); + this->component_.pin_mode(0, gpio::FLAG_INPUT); + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); +} + +} // namespace esphome::gpio_expander::testing From 7c69d1b63f3f496db6a34d66be037752d6f7578d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 15:59:18 -1000 Subject: [PATCH 23/37] [gpio_expander] Move eight expanders onto the shared interrupt and loop base (#21469) --- esphome/components/i2c/i2c.h | 3 +- esphome/components/mcp23016/__init__.py | 3 +- esphome/components/mcp23016/mcp23016.cpp | 23 +----------- esphome/components/mcp23016/mcp23016.h | 11 +----- .../mcp23x08_base/mcp23x08_base.cpp | 14 +++---- .../mcp23x17_base/mcp23x17_base.cpp | 14 +++---- esphome/components/mcp23xxx_base/__init__.py | 3 +- .../mcp23xxx_base/mcp23xxx_base.cpp | 2 - .../components/mcp23xxx_base/mcp23xxx_base.h | 37 +------------------ esphome/components/pca6416a/__init__.py | 3 +- esphome/components/pca6416a/pca6416a.cpp | 29 ++------------- esphome/components/pca6416a/pca6416a.h | 13 +------ esphome/components/pca9505/__init__.py | 3 +- esphome/components/pca9505/pca9505.cpp | 31 ++-------------- esphome/components/pca9505/pca9505.h | 15 ++------ esphome/components/pca9554/__init__.py | 6 +-- esphome/components/pca9554/pca9554.cpp | 32 +--------------- esphome/components/pca9554/pca9554.h | 13 +------ esphome/components/pcf8574/__init__.py | 3 +- esphome/components/pcf8574/pcf8574.cpp | 29 +-------------- esphome/components/pcf8574/pcf8574.h | 13 +------ esphome/components/pi4ioe5v6408/__init__.py | 3 +- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 32 +--------------- .../components/pi4ioe5v6408/pi4ioe5v6408.h | 13 ++----- esphome/components/tca9555/__init__.py | 3 +- esphome/components/tca9555/tca9555.cpp | 23 +----------- esphome/components/tca9555/tca9555.h | 15 +------- tests/components/pca9505/test_pca9505.cpp | 1 + 28 files changed, 56 insertions(+), 334 deletions(-) diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 1c3137c6d5f..f0d15c6aeca 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -322,8 +322,9 @@ class I2CDevice { bool update_register(uint8_t a_register, uint8_t mask, uint8_t value); protected: + I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance + // Last so a derived class can place its byte-sized members in the padding after it uint8_t address_{0x00}; ///< store the address of the device on the bus - I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance }; } // namespace esphome::i2c diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index d33b1e43ae4..8f569a2fc9f 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -20,10 +20,9 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x20)) ) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 2e49e4b1616..baa895f92b3 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -25,25 +25,9 @@ void MCP23016::setup() { // all pins input this->write_reg_(MCP23016_IODIR1, 0xFFFF); - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - this->set_invalidate_on_read_(false); - } - this->disable_loop(); + this->setup_interrupt_pin_(); } -void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); } -void MCP23016::loop() { - // Invalidate cache at the start of each loop - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } -} bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } bool MCP23016::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } @@ -51,14 +35,11 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { this->update_reg_(pin, true, MCP23016_IODIR1); - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg_(pin, false, MCP23016_IODIR1); } } -float MCP23016::get_setup_priority() const { return setup_priority::IO; } bool MCP23016::read_reg_(uint8_t reg, uint16_t *value) { if (this->is_failed()) return false; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 6c7eee5c3b9..1c2d270f916 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::mcp23016 { @@ -25,20 +25,14 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { +class MCP23016 final : public gpio_expander::CachedGpioExpanderComponent, public i2c::I2CDevice { public: MCP23016() = default; void setup() override; - void loop() override; void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } - protected: - static void gpio_intr(MCP23016 *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -54,7 +48,6 @@ class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expa uint16_t olat_{0x0000}; // Cache for input values (16-bit combined for both banks) uint16_t input_mask_{0x0000}; - InternalGPIOPin *interrupt_pin_{nullptr}; }; using MCP23016GPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 01d51aa8962..4eb4946b93e 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -32,15 +32,11 @@ void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } - // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins - // so the chip's INT output fires on any input state change - if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { - this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); - } - // Enable polling loop for input pins (not needed for interrupt-driven mode - // where the ISR handles re-enabling loop) - if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { - this->enable_loop(); + if (flags & gpio::FLAG_INPUT) { + // With an interrupt pin, INT fires on any change of an input + if (this->interrupt_pin_ != nullptr) + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + this->enable_polling_loop_(); } } diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 143a42d10a8..1e430eb002c 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -43,15 +43,11 @@ void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } - // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins - // so the chip's INT output fires on any input state change - if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { - this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); - } - // Enable polling loop for input pins (not needed for interrupt-driven mode - // where the ISR handles re-enabling loop) - if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { - this->enable_loop(); + if (flags & gpio::FLAG_INPUT) { + // With an interrupt pin, INT fires on any change of an input + if (this->interrupt_pin_ != nullptr) + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + this->enable_polling_loop_(); } } diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 5f018412b66..f54d35c7615 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -32,9 +32,8 @@ MCP23XXX_INTERRUPT_MODES = MCP23XXXInterruptMode.options( MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } -).extend(cv.COMPONENT_SCHEMA) +).extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) @coroutine diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 8ad2f7521e5..b8032fcec8c 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,8 +4,6 @@ namespace esphome::mcp23xxx_base { -void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } - template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index bdbfbddf9bd..d3a1944ae86 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -8,47 +8,15 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; -// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the -// ISR in flash. -void mcp23xxx_gpio_intr(Component *arg); - -template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { +template class MCP23XXXBase : public gpio_expander::CachedGpioExpanderComponent { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); virtual void pin_interrupt_mode(uint8_t pin, MCP23XXXInterruptMode interrupt_mode); void set_open_drain_ints(const bool value) { this->open_drain_ints_ = value; } - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } InternalGPIOPin *get_interrupt_pin() const { return this->interrupt_pin_; } - float get_setup_priority() const override { return setup_priority::IO; } - - void loop() override { - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } - } protected: - // No need to clear latched interrupts before attaching the ISR — if INT is - // already low the ISR fires immediately, loop runs, cache invalidates, and - // the GPIO read clears the latch. One harmless extra read at most. - void setup_interrupt_pin_() { - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), - gpio::INTERRUPT_FALLING_EDGE); - this->set_invalidate_on_read_(false); - } - // Disable loop until an input pin is configured via pin_mode() - // For interrupt-driven mode, loop is re-enabled by the ISR - // For polling mode, loop is re-enabled when pin_mode() registers an input pin - this->disable_loop(); - } - // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; // write a value to a given register @@ -57,7 +25,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: virtual void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) = 0; bool open_drain_ints_; - InternalGPIOPin *interrupt_pin_{nullptr}; }; template class MCP23XXXGPIOPin final : public GPIOPin { diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 92fb8f8eeed..ffbaf4b52fc 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -29,10 +29,9 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 8df002bcb65..b8ce216ba22 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -50,24 +50,7 @@ void PCA6416AComponent::setup() { ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - this->set_invalidate_on_read_(false); - } - this->disable_loop(); -} - -void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); } -void PCA6416AComponent::loop() { - // Invalidate cache at the start of each loop - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } + this->setup_interrupt_pin_(); } void PCA6416AComponent::dump_config() { @@ -113,9 +96,6 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { this->update_register_(pin, true, pull_dir); this->update_register_(pin, false, pull_en); } - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { this->update_register_(pin, true, io_dir); if (has_pullup_) { @@ -124,12 +104,11 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { } else { ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors"); } - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } } else if (flags == gpio::FLAG_OUTPUT) { this->update_register_(pin, false, io_dir); } + if (flags & gpio::FLAG_INPUT) + this->enable_polling_loop_(); } bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { @@ -182,8 +161,6 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re } } -float PCA6416AComponent::get_setup_priority() const { return setup_priority::IO; } - } // namespace esphome::pca6416a ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca6416a::PCA6416AComponent, "%u via PCA6416A") diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 37aa3286fe9..eef63fe2506 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -3,31 +3,23 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca6416a { -class PCA6416AComponent final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA6416AComponent final : public gpio_expander::CachedGpioExpanderComponent, public i2c::I2CDevice { public: PCA6416AComponent() = default; /// Check i2c availability and setup masks void setup() override; - void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - void dump_config() override; - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } - protected: - static void gpio_intr(PCA6416AComponent *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -46,7 +38,6 @@ class PCA6416AComponent final : public Component, esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; - InternalGPIOPin *interrupt_pin_{nullptr}; }; using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index f1c34b1eaad..182d23cff89 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -24,10 +24,9 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA9505Component), - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x20)) ) diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index e90346baff6..56d60f5b313 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -38,28 +38,7 @@ void PCA9505Component::setup() { } this->write_all_banks_(MASK_REG, 0xFF); - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - // Don't invalidate cache on read - only invalidate when interrupt fires - this->set_invalidate_on_read_(false); - } - - // Nothing to poll until pin_mode() registers an input pin. - this->disable_loop(); -} - -void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } - -void PCA9505Component::loop() { - // Invalidate the cache so the next digital_read() triggers a fresh I2C read - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } + this->setup_interrupt_pin_(); } void PCA9505Component::dump_config() { @@ -84,11 +63,9 @@ void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { const uint8_t mask = 1 << (pin % 8); if (flags & gpio::FLAG_INPUT) { this->config_[bank] &= ~mask; - // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + // An input may raise INT. this->mask_[bank] &= ~mask; - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } else if (flags & gpio::FLAG_OUTPUT) { this->config_[bank] |= mask; // An output must never raise INT. @@ -130,8 +107,6 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { LOG_STR("Failed to write register")); } -float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } - } // namespace esphome::pca9505 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9505::PCA9505Component, "%u via PCA9505") diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index 1ccf86df3c2..da9c874078e 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" @@ -14,23 +14,15 @@ static constexpr uint8_t PIN_COUNT = 40; /// The pins are grouped into five ports of eight, each with its own set of registers. static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; -class PCA9505Component final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9505Component final : public gpio_expander::CachedGpioExpanderComponent, + public i2c::I2CDevice { public: - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } - void setup() override; - void loop() override; void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - void dump_config() override; protected: - static void gpio_intr(PCA9505Component *arg); - bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -47,7 +39,6 @@ class PCA9505Component final : public Component, uint8_t input_[BANK_COUNT]{}; /// Which pins may pull INT low - 1 means masked, matching the chip uint8_t mask_[BANK_COUNT]{}; - InternalGPIOPin *interrupt_pin_{nullptr}; }; using PCA9505GPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 56d7842fb10..4b66abae0c4 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -2,9 +2,8 @@ from typing import NamedTuple from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c from esphome.components.const import CONF_DRIVE_STRENGTH -from esphome.components.gpio_expander import validate_interrupt_pin import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -90,9 +89,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ADDRESS): cv.i2c_address, cv.Optional(CONF_DEVICE): cv.one_of(*DEVICES, upper=True), cv.Optional(CONF_PIN_COUNT): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): validate_interrupt_pin, } - ).extend(cv.COMPONENT_SCHEMA), + ).extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA), _validate_hub, ) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index a2d1cd5ae3f..e53a95f83cc 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -52,29 +52,7 @@ void PCA9554Component::setup() { ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&PCA9554Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - // Don't invalidate cache on read — only invalidate when interrupt fires - this->set_invalidate_on_read_(false); - } - // Disable loop until an input pin is configured via pin_mode() - // For interrupt-driven mode, loop is re-enabled by the ISR - // For polling mode, loop is re-enabled when pin_mode() registers an input pin - this->disable_loop(); -} - -void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); } - -void PCA9554Component::loop() { - // Invalidate the cache so the next digital_read() triggers a fresh I2C read - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } + this->setup_interrupt_pin_(); } void PCA9554Component::dump_config() { @@ -113,11 +91,7 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags & gpio::FLAG_INPUT) { // Clear mode mask bit this->config_mask_ &= ~(1 << pin); - // Enable polling loop for input pins (not needed for interrupt-driven mode - // where the ISR handles re-enabling loop) - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } else if (flags & gpio::FLAG_OUTPUT) { // Set mode mask bit this->config_mask_ |= 1 << pin; @@ -204,8 +178,6 @@ void PCA9554Component::update_pcal_register_(uint8_t reg, uint16_t &shadow, uint } #endif -float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } - } // namespace esphome::pca9554 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9554::PCA9554Component, "%u via PCA9554") diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index b3efdab819f..77ba6399c13 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -3,28 +3,22 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca9554 { -class PCA9554Component final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9554Component final : public gpio_expander::CachedGpioExpanderComponent, public i2c::I2CDevice { public: explicit PCA9554Component(uint8_t pin_count) : pin_count_(pin_count), reg_width_((pin_count + 7) / 8) {} /// Check i2c availability and setup masks void setup() override; - void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - void dump_config() override; - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } #ifdef USE_PCA9554_PCAL /// The PCAL parts add pull resistors, input latches, an interrupt mask and drive strength. void set_pcal() { this->pcal_ = true; } @@ -38,8 +32,6 @@ class PCA9554Component final : public Component, #endif protected: - static void gpio_intr(PCA9554Component *arg); - bool read_inputs_(); bool write_register_(uint8_t reg, uint16_t value) { return this->write_address_(reg * this->reg_width_, value); } bool write_address_(uint8_t address, uint16_t value); @@ -61,7 +53,6 @@ class PCA9554Component final : public Component, bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - InternalGPIOPin *interrupt_pin_{nullptr}; #ifdef USE_PCA9554_PCAL /// Drive strength shadow, two bits per pin; the low reg_width_ bytes go to OUTPUT_DRIVE_0 uint32_t drive_{0xFFFFFFFF}; diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 21e9e7b007e..14f7ab15b35 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -36,10 +36,9 @@ CONFIG_SCHEMA = ( cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 5381961e659..f4b8d0b847d 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -21,27 +21,7 @@ void PCF8574Component::setup() { this->mode_mask_ = ~this->configured_input_pins_; } - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&PCF8574Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - // Don't invalidate cache on read — only invalidate when interrupt fires - this->set_invalidate_on_read_(false); - } - // Disable loop until an input pin is configured via pin_mode() - // For interrupt-driven mode, loop is re-enabled by the ISR - // For polling mode, loop is re-enabled when pin_mode() registers an input pin - this->disable_loop(); -} -void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_loop_soon_any_context(); } -void PCF8574Component::loop() { - // Invalidate the cache so the next digital_read() triggers a fresh I2C read - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } + this->setup_interrupt_pin_(); } void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, @@ -69,11 +49,7 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->mode_mask_ &= ~(1 << pin); // Write GPIO to enable input mode this->write_gpio_(); - // Enable polling loop for input pins (not needed for interrupt-driven mode - // where the ISR handles re-enabling loop) - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } else if (flags == gpio::FLAG_OUTPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; @@ -103,7 +79,6 @@ bool PCF8574Component::write_gpio_() { data[1] = value >> 8; return this->status_comm_result(this->write(data, this->pcf8575_ ? 2 : 1) == i2c::ERROR_OK); } -float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } } // namespace esphome::pcf8574 diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 593b2db5534..ae2e3445fe9 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -3,16 +3,14 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together -class PCF8574Component final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCF8574Component final : public gpio_expander::CachedGpioExpanderComponent, public i2c::I2CDevice { public: PCF8574Component() = default; @@ -20,21 +18,15 @@ class PCF8574Component final : public Component, void set_reset(bool reset) { this->reset_ = reset; } // Called by pin setters before any component's setup or GPIO writes. void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks void setup() override; - void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - void dump_config() override; protected: - static void gpio_intr(PCF8574Component *arg); - bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -54,7 +46,6 @@ class PCF8574Component final : public Component, // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 - InternalGPIOPin *interrupt_pin_{nullptr}; }; using PCF8574GPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d9d1fd31f51..64a76fdd4cf 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -34,10 +34,9 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x43)) ) diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index c264aa22acc..f5dc4c31cfa 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -34,20 +34,8 @@ void PI4IOE5V6408Component::setup() { } } - // No need to clear latched interrupts before attaching the ISR — if INT is - // already low the ISR fires immediately, loop runs, cache invalidates, and - // the read clears the latch. One harmless extra read at most. - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&PI4IOE5V6408Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - this->set_invalidate_on_read_(false); - } - // Disable loop until an input pin is configured via pin_mode() - // For interrupt-driven mode, loop is re-enabled by the ISR - // For polling mode, loop is re-enabled when pin_mode() registers an input pin - this->disable_loop(); + this->setup_interrupt_pin_(); } -void IRAM_ATTR PI4IOE5V6408Component::gpio_intr(PI4IOE5V6408Component *arg) { arg->enable_loop_soon_any_context(); } void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); @@ -67,26 +55,12 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->pull_up_down_mask_ &= ~(1 << pin); this->pull_enable_mask_ |= 1 << pin; } - // Enable polling loop for input pins (not needed for interrupt-driven mode - // where the ISR handles re-enabling loop) - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } // Write GPIO to enable input mode this->write_gpio_modes_(); } -void PI4IOE5V6408Component::loop() { - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } -} - bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) return false; @@ -177,8 +151,6 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->input_mask_ & (1 << pin)); } -float PI4IOE5V6408Component::get_setup_priority() const { return setup_priority::IO; } - } // namespace esphome::pi4ioe5v6408 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pi4ioe5v6408::PI4IOE5V6408Component, "%u via PI4IOE5V6408") diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index dd7a8ef6540..9e0ffc02f84 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -1,32 +1,26 @@ #pragma once -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" namespace esphome::pi4ioe5v6408 { -class PI4IOE5V6408Component final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PI4IOE5V6408Component final : public gpio_expander::CachedGpioExpanderComponent, + public i2c::I2CDevice { public: PI4IOE5V6408Component() = default; void setup() override; void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; void dump_config() override; - void loop() override; /// Indicate if the component should reset the state during setup void set_reset(bool reset) { this->reset_ = reset; } - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: - static void gpio_intr(PI4IOE5V6408Component *arg); - bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -43,7 +37,6 @@ class PI4IOE5V6408Component final : public Component, uint8_t pull_up_down_mask_{0x00}; bool reset_{true}; - InternalGPIOPin *interrupt_pin_{nullptr}; bool read_gpio_modes_(); bool write_gpio_modes_(); diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5b7f58253e8..9c45b3fcc99 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -23,10 +23,9 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(gpio_expander.INTERRUPT_EXPANDER_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index d162480f0aa..8b4e95cbd32 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -25,14 +25,8 @@ void TCA9555Component::setup() { return; } - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); - this->set_invalidate_on_read_(false); - } - this->disable_loop(); + this->setup_interrupt_pin_(); } -void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); } void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); @@ -42,9 +36,7 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; - if (this->interrupt_pin_ == nullptr) { - this->enable_loop(); - } + this->enable_polling_loop_(); } else if (flags == gpio::FLAG_OUTPUT) { // Clear mode mask bit this->mode_mask_ &= ~(1 << pin); @@ -52,15 +44,6 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { // Write GPIO to enable input mode this->write_gpio_modes_(); } -void TCA9555Component::loop() { - this->reset_pin_cache_(); - // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the - // I2C read leave INT asserted without re-firing a falling edge, which would strand us with - // stale state forever; keep looping until the line is released so we self-heal. - if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { - this->disable_loop(); - } -} bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) @@ -124,8 +107,6 @@ bool TCA9555Component::write_gpio_modes_() { bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } -float TCA9555Component::get_setup_priority() const { return setup_priority::IO; } - } // namespace esphome::tca9555 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::tca9555::TCA9555Component, "%u via TCA9555") diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 2df3433cdb5..2e419586267 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/cached_gpio_component.h" #include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" @@ -8,9 +8,7 @@ namespace esphome::tca9555 { -class TCA9555Component final : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class TCA9555Component final : public gpio_expander::CachedGpioExpanderComponent, public i2c::I2CDevice { public: TCA9555Component() = default; @@ -18,16 +16,9 @@ class TCA9555Component final : public Component, void setup() override; void pin_mode(uint8_t pin, gpio::Flags flags); - float get_setup_priority() const override; - void dump_config() override; - void loop() override; - - void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } - protected: - static void gpio_intr(TCA9555Component *arg); // Virtual methods from GpioExpander base class — names come from base bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) @@ -43,8 +34,6 @@ class TCA9555Component final : public Component, bool read_gpio_modes_(); bool write_gpio_modes_(); bool read_gpio_outputs_(); - - InternalGPIOPin *interrupt_pin_{nullptr}; }; using TCA9555GPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp index 4ee55fd805f..eace488003f 100644 --- a/tests/components/pca9505/test_pca9505.cpp +++ b/tests/components/pca9505/test_pca9505.cpp @@ -69,6 +69,7 @@ class PCA9505Test : public ::testing::Test { void SetUp() override { this->component_.set_i2c_bus(&this->bus_); this->component_.set_i2c_address(FakeBus::ADDRESS); + this->int_pin_.set_inverted(true); // INT released } PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { From 6d110fd9815726c68fe9cedee96844a39a1d604a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:03:52 -0400 Subject: [PATCH 24/37] [lvgl] Skip the creation handler when updating widgets without update options (#21535) --- esphome/components/lvgl/widgets/__init__.py | 7 ++- .../update_creation_only_widgets_test.yaml | 56 +++++++++++++++++++ .../lvgl/test_update_creation_only_widgets.py | 46 +++++++++++++++ tests/components/lvgl/common.yaml | 12 ++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/update_creation_only_widgets_test.yaml create mode 100644 tests/component_tests/lvgl/test_update_creation_only_widgets.py diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 210314103ec..5ac35f8ac49 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -210,10 +210,15 @@ class WidgetType: async def update_to_code(self, w: "Widget", config: dict) -> None: """ - Update a widget. Defaults to calling to_code, but can be overridden + Update a widget. Defaults to calling to_code when the widget declares + update options; a widget with an empty modify_schema has nothing + widget-specific to update because the shared update path already + applies the common properties. Can be overridden. :param w: The widget to update :param config: The configuration for the update """ + if not self.modify_schema.schema: + return await self.to_code(w, config) async def obj_creator(self, parent: MockObj, config: dict): diff --git a/tests/component_tests/lvgl/config/update_creation_only_widgets_test.yaml b/tests/component_tests/lvgl/config/update_creation_only_widgets_test.yaml new file mode 100644 index 00000000000..232f0d27e4e --- /dev/null +++ b/tests/component_tests/lvgl/config/update_creation_only_widgets_test.yaml @@ -0,0 +1,56 @@ +esphome: + name: test-update-creation-only + on_boot: + - lvgl.tabview.update: + id: test_tabview + hidden: true + - lvgl.tileview.update: + id: test_tileview + hidden: true + - lvgl.canvas.update: + id: test_canvas + hidden: true + - lvgl.menu.update: + id: test_menu + hidden: true + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: GPIO3 + +lvgl: + widgets: + - tabview: + id: test_tabview + tabs: + - name: First + id: test_tab + - tileview: + id: test_tileview + tiles: + - id: test_tile + row: 0 + column: 0 + - canvas: + id: test_canvas + width: 16 + height: 16 + - menu: + id: test_menu + root_page: test_page + pages: + - id: test_page diff --git a/tests/component_tests/lvgl/test_update_creation_only_widgets.py b/tests/component_tests/lvgl/test_update_creation_only_widgets.py new file mode 100644 index 00000000000..436c8c32d9c --- /dev/null +++ b/tests/component_tests/lvgl/test_update_creation_only_widgets.py @@ -0,0 +1,46 @@ +"""Updating a widget whose update schema has no widget-specific options must not re-run +the creation handler, which reads keys that only exist at creation time.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent + / "config" + / "update_creation_only_widgets_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_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +@pytest.mark.parametrize( + ("widget", "creation_call"), + [ + ("test_tabview", "lv_tabview_add_tab("), + ("test_tileview", "lv_tileview_add_tile("), + ("test_canvas", "lv_canvas_set_draw_buf("), + ("test_menu", "lv_menu_set_page("), + ], +) +def test_update_sets_flag_without_recreating( + main_cpp: str, widget: str, creation_call: str +) -> None: + assert f"lv_obj_set_hidden({widget}, true);" in main_cpp + assert main_cpp.count(creation_call) == 1 diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index 0b6aa3501e1..d6a717a5ca2 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -104,6 +104,18 @@ binary_sensor: id: tileview_id row: 0 column: 0 + - lvgl.tabview.update: + id: tabview_id + hidden: false + - lvgl.tileview.update: + id: tileview_id + hidden: false + - lvgl.menu.update: + id: settings_menu + hidden: false + - lvgl.canvas.update: + id: canvas_id + hidden: false - if: condition: lvgl.is_paused: From f35395b58470213d030b4218c38aa2f0ab09c06a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 16:12:06 -1000 Subject: [PATCH 25/37] [mqtt] Rebuild subscription topics when needed instead of storing them (#20762) --- esphome/components/mqtt/__init__.py | 21 +++-- .../mqtt/mqtt_alarm_control_panel.cpp | 2 +- esphome/components/mqtt/mqtt_button.cpp | 2 +- esphome/components/mqtt/mqtt_client.cpp | 79 ++++++++++++++++--- esphome/components/mqtt/mqtt_client.h | 79 ++++++++++++++++--- esphome/components/mqtt/mqtt_climate.cpp | 43 +++++----- esphome/components/mqtt/mqtt_component.cpp | 8 ++ esphome/components/mqtt/mqtt_component.h | 50 +++++++----- esphome/components/mqtt/mqtt_cover.cpp | 52 ++++++------ esphome/components/mqtt/mqtt_date.cpp | 2 +- esphome/components/mqtt/mqtt_datetime.cpp | 2 +- esphome/components/mqtt/mqtt_fan.cpp | 53 +++++++------ esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_number.cpp | 2 +- esphome/components/mqtt/mqtt_select.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_text.cpp | 2 +- esphome/components/mqtt/mqtt_time.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- esphome/components/mqtt/mqtt_valve.cpp | 23 +++--- esphome/core/automation.h | 2 + .../components/test_mqtt_topic_source.py | 41 ++++++++++ 23 files changed, 330 insertions(+), 145 deletions(-) create mode 100644 tests/unit_tests/components/test_mqtt_topic_source.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 275c77a4178..8852fedcbc7 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -112,10 +112,12 @@ DEFAULT_WS_PATH = "/mqtt" # Max lengths for stack-based topic building. # These values are used in cv.Length() validators below to ensure the C++ code -# in mqtt_component.cpp can safely use fixed-size stack buffers without overflow. -# If you change these, update the corresponding constants in mqtt_component.cpp. +# can safely use fixed-size stack buffers without overflow. If you change these, +# update the corresponding constants in mqtt_client.h and mqtt_component.h. TOPIC_PREFIX_MAX_LEN = 64 # Default is device name, typically short DISCOVERY_PREFIX_MAX_LEN = 64 # Default is "homeassistant" (13 chars) +# Size of the stack buffer topics are built into (static_assert in mqtt_client.cpp) +MQTT_DEFAULT_TOPIC_MAX_LEN = 248 def validate_message_just_topic(value): @@ -145,6 +147,15 @@ MQTT_MESSAGE_SCHEMA = cv.Any( ) mqtt_ns = cg.esphome_ns.namespace("mqtt") + + +def _topic_source(topic: str) -> cg.MockObj: + # On ESP8266 a topic that fits the buffer stays in flash and is copied out when needed + if CORE.is_esp8266 and len(topic.encode()) < MQTT_DEFAULT_TOPIC_MAX_LEN: + return mqtt_ns.progmem_topic(cg.FlashStringLiteral(topic)) + return mqtt_ns.static_topic(topic) + + MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) @@ -763,9 +774,7 @@ async def to_code(config): ((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),), ), ) - cg.add( - var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]) - ) + cg.add(var.subscribe(_topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS])) for conf in config.get(CONF_ON_JSON_MESSAGE, []): callback = await automation.build_trigger_callback( @@ -776,7 +785,7 @@ async def to_code(config): ) cg.add( var.subscribe_json( - cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS] + _topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS] ) ) diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index 027d73e9517..9acb0c2304a 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -50,7 +50,7 @@ static bool apply_command(AlarmControlPanelCall &call, const char *state) { void MQTTAlarmControlPanelComponent::setup() { this->alarm_control_panel_->add_on_state_callback( [this](AlarmControlPanelState /*state*/) { this->publish_state(); }); - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { auto call = this->alarm_control_panel_->make_call(); if (!payload.empty() && payload[0] == '{') { // JSON payload: {"state": "DISARM", "code": "1234"} diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 0cff67f79ae..e31143cd727 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -15,7 +15,7 @@ using namespace esphome::button; MQTTButtonComponent::MQTTButtonComponent(button::Button *button) : button_(button) {} void MQTTButtonComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { if (payload == "PRESS") { this->button_->press(); } else { diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 64066c149d3..0e8a34927ec 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -98,12 +98,16 @@ void MQTTClientComponent::setup() { if (this->is_discovery_ip_enabled()) { this->subscribe( - "esphome/discover", [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, - 2); + static_topic("esphome/discover"), + [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); - // Format topic on stack - subscribe() copies it - char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE]; - format_esphome_topic(ping_topic, "ping"); + static_assert(sizeof("esphome/ping/") + ESPHOME_DEVICE_NAME_MAX_LEN <= MQTT_DEFAULT_TOPIC_MAX_LEN); + MQTTTopicSource ping_topic{[](const void *, std::span buf) { + size_t len = buf_append_printf(buf.data(), buf.size(), 0, "esphome/ping/%s", + App.get_name().c_str()); + return StringRef(buf.data(), len); + }, + nullptr}; this->subscribe( ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); } @@ -479,6 +483,12 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) { } return ret != 0; } +// Out of line so the topic buffer is not part of loop()'s stack frame +__attribute__((noinline)) bool MQTTClientComponent::subscribe_topic_(const MQTTTopicSource &topic, uint8_t qos) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + return this->subscribe_(topic.build(buf).c_str(), qos); +} + void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) { if (sub->subscribed) return; @@ -487,7 +497,7 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) { bool do_resub = sub->resubscribe_timeout == 0 || now - sub->resubscribe_timeout > 1000; if (do_resub) { - sub->subscribed = this->subscribe_(sub->topic.c_str(), sub->qos); + sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos); sub->resubscribe_timeout = now; } } @@ -510,20 +520,57 @@ void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, co } #endif -void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, +static StringRef static_topic_builder(const void *context, std::span) { + return StringRef(static_cast(context)); +} +// Same as static_topic_builder, but a distinct function so its address marks the topics to free +static StringRef copied_topic_builder(const void *context, std::span) { + return StringRef(static_cast(context)); +} + +MQTTTopicSource static_topic(const char *topic) { return {static_topic_builder, topic}; } + +static_assert(MQTT_DEFAULT_TOPIC_MAX_LEN == 248, "update MQTT_DEFAULT_TOPIC_MAX_LEN in mqtt/__init__.py"); + +#ifdef USE_ESP8266 +// mqtt/__init__.py picks this source only for topics that fit +static StringRef progmem_topic_builder(const void *context, std::span buf) { + size_t len = buf_append_str_p(buf.data(), buf.size(), 0, static_cast(context)); + return StringRef(buf.data(), len); +} + +MQTTTopicSource progmem_topic(ProgmemStr topic) { + return {progmem_topic_builder, reinterpret_cast(topic)}; +} +#endif + +MQTTTopicSource MQTTClientComponent::copy_topic(StringRef topic) { + char *copy = new char[topic.size() + 1]; // NOLINT(cppcoreguidelines-owning-memory) + memcpy(copy, topic.c_str(), topic.size()); + copy[topic.size()] = '\0'; + return {copied_topic_builder, copy}; +} + +void MQTTClientComponent::add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos) { auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ - .topic = std::move(topic), + .topic = topic, + .callback = callback, + .resubscribe_timeout = 0, .qos = qos, .subscribed = false, .boxed = boxed, .removed = false, - .callback = callback, - .resubscribe_timeout = 0, }); this->resubscribe_subscription_(&subscription); } +// Out of line so the topic buffer is not part of unsubscribe()'s stack frame +__attribute__((noinline)) static bool topic_equals(const MQTTTopicSource &source, const std::string &topic) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + return source.build(buf) == topic; +} + void MQTTClientComponent::unsubscribe(const std::string &topic) { bool ret = this->mqtt_backend_.unsubscribe(topic.c_str()); yield(); @@ -536,7 +583,7 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { } for (auto &subscription : this->subscriptions_) { - if (subscription.topic == topic) + if (topic_equals(subscription.topic, topic)) subscription.removed = true; } // A callback may be running; the outermost dispatch erases them once it returns. @@ -553,6 +600,8 @@ void MQTTClientComponent::erase_removed_subscriptions_() { if (it->removed) { if (it->boxed) it->callback.free_boxed(); + if (it->topic.builder == copied_topic_builder) + delete[] static_cast(it->topic.context); // NOLINT(cppcoreguidelines-owning-memory) it = this->subscriptions_.erase(it); } else { ++it; @@ -629,6 +678,12 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } +// Out of line so the topic buffer is off the stack while the matched callback runs +__attribute__((noinline)) static bool topic_matches(const char *message, const MQTTTopicSource &topic) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + return topic_match(message, topic.build(buf).c_str()); +} + 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. @@ -662,7 +717,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string 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())) + if (!subscription.removed && topic_matches(topic.c_str(), subscription.topic)) subscription.callback.call(topic, payload); } this->dispatching_ = was_dispatching; diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index cf4f2a617ae..add6d7ed09d 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -8,8 +8,11 @@ #include "esphome/components/network/ip_address.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -22,6 +25,7 @@ #endif #include "lwip/ip_addr.h" +#include #include // The crash log replay needs the logger, which mqtt does not depend on @@ -31,6 +35,18 @@ namespace esphome::mqtt { +// Max lengths for stack-based topic building. +// These limits are enforced at Python config validation time in mqtt/__init__.py +// using cv.Length() validators for topic_prefix. +// This ensures the stack buffers are always large enough. +static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20; +static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32; +static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) +// Stack buffer size - safe because all inputs are length-validated at config time +// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null +static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN = + MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; + /** Callback for MQTT events. */ using mqtt_on_connect_callback_t = std::function; @@ -46,15 +62,33 @@ 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; +/// Returns a subscription's topic, written into buf or referring to storage that outlives the subscription. +/// Never a null c_str(); an empty topic is "". +using mqtt_topic_builder_t = StringRef (*)(const void *context, std::span buf); + +/// Where a subscription's topic comes from. The topic is rebuilt whenever it is needed, so nothing is stored. +struct MQTTTopicSource { + mqtt_topic_builder_t builder; + const void *context; + StringRef build(std::span buf) const { return this->builder(this->context, buf); } +}; + +/// A topic string that outlives the subscription, such as a literal; it is referenced, not copied. +MQTTTopicSource static_topic(const char *topic); +#ifdef USE_ESP8266 +/// A topic in flash, copied into the buffer when needed, so at most MQTT_DEFAULT_TOPIC_MAX_LEN - 1 characters. +MQTTTopicSource progmem_topic(ProgmemStr topic); +#endif + /// internal struct for MQTT subscriptions. struct MQTTSubscription { - std::string topic; + MQTTTopicSource topic; + mqtt_subscription_callback_t callback; + uint32_t resubscribe_timeout; uint8_t qos; 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; }; /// internal struct for MQTT credentials. @@ -183,32 +217,44 @@ class MQTTClientComponent final : public Component { * 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 Where the topic comes from; it is rebuilt when needed instead of stored. + * @param callback The callback function. + * @param qos The QoS of this subscription. + */ + template void subscribe(MQTTTopicSource topic, F &&callback, uint8_t qos = 0) { + constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline(); + this->add_subscription_(topic, + boxed ? mqtt_subscription_callback_t::create_boxed(std::forward(callback)) + : mqtt_subscription_callback_t::create(std::forward(callback)), + boxed, qos); + } + + /** Subscribe to an MQTT topic and call callback when a message is received. + * + * The topic is copied to 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. */ - 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); + template void subscribe(const std::string &topic, F &&callback, uint8_t qos = 0) { + this->subscribe(copy_topic(StringRef(topic)), std::forward(callback), qos); } /** Subscribe to a MQTT topic and automatically parse JSON payload. * * If an invalid JSON payload is received, the callback will not be called. * - * @param topic The topic. Wildcards are currently not supported. + * @param topic An MQTTTopicSource, or a string that is copied to the heap. * @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is * received. * @param qos The QoS of this subscription. */ - template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + template void subscribe_json(T &&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), + std::forward(topic), [cb = std::forward(callback)](const std::string &topic, const std::string &payload) { call_json(cb, topic, payload); }, @@ -301,6 +347,12 @@ class MQTTClientComponent final : public Component { void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } protected: + // For the lambda command topic, which is copied once + friend class MQTTComponent; + + /// A heap copy of topic, owned by the one subscription it is passed to and freed when that is erased. + static MQTTTopicSource copy_topic(StringRef topic); + void send_device_info_(); /// Reconnect to the MQTT broker if not already connected. @@ -317,6 +369,7 @@ class MQTTClientComponent final : public Component { void recalculate_availability_(); bool subscribe_(const char *topic, uint8_t qos); + bool subscribe_topic_(const MQTTTopicSource &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); @@ -328,7 +381,7 @@ class MQTTClientComponent final : public Component { #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 add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); MQTTCredentials credentials_; diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 83efc3da9ce..4d664dcd97d 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -209,7 +209,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo } void MQTTClimateComponent::setup() { auto traits = this->device_->get_traits(); - this->subscribe(this->get_mode_command_topic(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->get_mode_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto call = this->device_->make_call(); call.set_mode(payload); call.perform(); @@ -217,7 +217,7 @@ void MQTTClimateComponent::setup() { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { - this->subscribe(this->get_target_temperature_low_command_topic(), + this->subscribe(this->get_target_temperature_low_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto val = parse_number(payload); if (!val.has_value()) { @@ -228,7 +228,7 @@ void MQTTClimateComponent::setup() { call.set_target_temperature_low(*val); call.perform(); }); - this->subscribe(this->get_target_temperature_high_command_topic(), + this->subscribe(this->get_target_temperature_high_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto val = parse_number(payload); if (!val.has_value()) { @@ -240,7 +240,7 @@ void MQTTClimateComponent::setup() { call.perform(); }); } else { - this->subscribe(this->get_target_temperature_command_topic(), + this->subscribe(this->get_target_temperature_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto val = parse_number(payload); if (!val.has_value()) { @@ -254,7 +254,7 @@ void MQTTClimateComponent::setup() { } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { - this->subscribe(this->get_target_humidity_command_topic(), + this->subscribe(this->get_target_humidity_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto val = parse_number(payload); if (!val.has_value()) { @@ -268,27 +268,30 @@ void MQTTClimateComponent::setup() { } if (traits.get_supports_presets() || !traits.get_supported_custom_presets().empty()) { - this->subscribe(this->get_preset_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto call = this->device_->make_call(); - call.set_preset(payload); - call.perform(); - }); + this->subscribe(this->get_preset_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + auto call = this->device_->make_call(); + call.set_preset(payload); + call.perform(); + }); } if (traits.get_supports_fan_modes()) { - this->subscribe(this->get_fan_mode_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto call = this->device_->make_call(); - call.set_fan_mode(payload); - call.perform(); - }); + this->subscribe(this->get_fan_mode_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + auto call = this->device_->make_call(); + call.set_fan_mode(payload); + call.perform(); + }); } if (traits.get_supports_swing_modes()) { - this->subscribe(this->get_swing_mode_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto call = this->device_->make_call(); - call.set_swing_mode(payload); - call.perform(); - }); + this->subscribe(this->get_swing_mode_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + auto call = this->device_->make_call(); + call.set_swing_mode(payload); + call.perform(); + }); } this->device_->add_on_state_callback([this](Climate & /*unused*/) { this->publish_state_(); }); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index bd40af1a607..c4cc1c7e950 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -124,6 +124,14 @@ StringRef MQTTComponent::get_command_topic_to_(std::spanget_default_topic_for_to_(buf, "command", 7); } +MQTTTopicSource MQTTComponent::command_topic_source_() const { + if (this->custom_command_topic_.is_lambda()) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + return MQTTClientComponent::copy_topic(this->get_command_topic_to_(buf)); + } + return this->topic_source_(); +} + std::string MQTTComponent::get_state_topic_() const { char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; StringRef ref = this->get_state_topic_to_(buf); diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index d5a766e48c3..f11006fef2b 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -21,23 +21,14 @@ struct SendDiscoveryConfig { bool command_topic{true}; ///< If the command topic should be included. Default to true. }; -// Max lengths for stack-based topic building. -// These limits are enforced at Python config validation time in mqtt/__init__.py -// using cv.Length() validators for topic_prefix and discovery_prefix. -// This ensures the stack buffers are always large enough. -static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20; -static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32; -static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) -// Stack buffer size - safe because all inputs are length-validated at config time -// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null -static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN = - MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; -static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) +class MQTTComponent; // Forward declaration + +// Validated in Python: cv.Length(max=64), DISCOVERY_PREFIX_MAX_LEN in mqtt/__init__.py +static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; -class MQTTComponent; // Forward declaration void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); #define LOG_MQTT_COMPONENT(state_topic, command_topic) log_mqtt_component(TAG, this, state_topic, command_topic) @@ -49,7 +40,9 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b static_assert(sizeof(type_str) - 1 <= MQTT_COMPONENT_TYPE_MAX_LEN, \ #class_name "::component_type() exceeds MQTT_COMPONENT_TYPE_MAX_LEN"); -// Macro to define custom topic getter/setter with compile-time suffix length verification +// Macro to define custom topic getter/setter with compile-time suffix length verification. +// Set the topic before setup(): subscriptions rebuild it when needed, so a later change only reaches the broker +// after a reconnect. #define MQTT_COMPONENT_CUSTOM_TOPIC_(name, type) \ static_assert(sizeof(#name "/" #type) - 1 <= MQTT_SUFFIX_MAX_LEN, \ "topic suffix " #name "/" #type " exceeds MQTT_SUFFIX_MAX_LEN"); \ @@ -64,6 +57,10 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b return StringRef(this->custom_##name##_##type##_topic_.data(), this->custom_##name##_##type##_topic_.size()); \ return this->get_default_topic_for_to_(buf, #name "/" #type, sizeof(#name "/" #type) - 1); \ } \ + MQTTTopicSource get_##name##_##type##_topic_source() const { \ + using Self = std::remove_cvref_t; \ + return this->topic_source_(); \ + } \ std::string get_##name##_##type##_topic() const { \ if (this->custom_##name##_##type##_topic_.empty()) \ return this->get_default_topic_for_(#name "/" #type); \ @@ -128,6 +125,7 @@ class MQTTComponent : public Component { template void set_custom_state_topic(T &&custom_state_topic) { this->custom_state_topic_ = std::forward(custom_state_topic); } + /// Set a custom command topic before setup(); a later change only reaches the broker after a reconnect. template void set_custom_command_topic(T &&custom_command_topic) { this->custom_command_topic_ = std::forward(custom_command_topic); } @@ -255,25 +253,25 @@ class MQTTComponent : public Component { /** Subscribe to a MQTT topic. * - * @param topic The topic. Wildcards are currently not supported. + * @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap. * @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. */ - template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { - global_mqtt_client->subscribe(std::move(topic), std::forward(callback), qos); + template void subscribe(T &&topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe(std::forward(topic), std::forward(callback), qos); } /** Subscribe to a MQTT topic and automatically parse JSON payload. * * If an invalid JSON payload is received, the callback will not be called. * - * @param topic The topic. Wildcards are currently not supported. + * @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap. * @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is * received. * @param qos The MQTT quality of service. Defaults to 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); + template void subscribe_json(T &&topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe_json(std::forward(topic), std::forward(callback), qos); } protected: @@ -322,6 +320,18 @@ class MQTTComponent : public Component { /// @return StringRef pointing to the topic in the buffer. StringRef get_command_topic_to_(std::span buf) const; + /// The command topic as a subscription source. A lambda topic is read once and copied, as it may change. + MQTTTopicSource command_topic_source_() const; + + /// A topic built by one of this component's `get_*_topic_to` getters, rebuilt when needed. + template) const> + MQTTTopicSource topic_source_() const { + return {[](const void *context, std::span buf) { + return (static_cast(context)->*Getter)(buf); + }, + static_cast(this)}; + } + /// Get the MQTT topic that new states will be shared to (allocates std::string). std::string get_state_topic_() const; diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index 560377bc47f..69f0e46607b 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -31,38 +31,40 @@ MQTTCoverComponent::MQTTCoverComponent(Cover *cover) : cover_(cover) {} void MQTTCoverComponent::setup() { auto traits = this->cover_->get_traits(); this->cover_->add_on_state_callback([this]() { this->publish_state(); }); - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { auto call = this->cover_->make_call(); call.set_command(payload.c_str()); call.perform(); }); if (traits.get_supports_position()) { - this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto value = parse_number(payload); - if (!value.has_value()) { - ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str()); - return; - } - auto call = this->cover_->make_call(); - call.set_position(*value / 100.0f); - call.perform(); - }); + this->subscribe(this->get_position_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + auto value = parse_number(payload); + if (!value.has_value()) { + ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str()); + return; + } + auto call = this->cover_->make_call(); + call.set_position(*value / 100.0f); + call.perform(); + }); } if (traits.get_supports_tilt()) { - this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) { - if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) { - this->cover_->make_call().set_command_stop_tilt().perform(); - return; - } - auto value = parse_number(payload); - if (!value.has_value()) { - ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str()); - return; - } - auto call = this->cover_->make_call(); - call.set_tilt(*value / 100.0f); - call.perform(); - }); + this->subscribe(this->get_tilt_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) { + this->cover_->make_call().set_command_stop_tilt().perform(); + return; + } + auto value = parse_number(payload); + if (!value.has_value()) { + ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str()); + return; + } + auto call = this->cover_->make_call(); + call.set_tilt(*value / 100.0f); + call.perform(); + }); } } diff --git a/esphome/components/mqtt/mqtt_date.cpp b/esphome/components/mqtt/mqtt_date.cpp index b580ad5eddd..26d9c6a4517 100644 --- a/esphome/components/mqtt/mqtt_date.cpp +++ b/esphome/components/mqtt/mqtt_date.cpp @@ -17,7 +17,7 @@ using namespace esphome::datetime; MQTTDateComponent::MQTTDateComponent(DateEntity *date) : date_(date) {} void MQTTDateComponent::setup() { - this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) { + this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) { auto call = this->date_->make_call(); if (root[ESPHOME_F("year")].is()) { call.set_year(root[ESPHOME_F("year")]); diff --git a/esphome/components/mqtt/mqtt_datetime.cpp b/esphome/components/mqtt/mqtt_datetime.cpp index e0129cef0f9..81cd5fb32db 100644 --- a/esphome/components/mqtt/mqtt_datetime.cpp +++ b/esphome/components/mqtt/mqtt_datetime.cpp @@ -17,7 +17,7 @@ using namespace esphome::datetime; MQTTDateTimeComponent::MQTTDateTimeComponent(DateTimeEntity *datetime) : datetime_(datetime) {} void MQTTDateTimeComponent::setup() { - this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) { + this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) { auto call = this->datetime_->make_call(); if (root[ESPHOME_F("year")].is()) { call.set_year(root[ESPHOME_F("year")]); diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 1756d68ac61..99d3ae1c0de 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -28,7 +28,7 @@ MQTT_COMPONENT_TYPE(MQTTFanComponent, "fan") const EntityBase *MQTTFanComponent::get_entity() const { return this->state_; } void MQTTFanComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { auto val = parse_on_off(payload.c_str()); switch (val) { case PARSE_ON: @@ -52,33 +52,34 @@ void MQTTFanComponent::setup() { }); if (this->state_->get_traits().supports_direction()) { - this->subscribe(this->get_direction_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto val = parse_on_off(payload.c_str(), "forward", "reverse"); - switch (val) { - case PARSE_ON: - ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_())); - this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform(); - break; - case PARSE_OFF: - ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_())); - this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform(); - break; - case PARSE_TOGGLE: - this->state_->make_call() - .set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE - : fan::FanDirection::FORWARD) - .perform(); - break; - case PARSE_NONE: - ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str()); - this->status_momentary_warning(5000); - break; - } - }); + this->subscribe( + this->get_direction_command_topic_source(), [this](const std::string &topic, const std::string &payload) { + auto val = parse_on_off(payload.c_str(), "forward", "reverse"); + switch (val) { + case PARSE_ON: + ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_())); + this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform(); + break; + case PARSE_OFF: + ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_())); + this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform(); + break; + case PARSE_TOGGLE: + this->state_->make_call() + .set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE + : fan::FanDirection::FORWARD) + .perform(); + break; + case PARSE_NONE: + ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str()); + this->status_momentary_warning(5000); + break; + } + }); } if (this->state_->get_traits().supports_oscillation()) { - this->subscribe(this->get_oscillation_command_topic(), + this->subscribe(this->get_oscillation_command_topic_source(), [this](const std::string &topic, const std::string &payload) { auto val = parse_on_off(payload.c_str(), "oscillate_on", "oscillate_off"); switch (val) { @@ -102,7 +103,7 @@ void MQTTFanComponent::setup() { } if (this->state_->get_traits().supports_speed()) { - this->subscribe(this->get_speed_level_command_topic(), + this->subscribe(this->get_speed_level_command_topic_source(), [this](const std::string &topic, const std::string &payload) { optional speed_level_opt = parse_number(payload); if (speed_level_opt.has_value()) { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index 7a9240f73e6..fbdf6f11ef4 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -18,7 +18,7 @@ MQTT_COMPONENT_TYPE(MQTTJSONLightComponent, "light") const EntityBase *MQTTJSONLightComponent::get_entity() const { return this->state_; } void MQTTJSONLightComponent::setup() { - this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) { + this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) { LightCall call = this->state_->make_call(); LightJSONSchema::parse_json(*this->state_, call, root); call.perform(); diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 77dc7cc679d..bd9bcfd1c3d 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -16,7 +16,7 @@ using namespace esphome::lock; MQTTLockComponent::MQTTLockComponent(lock::Lock *a_lock) : lock_(a_lock) {} void MQTTLockComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("LOCK")) == 0) { this->lock_->lock(); } else if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("UNLOCK")) == 0) { diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index 58492e2605d..148df5ed2e1 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(NumberMqttModeStrings, "", "box", "slider"); MQTTNumberComponent::MQTTNumberComponent(Number *number) : number_(number) {} void MQTTNumberComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) { auto val = parse_number(state); if (!val.has_value()) { ESP_LOGW(TAG, "Can't convert '%s' to number!", state.c_str()); diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index c24ddb80ea7..e9194c634f1 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -15,7 +15,7 @@ using namespace esphome::select; MQTTSelectComponent::MQTTSelectComponent(Select *select) : select_(select) {} void MQTTSelectComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) { auto call = this->select_->make_call(); call.set_option(state); call.perform(); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index d386d5c3238..988b1791db9 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -15,7 +15,7 @@ using namespace esphome::switch_; MQTTSwitchComponent::MQTTSwitchComponent(switch_::Switch *a_switch) : switch_(a_switch) {} void MQTTSwitchComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { switch (parse_on_off(payload.c_str())) { case PARSE_ON: this->switch_->turn_on(); diff --git a/esphome/components/mqtt/mqtt_text.cpp b/esphome/components/mqtt/mqtt_text.cpp index 979b50e0a2e..48457bf32f1 100644 --- a/esphome/components/mqtt/mqtt_text.cpp +++ b/esphome/components/mqtt/mqtt_text.cpp @@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(TextMqttModeStrings, "text", "password"); MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {} void MQTTTextComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) { auto call = this->text_->make_call(); call.set_value(state); call.perform(); diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp index 70ea89a7ba0..e40a7aa0e3b 100644 --- a/esphome/components/mqtt/mqtt_time.cpp +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -17,7 +17,7 @@ using namespace esphome::datetime; MQTTTimeComponent::MQTTTimeComponent(TimeEntity *time) : time_(time) {} void MQTTTimeComponent::setup() { - this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) { + this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) { auto call = this->time_->make_call(); if (root[ESPHOME_F("hour")].is()) { call.set_hour(root[ESPHOME_F("hour")]); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index 4e03f634d13..e5224337cc4 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -15,7 +15,7 @@ using namespace esphome::update; MQTTUpdateComponent::MQTTUpdateComponent(UpdateEntity *update) : update_(update) {} void MQTTUpdateComponent::setup() { - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { if (payload == "INSTALL") { this->update_->perform(); } else { diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index 9ee1ce85dac..bc2b4b0b01c 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -31,22 +31,23 @@ MQTTValveComponent::MQTTValveComponent(Valve *valve) : valve_(valve) {} void MQTTValveComponent::setup() { auto traits = this->valve_->get_traits(); this->valve_->add_on_state_callback([this]() { this->publish_state(); }); - this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { + this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) { auto call = this->valve_->make_call(); call.set_command(payload.c_str()); call.perform(); }); if (traits.get_supports_position()) { - this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) { - auto value = parse_number(payload); - if (!value.has_value()) { - ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str()); - return; - } - auto call = this->valve_->make_call(); - call.set_position(*value / 100.0f); - call.perform(); - }); + this->subscribe(this->get_position_command_topic_source(), + [this](const std::string &topic, const std::string &payload) { + auto value = parse_number(payload); + if (!value.has_value()) { + ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str()); + return; + } + auto call = this->valve_->make_call(); + call.set_position(*value / 100.0f); + call.perform(); + }); } } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index c264f147682..87ff7094fa0 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -392,6 +392,8 @@ template class TemplatableValue { } bool has_value() const { return this->type_ != NONE; } + /// Whether the value comes from a lambda, so it may differ between reads. + bool is_lambda() const { return this->type_ == LAMBDA || this->type_ == STATELESS_LAMBDA; } std::string value(X... x) const { switch (this->type_) { diff --git a/tests/unit_tests/components/test_mqtt_topic_source.py b/tests/unit_tests/components/test_mqtt_topic_source.py new file mode 100644 index 00000000000..902275f0812 --- /dev/null +++ b/tests/unit_tests/components/test_mqtt_topic_source.py @@ -0,0 +1,41 @@ +"""Tests for choosing where an mqtt trigger topic is stored.""" + +import pytest + +from esphome.components.mqtt import MQTT_DEFAULT_TOPIC_MAX_LEN, _topic_source +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE + +# "é" is two bytes in UTF-8, so these topics are one character shorter than their byte length +LIMIT = MQTT_DEFAULT_TOPIC_MAX_LEN + + +@pytest.mark.parametrize( + ("topic", "platform", "source"), + [ + pytest.param("t" * (LIMIT - 1), PLATFORM_ESP8266, "progmem_topic", id="fits"), + pytest.param("t" * LIMIT, PLATFORM_ESP8266, "static_topic", id="too_long"), + pytest.param( + "é" + "t" * (LIMIT - 3), PLATFORM_ESP8266, "progmem_topic", id="utf8_fits" + ), + pytest.param( + "é" + "t" * (LIMIT - 2), + PLATFORM_ESP8266, + "static_topic", + id="utf8_bytes_too_long", + ), + pytest.param("t" * (LIMIT - 1), PLATFORM_ESP32, "static_topic", id="esp32"), + pytest.param("é" * 10, PLATFORM_ESP32, "static_topic", id="esp32_utf8"), + ], +) +def test_topic_source( + monkeypatch: pytest.MonkeyPatch, topic: str, platform: str, source: str +) -> None: + """ESP8266 keeps a topic in flash only when its UTF-8 bytes fit the copy buffer.""" + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) + assert str(_topic_source(topic)).startswith(f"mqtt::{source}(") From 1a1142447584e519ac14d8ee819b51dee38cf595 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:32:32 -0400 Subject: [PATCH 26/37] [tests] Cover remaining untested triggers and lvgl update actions (#21530) --- tests/components/ble_client/common.yaml | 2 ++ tests/components/datetime/common.yaml | 10 ++++++++++ tests/components/lvgl/common.yaml | 18 ++++++++++++++++++ tests/components/msa3xx/common.yaml | 3 +++ tests/components/number/common.yaml | 8 ++++++++ tests/components/pn532_spi/common.yaml | 3 +++ tests/components/pn7150_i2c/common.yaml | 2 ++ tests/components/pn7160_i2c/common.yaml | 2 ++ tests/components/rc522_i2c/common.yaml | 3 +++ tests/components/rdm6300/common.yaml | 3 +++ tests/components/sensor/common.yaml | 4 ++++ tests/components/text_sensor/common.yaml | 4 ++++ .../components/voice_assistant/common-idf.yaml | 6 ++++++ 13 files changed, 68 insertions(+) diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index a92a95fd7c3..9486cd58d71 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -15,6 +15,8 @@ ble_client: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: [0x04, 0x05, 0x06] + on_disconnect: + - logger.log: BLE client disconnected on_passkey_request: - ble_client.passkey_reply: id: test_blec diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index add75645292..1559599602f 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -34,10 +34,20 @@ datetime: id: datetime_test_time type: time optimistic: true + on_time: + - logger.log: Time reached - platform: template name: Datetime Test DateTime id: datetime_test_datetime type: datetime optimistic: true + on_time: + - logger.log: Datetime reached + +wifi: + ssid: MySSID + password: password1 time: + - platform: sntp + id: sntp_time diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index d6a717a5ca2..4f99fd585e0 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -104,6 +104,24 @@ binary_sensor: id: tileview_id row: 0 column: 0 + - lvgl.switch.update: + id: wifi_switch + state: + checked: true + - lvgl.roller.update: + id: lv_roller + selected_index: 1 + - lvgl.led.update: + id: lv_led + brightness: 50% + - lvgl.buttonmatrix.update: + id: b_matrix + hidden: false + - lvgl.obj.update: + id: anim_box + hidden: false + - lvgl.page.update: + id: page1 - lvgl.tabview.update: id: tabview_id hidden: false diff --git a/tests/components/msa3xx/common.yaml b/tests/components/msa3xx/common.yaml index 297ed8a7413..90757850809 100644 --- a/tests/components/msa3xx/common.yaml +++ b/tests/components/msa3xx/common.yaml @@ -25,6 +25,9 @@ msa3xx: on_orientation: - then: - logger.log: "Orientation changed" + on_freefall: + - then: + - logger.log: "Freefall detected" sensor: - platform: msa3xx diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index 3199dd90f89..6c330c2d466 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -6,6 +6,14 @@ number: min_value: 0 max_value: 100 step: 1 + on_value_range: + - above: 10 + below: 90 + then: + - logger.log: Number in range + - above: !lambda return 95; + then: + - logger.log: Number above 95 sensor: - platform: number diff --git a/tests/components/pn532_spi/common.yaml b/tests/components/pn532_spi/common.yaml index 7af4beac6ed..cea42f1b303 100644 --- a/tests/components/pn532_spi/common.yaml +++ b/tests/components/pn532_spi/common.yaml @@ -10,6 +10,9 @@ pn532_spi: on_tag_removed: then: - lambda: ESP_LOGD("test", "%s", tag.get_tag_type().c_str()); + on_finished_write: + then: + - logger.log: Tag write finished binary_sensor: - platform: pn532 diff --git a/tests/components/pn7150_i2c/common.yaml b/tests/components/pn7150_i2c/common.yaml index c5b83460ddd..a3fbb161ac0 100644 --- a/tests/components/pn7150_i2c/common.yaml +++ b/tests/components/pn7150_i2c/common.yaml @@ -33,5 +33,7 @@ pn7150_i2c: - logger.log: "Tag" on_tag_removed: - logger.log: "Tag removed" + on_finished_write: + - logger.log: Tag write finished on_emulated_tag_scan: - logger.log: "Tag emulated" diff --git a/tests/components/pn7160_i2c/common.yaml b/tests/components/pn7160_i2c/common.yaml index 052f309a188..d276f0ac842 100644 --- a/tests/components/pn7160_i2c/common.yaml +++ b/tests/components/pn7160_i2c/common.yaml @@ -33,5 +33,7 @@ pn7160_i2c: - logger.log: "Tag" on_tag_removed: - logger.log: "Tag removed" + on_finished_write: + - logger.log: Tag write finished on_emulated_tag_scan: - logger.log: "Tag emulated" diff --git a/tests/components/rc522_i2c/common.yaml b/tests/components/rc522_i2c/common.yaml index 0e624351d48..30fa6ab74b3 100644 --- a/tests/components/rc522_i2c/common.yaml +++ b/tests/components/rc522_i2c/common.yaml @@ -5,6 +5,9 @@ rc522_i2c: on_tag: - lambda: |- ESP_LOGD("main", "Found tag %s", x.c_str()); + on_tag_removed: + - lambda: |- + ESP_LOGD("main", "Removed tag %s", x.c_str()); binary_sensor: - platform: rc522 diff --git a/tests/components/rdm6300/common.yaml b/tests/components/rdm6300/common.yaml index f1a53050138..e3a0aab7c4c 100644 --- a/tests/components/rdm6300/common.yaml +++ b/tests/components/rdm6300/common.yaml @@ -1,4 +1,7 @@ rdm6300: + on_tag: + - lambda: |- + ESP_LOGD("main", "Found tag %" PRIu32, x); binary_sensor: - platform: rdm6300 diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index c908a3e5e05..c83a52b299c 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -5,6 +5,10 @@ sensor: id: source_sensor lambda: return 42.0; update_interval: 1s + on_raw_value: + - logger.log: + format: "Raw value %.1f" + args: [x] on_value: - sensor.template.publish: id: sensor_publish_target diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index efbf4e1dc7a..246f82163a7 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -2,6 +2,10 @@ text_sensor: - platform: template name: "Test Substitute Single" id: test_substitute_single + on_raw_value: + - logger.log: + format: "Raw text %s" + args: [x.c_str()] filters: - substitute: - ERROR -> Error diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 49a61552bc9..9cf8382cb4c 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -88,6 +88,12 @@ voice_assistant: - logger.log: "TTS stream start" on_tts_stream_end: - logger.log: "TTS stream end" + on_idle: + - logger.log: "Voice assistant idle" + on_client_connected: + - logger.log: "Voice assistant client connected" + on_client_disconnected: + - logger.log: "Voice assistant client disconnected" on_error: - logger.log: format: "Voice assistant error - code %s, message: %s" From b6fdccfc65f0a32337e3bf4bf53fd99f6751c911 Mon Sep 17 00:00:00 2001 From: jaaviercr <28541530+jaaviercr@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:37:22 +0200 Subject: [PATCH 27/37] [mqtt] Honor publish_nan_as_none for climate target states (#20598) Co-authored-by: Javier Carrasco Reyes Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mqtt/mqtt_climate.cpp | 17 ++++++++--------- esphome/components/mqtt/mqtt_component.cpp | 10 ++++++++++ esphome/components/mqtt/mqtt_component.h | 3 +++ esphome/components/mqtt/mqtt_sensor.cpp | 7 +------ 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 4d664dcd97d..22b621acd6f 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -321,15 +321,15 @@ bool MQTTClimateComponent::publish_state_() { } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { - len = value_accuracy_to_buf(payload, this->device_->target_temperature_low, target_accuracy); - if (!this->publish(this->get_target_temperature_low_state_topic_to(topic_buf), payload, len)) + if (!this->publish_float_(this->get_target_temperature_low_state_topic_to(topic_buf), + this->device_->target_temperature_low, target_accuracy)) success = false; - len = value_accuracy_to_buf(payload, this->device_->target_temperature_high, target_accuracy); - if (!this->publish(this->get_target_temperature_high_state_topic_to(topic_buf), payload, len)) + if (!this->publish_float_(this->get_target_temperature_high_state_topic_to(topic_buf), + this->device_->target_temperature_high, target_accuracy)) success = false; } else { - len = value_accuracy_to_buf(payload, this->device_->target_temperature, target_accuracy); - if (!this->publish(this->get_target_temperature_state_topic_to(topic_buf), payload, len)) + if (!this->publish_float_(this->get_target_temperature_state_topic_to(topic_buf), this->device_->target_temperature, + target_accuracy)) success = false; } @@ -340,9 +340,8 @@ bool MQTTClimateComponent::publish_state_() { success = false; } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY) && - !std::isnan(this->device_->target_humidity)) { - len = value_accuracy_to_buf(payload, this->device_->target_humidity, 0); - if (!this->publish(this->get_target_humidity_state_topic_to(topic_buf), payload, len)) + (!std::isnan(this->device_->target_humidity) || mqtt::global_mqtt_client->is_publish_nan_as_none())) { + if (!this->publish_float_(this->get_target_humidity_state_topic_to(topic_buf), this->device_->target_humidity, 0)) success = false; } diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index c4cc1c7e950..d3f43f099c0 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -2,6 +2,8 @@ #ifdef USE_MQTT +#include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -178,6 +180,14 @@ bool MQTTComponent::publish(const char *topic, ProgmemStr payload) { } #endif +bool MQTTComponent::publish_float_(StringRef topic, float value, int8_t accuracy) { + if (global_mqtt_client->is_publish_nan_as_none() && std::isnan(value)) + return this->publish(topic, "None", 4); + char buf[VALUE_ACCURACY_MAX_LEN]; + size_t len = value_accuracy_to_buf(buf, value, accuracy); + return this->publish(topic, buf, len); +} + bool MQTTComponent::publish_json(const std::string &topic, const json::json_build_t &f) { return this->publish_json(topic.c_str(), f); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index f11006fef2b..9a8fe34737f 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -277,6 +277,9 @@ class MQTTComponent : public Component { protected: static constexpr uint32_t SEND_DEFER_ID = 0; + /// Publish a float formatted to accuracy, or "None" for NaN when publish_nan_as_none is set. + bool publish_float_(StringRef topic, float value, int8_t accuracy); + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index a3f7a0cf537..9db20cbec08 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -73,12 +73,7 @@ bool MQTTSensorComponent::send_initial_state() { } bool MQTTSensorComponent::publish_state(float value) { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; - if (mqtt::global_mqtt_client->is_publish_nan_as_none() && std::isnan(value)) - return this->publish(this->get_state_topic_to_(topic_buf), "None", 4); - int8_t accuracy = this->sensor_->get_accuracy_decimals(); - char buf[VALUE_ACCURACY_MAX_LEN]; - size_t len = value_accuracy_to_buf(buf, value, accuracy); - return this->publish(this->get_state_topic_to_(topic_buf), buf, len); + return this->publish_float_(this->get_state_topic_to_(topic_buf), value, this->sensor_->get_accuracy_decimals()); } } // namespace esphome::mqtt From b3633fe53cf49bc12eb7c8b297fd83f9e424020f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 16:40:25 -1000 Subject: [PATCH 28/37] [text_sensor] Publish PROGMEM strings without copying them at the call site (#21536) --- AGENTS.md | 3 ++- esphome/components/text_sensor/text_sensor.cpp | 9 +++++++++ esphome/components/text_sensor/text_sensor.h | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 450b04180f4..272584648df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,8 @@ file does, and it is the authority when they disagree. The most useful starting registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash; `TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes. * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), - logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. + logged with `get_log_str` and `LOG_STR_ARG`, published on a text sensor with + `publish_state(Table::get_progmem_str(idx, fallback))`, which copies it out of flash on ESP8266. A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`, `reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend`, `bcd_to_uint8`, `assign_bits`); pick diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 06da5c21593..780f0e34396 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -44,6 +44,15 @@ void TextSensor::publish_state(const char *state, size_t len) { #endif } +#ifdef USE_ESP8266 +void TextSensor::publish_state(ProgmemStr state) { + char buf[256]; + strncpy_P(buf, reinterpret_cast(state), sizeof(buf)); + buf[sizeof(buf) - 1] = '\0'; + this->publish_state(buf); +} +#endif + #ifdef USE_TEXT_SENSOR_FILTER void TextSensor::add_filter(Filter *filter) { // inefficient, but only happens once on every sensor setup and nobody's going to have massive amounts of diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 5041ebc4e08..fae3134a99a 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_TEXT_SENSOR_FILTER #include "esphome/components/text_sensor/filter.h" #endif @@ -41,6 +42,11 @@ class TextSensor : public EntityBase { void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); +#ifdef USE_ESP8266 + /// Publish a flash string, such as a PROGMEM_STRING_TABLE get_progmem_str(); at most 255 characters, the Home + /// Assistant state limit, are kept. + void publish_state(ProgmemStr state); +#endif #ifdef USE_TEXT_SENSOR_FILTER /// Add a filter to the filter chain. Will be appended to the back. From 4c62713eb28042f855ffc04d4a36c591ccfba278 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:46:33 -0400 Subject: [PATCH 29/37] [alarm_control_panel] Reject a code on the pending and triggered actions (#21539) --- .../alarm_control_panel/__init__.py | 14 +++++---- .../alarm_control_panel/__init__.py | 0 .../test_action_schemas.py | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/alarm_control_panel/__init__.py create mode 100644 tests/component_tests/alarm_control_panel/test_action_schemas.py diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index ac12835433d..d58d09f6215 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -91,17 +91,19 @@ def alarm_control_panel_schema( ALARM_CONTROL_PANEL_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(AlarmControlPanel), + } +) + +ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA = maybe_simple_id( { cv.GenerateID(): cv.use_id(AlarmControlPanel), cv.Optional(CONF_CODE): cv.templatable(cv.string), } ) -ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( - { - cv.GenerateID(): cv.use_id(AlarmControlPanel), - } -) +ALARM_CONTROL_PANEL_CONDITION_SCHEMA = ALARM_CONTROL_PANEL_ACTION_SCHEMA _CALLBACK_AUTOMATIONS = ( @@ -194,7 +196,7 @@ for _name, _arm in ( ): automation.register_apply_action( _name, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, + ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA, automation.ApplyCall(_arm), automation.ApplyField(CONF_CODE, "set_code", cg.std_string), call="make_call", diff --git a/tests/component_tests/alarm_control_panel/__init__.py b/tests/component_tests/alarm_control_panel/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/alarm_control_panel/test_action_schemas.py b/tests/component_tests/alarm_control_panel/test_action_schemas.py new file mode 100644 index 00000000000..54c33787054 --- /dev/null +++ b/tests/component_tests/alarm_control_panel/test_action_schemas.py @@ -0,0 +1,29 @@ +"""pending and triggered never forward a code, so their schemas must not accept one.""" + +import pytest + +from esphome import config_validation as cv +from esphome.automation import ACTION_REGISTRY +import esphome.components.alarm_control_panel # noqa: F401 + + +@pytest.mark.parametrize( + "action", ["alarm_control_panel.pending", "alarm_control_panel.triggered"] +) +def test_code_is_rejected(action: str) -> None: + with pytest.raises(cv.Invalid, match="code"): + ACTION_REGISTRY[action].schema({"id": "acp1", "code": "1234"}) + + +@pytest.mark.parametrize( + "action", + [ + "alarm_control_panel.arm_away", + "alarm_control_panel.arm_home", + "alarm_control_panel.arm_night", + "alarm_control_panel.disarm", + ], +) +def test_code_is_accepted(action: str) -> None: + config = ACTION_REGISTRY[action].schema({"id": "acp1", "code": "1234"}) + assert config["code"] == "1234" From e04dbc18815fdf022cbdc30271f0ab7e17739f76 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:54:18 -0400 Subject: [PATCH 30/37] [tests] Exercise unused options on core entity actions (#21532) --- .../alarm_control_panel/common.yaml | 6 +++ tests/components/climate/common.yaml | 21 ++++++++++ tests/components/fan/common.yaml | 7 ++++ tests/components/light/common.yaml | 38 +++++++++++++++++++ tests/components/media_player/common.yaml | 1 + tests/components/number/common.yaml | 4 ++ tests/components/select/common.yaml | 4 ++ tests/components/template/common-base.yaml | 11 ++++++ tests/components/valve/common.yaml | 6 +++ 9 files changed, 98 insertions(+) diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index f9c26ec5c9d..8dbcc485ee4 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -88,6 +88,12 @@ button: - alarm_control_panel.disarm: id: alarmcontrolpanel1 code: "1234" + - alarm_control_panel.disarm: + id: alarmcontrolpanel1 + code: !lambda return std::string("1234"); + - alarm_control_panel.arm_night: + id: alarmcontrolpanel1 + code: !lambda return std::string("0000"); - alarm_control_panel.arm_away: alarmcontrolpanel2 - alarm_control_panel.arm_home: alarmcontrolpanel2 - alarm_control_panel.arm_night: alarmcontrolpanel2 diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index c2621b18f5b..489696d588d 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -84,6 +84,27 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + - platform: template + name: "Climate Control Fan Preset Swing" + on_press: + - climate.control: + id: climate_test_thermostat + fan_mode: AUTO + preset: HOME + swing_mode: VERTICAL + target_humidity: 45 + target_temperature: 21°C + - platform: template + name: "Climate Control Enum Lambdas" + on_press: + - climate.control: + id: climate_test_thermostat + mode: !lambda return climate::CLIMATE_MODE_HEAT; + fan_mode: !lambda return climate::CLIMATE_FAN_LOW; + preset: !lambda return climate::CLIMATE_PRESET_ECO; + swing_mode: !lambda return climate::CLIMATE_SWING_OFF; + target_temperature_low: !lambda return 18.5; + target_humidity: !lambda return 50; # Custom strings take the (const char *, size_t) overload; the byte length of # the non-ASCII value is computed at codegen time. - platform: template diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index c6fb224bb87..a9f2bb7134e 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -111,6 +111,13 @@ button: oscillating: false speed: 3 direction: FORWARD + - platform: template + name: "Fan Lambda Oscillating + Direction" + on_press: + - fan.turn_on: + id: test_fan + oscillating: !lambda return true; + direction: !lambda return fan::FanDirection::REVERSE; - platform: template name: "Fan Lambda Speed" on_press: diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 938b196ec0c..8e287042645 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -116,6 +116,35 @@ esphome: - light.control: id: test_monochromatic_light state: on + - light.turn_on: + id: test_rgbww_light + color_mode: RGB_COLD_WARM_WHITE + color_brightness: 80% + cold_white: 60% + warm_white: 40% + - light.turn_on: + id: test_rgbww_light + color_mode: COLD_WARM_WHITE + cold_white: !lambda return 0.3f; + warm_white: !lambda return 0.7f; + color_brightness: !lambda return 0.5f; + - light.control: + id: test_rgbww_light + state: !lambda return true; + - light.control: + id: test_rgbw_light + color_mode: RGB_WHITE + brightness: 70% + red: 10% + green: 20% + blue: 30% + white: 50% + color_brightness: 60% + transition_length: 400ms + - light.control: + id: test_rgbw_light + white: !lambda return 0.4f; + brightness: !lambda return 0.9f; # flash_length and a turn_off with its own transition - light.turn_on: id: test_rgb_light @@ -200,6 +229,15 @@ esphome: - light.addressable_set: id: test_addressable_transition color: 0x223344 + - light.addressable_set: + id: test_addressable_transition + range_from: !lambda return 1; + range_to: !lambda return 3; + red: !lambda return 0.5f; + green: !lambda return 0.25f; + blue: !lambda return 0.75f; + color_brightness: 50% + white: 20% # Exercise light actions inside a trigger with non-empty Ts (number on_value # passes float). diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index dcd04e017ad..1fbe1e91c3e 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -17,6 +17,7 @@ media_player: - media_player.play_media: id: test_media_player media_url: http://localhost/media.mp3 + announcement: true - media_player.play_media: id: test_media_player media_url: !lambda |- diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index 6c330c2d466..0199417d17b 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -33,6 +33,10 @@ button: float next = id(number_test_number).state + 1; return next; - number.increment: number_test_number + - number.operation: + id: number_test_number + operation: increment + cycle: !lambda return true; - number.decrement: id: number_test_number cycle: false diff --git a/tests/components/select/common.yaml b/tests/components/select/common.yaml index ff41b48ff42..0f10ce5c0d9 100644 --- a/tests/components/select/common.yaml +++ b/tests/components/select/common.yaml @@ -28,6 +28,10 @@ button: id: select_test_select index: !lambda return 2; - select.next: select_test_select + - select.operation: + id: select_test_select + operation: next + cycle: !lambda return false; - select.previous: id: select_test_select cycle: false diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 49d6c7e1f75..3d0067ce71e 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -447,6 +447,17 @@ cover: - cover.control: id: template_cover_actions stop_tilt: true + - cover.control: + id: template_cover_actions + state: CLOSED + - cover.control: + id: template_cover_actions + stop: !lambda return true; + - cover.control: + id: template_cover_actions + stop_tilt: !lambda return true; + position: !lambda return 0.4f; + tilt: !lambda return 0.6f; - cover.open: template_cover_actions - cover.close: template_cover_actions - cover.stop: template_cover_actions diff --git a/tests/components/valve/common.yaml b/tests/components/valve/common.yaml index bf01f77bc9e..2a7bb6b6464 100644 --- a/tests/components/valve/common.yaml +++ b/tests/components/valve/common.yaml @@ -16,3 +16,9 @@ button: - valve.control: id: test_valve position: 50% + - valve.control: + id: test_valve + state: OPEN + - valve.control: + id: test_valve + stop: !lambda return true; From 49d4b83dd2253572c1842dcdaa0103c6be92aa19 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 23 Oct 2026 22:29:53 -0500 Subject: [PATCH 31/37] [core] Add esphome.project.sbom for SBOM supplier and contact (#21455) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/core/config.py | 52 +++++++++++++++++ esphome/espidf/sbom.py | 20 ++++++- tests/components/esphome/test.esp32-idf.yaml | 7 +++ tests/unit_tests/core/test_config.py | 59 ++++++++++++++++++++ tests/unit_tests/test_espidf_sbom.py | 58 +++++++++++++++++++ 5 files changed, 195 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index ea067a21693..eb45bb07ec0 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -4,6 +4,7 @@ from collections import Counter import logging import os from pathlib import Path +import re from esphome import automation, core import esphome.codegen as cg @@ -42,6 +43,7 @@ from esphome.const import ( CONF_PRIORITY, CONF_PROJECT, CONF_TRIGGER_ID, + CONF_URL, CONF_VERSION, CONF_WATCHDOG_TIMEOUT, KEY_CORE, @@ -68,8 +70,11 @@ from esphome.helpers import ( ) from esphome.types import ConfigType +CONF_CONTACT = "contact" CONF_LOOP_INTERVAL = "loop_interval" CONF_RELATION = "relation" +CONF_SBOM = "sbom" +CONF_SUPPLIER = "supplier" CONF_SUSPEND_LOOP = "suspend_loop" _LOGGER = logging.getLogger(__name__) @@ -258,6 +263,52 @@ def valid_project_name(value: str): return value +# The checks below match esp-idf-sbom's, which aborts on a value they reject +def _sbom_email(value: str) -> str: + value = cv.string_strict(value) + if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value) is None: + raise cv.Invalid("Expected an email address") + return value + + +def _sbom_url(value: str) -> str: + value = cv.url(value) + if not value.startswith(("http://", "https://")): + raise cv.Invalid("Expected an http or https URL") + return value + + +def _sbom_supplier(value: str) -> str: + value = cv.string_strict(value).strip() + if not value: + raise cv.Invalid("Supplier name must not be empty") + if any(c in value for c in "()\n\r"): + raise cv.Invalid("Supplier name must not contain parentheses or line breaks") + return value + + +def _sbom_platform(value: ConfigType) -> ConfigType: + # Only ESP-IDF builds produce the manifests esp-idf-sbom reads + if not CORE.is_esp32: + raise cv.Invalid( + f"SBOM details are only used on ESP32, not on {CORE.target_platform}" + ) + return value + + +SBOM_SCHEMA = cv.All( + _sbom_platform, + cv.Schema( + { + cv.Required(CONF_SUPPLIER): _sbom_supplier, + cv.Optional(CONF_CONTACT): _sbom_email, + cv.Optional(CONF_URL): _sbom_url, + } + ), + cv.has_at_least_one_key(CONF_CONTACT, CONF_URL), +) + + if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" in os.environ: _compile_process_limit_default = min( int(os.environ["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"]), get_usable_cpu_count() @@ -413,6 +464,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_VERSION): cv.All( cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH) ), + cv.Optional(CONF_SBOM): SBOM_SCHEMA, cv.Optional(CONF_ON_UPDATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( diff --git a/esphome/espidf/sbom.py b/esphome/espidf/sbom.py index 5d6c89adc13..0d7540d96d3 100644 --- a/esphome/espidf/sbom.py +++ b/esphome/espidf/sbom.py @@ -26,10 +26,12 @@ from esphome.const import ( CONF_ESPHOME, CONF_NAME, CONF_PROJECT, + CONF_URL, CONF_VERSION, __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.core.config import CONF_CONTACT, CONF_SBOM, CONF_SUPPLIER from esphome.git import GitException, run_git_command, strip_url_credentials from esphome.helpers import write_file_if_changed @@ -204,7 +206,23 @@ def _esphome_vcs() -> _Source | None: def _project_manifest() -> dict[str, Any] | None: if (project := CORE.config[CONF_ESPHOME].get(CONF_PROJECT)) is None: return None - return {"name": project[CONF_NAME], "version": project[CONF_VERSION]} + manifest: dict[str, Any] = { + "name": project[CONF_NAME], + "version": project[CONF_VERSION], + } + if (sbom := project.get(CONF_SBOM)) is None: + return manifest + organization = f"Organization: {sbom[CONF_SUPPLIER]}" + contact = sbom.get(CONF_CONTACT) + manifest["supplier"] = f"{organization} ({contact})" if contact else organization + # The manufacturer is the vulnerability contact for the SBOM document + manufacturer = {"name": organization} + if contact: + manufacturer["contact"] = contact + if url := sbom.get(CONF_URL): + manufacturer["url"] = url + manifest["document"] = {"manufacturer": manufacturer} + return manifest def _source_commit(source: ExternalSource) -> str | None: diff --git a/tests/components/esphome/test.esp32-idf.yaml b/tests/components/esphome/test.esp32-idf.yaml index 7c571c7f515..b6a87543e47 100644 --- a/tests/components/esphome/test.esp32-idf.yaml +++ b/tests/components/esphome/test.esp32-idf.yaml @@ -3,3 +3,10 @@ packages: esphome: suspend_loop: true + project: + name: esphome.test + version: "1.1" + sbom: + supplier: ESPHome Test + contact: security@example.com + url: https://example.com/security diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index f2fa1ce33fc..2b6e89f4063 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -31,6 +31,7 @@ from esphome.core.config import ( CONF_RELATION, CONF_SUSPEND_LOOP, DEVICE_RELATIONS, + SBOM_SCHEMA, SHARED_DEVICE_RELATION_DEFINES, Area, make_app_name_cpp, @@ -797,6 +798,64 @@ def test_includes_in_a_package_resolve_next_to_the_package( assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")] +@pytest.fixture(name="esp32_target") +def fixture_esp32_target() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + + +@pytest.mark.usefixtures("esp32_target") +@pytest.mark.parametrize( + "value", + [ + {"supplier": "Acme Corp", "contact": "psirt@acme.example"}, + {"supplier": "Acme Corp", "url": "https://acme.example/security"}, + { + "supplier": "Acme Corp", + "contact": "psirt@acme.example", + "url": "http://acme.example", + }, + ], +) +def test_sbom_schema_valid(value: dict[str, str]) -> None: + assert SBOM_SCHEMA(value) == value + + +@pytest.mark.usefixtures("esp32_target") +def test_sbom_schema_strips_supplier() -> None: + config = SBOM_SCHEMA({"supplier": " Acme Corp ", "contact": "a@b.c"}) + assert config["supplier"] == "Acme Corp" + + +@pytest.mark.usefixtures("esp32_target") +@pytest.mark.parametrize( + ("value", "match"), + [ + ({"supplier": "Acme Corp"}, "at least one of contact, url"), + ({"supplier": "", "contact": "a@b.c"}, "must not be empty"), + ({"supplier": " ", "contact": "a@b.c"}, "must not be empty"), + ({"supplier": "Acme\nCorp", "contact": "a@b.c"}, "line breaks"), + ({"contact": "psirt@acme.example"}, "required key not provided"), + ({"supplier": "Acme (EU)", "contact": "a@b.c"}, "parentheses"), + ({"supplier": "Acme", "contact": "psirt"}, "email address"), + ({"supplier": "Acme", "contact": "a b@c.d"}, "email address"), + ({"supplier": "Acme", "url": "file:///security.txt"}, "http or https"), + ({"supplier": "Acme", "url": "acme.example"}, "URL scheme"), + ], +) +def test_sbom_schema_invalid(value: dict[str, str], match: str) -> None: + """Values esp-idf-sbom would abort on are rejected during validation.""" + with pytest.raises(cv.Invalid, match=match): + SBOM_SCHEMA(value) + + +@pytest.mark.parametrize("platform", ["esp8266", "rp2040", "bk72xx", "host"]) +def test_sbom_schema_rejects_other_platforms(platform: str) -> None: + """Only ESP32 builds use it, so elsewhere it must not look like it works.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + with pytest.raises(cv.Invalid, match=f"only used on ESP32, not on {platform}"): + SBOM_SCHEMA({"supplier": "Acme Corp", "contact": "a@b.c"}) + + 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" diff --git a/tests/unit_tests/test_espidf_sbom.py b/tests/unit_tests/test_espidf_sbom.py index 2d65f2c42ad..bf12accc3e9 100644 --- a/tests/unit_tests/test_espidf_sbom.py +++ b/tests/unit_tests/test_espidf_sbom.py @@ -204,6 +204,64 @@ def test_project_manifest(build_tree: Path) -> None: assert not (build_tree / "sbom.yml").exists() +def test_project_manifest_sbom(build_tree: Path) -> None: + """Product details become the supplier and the document's manufacturer.""" + CORE.config[CONF_ESPHOME][CONF_PROJECT] = { + CONF_NAME: "acme.thermostat", + CONF_VERSION: "1.2.3", + "sbom": { + "supplier": "Acme Corp", + "contact": "psirt@acme.example", + "url": "https://acme.example/security", + }, + } + sbom.write_manifests() + assert _read(build_tree / "sbom.yml") == { + "name": "acme.thermostat", + "version": "1.2.3", + "supplier": "Organization: Acme Corp (psirt@acme.example)", + "document": { + "manufacturer": { + "name": "Organization: Acme Corp", + "contact": "psirt@acme.example", + "url": "https://acme.example/security", + } + }, + } + + +def test_project_manifest_sbom_url_only(build_tree: Path) -> None: + CORE.config[CONF_ESPHOME][CONF_PROJECT] = { + CONF_NAME: "acme.thermostat", + CONF_VERSION: "1.2.3", + "sbom": {"supplier": "Acme Corp", "url": "https://acme.example"}, + } + sbom.write_manifests() + manifest = _read(build_tree / "sbom.yml") + assert manifest["supplier"] == "Organization: Acme Corp" + assert manifest["document"] == { + "manufacturer": { + "name": "Organization: Acme Corp", + "url": "https://acme.example", + } + } + + +def test_project_manifest_sbom_contact_only(build_tree: Path) -> None: + CORE.config[CONF_ESPHOME][CONF_PROJECT] = { + CONF_NAME: "acme.thermostat", + CONF_VERSION: "1.2.3", + "sbom": {"supplier": "Acme Corp", "contact": "psirt@acme.example"}, + } + sbom.write_manifests() + assert _read(build_tree / "sbom.yml")["document"] == { + "manufacturer": { + "name": "Organization: Acme Corp", + "contact": "psirt@acme.example", + } + } + + def test_external_components(build_tree: Path, tmp_path: Path) -> None: """Each loaded external component becomes its own package inside src.""" git_dir = tmp_path / "repo" / "components" From d515e8b2e30604b4567e5814b21ecc2aa8fb1a79 Mon Sep 17 00:00:00 2001 From: Guillermo Ruffino Date: Sat, 24 Oct 2026 00:31:36 -0300 Subject: [PATCH 32/37] [sim800l] Add on_dtmf_received trigger (#21495) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/sim800l/__init__.py | 7 ++++++ esphome/components/sim800l/sim800l.cpp | 31 +++++++++++++++++++++++++- esphome/components/sim800l/sim800l.h | 12 +++++++++- tests/components/sim800l/common.yaml | 4 ++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 2500e4684bf..1b2c33727d7 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -18,6 +18,7 @@ CONF_ON_USSD_RECEIVED = "on_ussd_received" CONF_ON_INCOMING_CALL = "on_incoming_call" CONF_ON_CALL_CONNECTED = "on_call_connected" CONF_ON_CALL_DISCONNECTED = "on_call_disconnected" +CONF_ON_DTMF_RECEIVED = "on_dtmf_received" CONF_RECIPIENT = "recipient" CONF_USSD = "ussd" @@ -30,6 +31,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CALL_CONNECTED): automation.validate_automation({}), cv.Optional(CONF_ON_CALL_DISCONNECTED): automation.validate_automation({}), cv.Optional(CONF_ON_USSD_RECEIVED): automation.validate_automation({}), + cv.Optional(CONF_ON_DTMF_RECEIVED): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("5s")) @@ -62,6 +64,11 @@ _CALLBACK_AUTOMATIONS = ( "add_on_ussd_received_callback", [(cg.std_string, "ussd")], ), + automation.CallbackAutomation( + CONF_ON_DTMF_RECEIVED, + "add_on_dtmf_received_callback", + [(cg.std_string, "key")], + ), ) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 78f1bc8fae3..5e8fce6e1e5 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -14,6 +14,8 @@ void Sim800LComponent::update() { if (this->watch_dog_++ == 2) { this->state_ = STATE_INIT; this->write(26); + // The module may have restarted, enable DTMF detection again + this->dtmf_ready_ = false; } if (this->expect_ack_) @@ -82,6 +84,15 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->call_state_ = 6; this->call_disconnected_callback_.call(); } + } else if (message.starts_with("+DTMF:")) { + // Unsolicited DTMF key report: "+DTMF: [,]" + size_t start = message.find_first_not_of(' ', 6); + if (start != std::string::npos) { + std::string key = message.substr(start, message.find(',', start) - start); + ESP_LOGD(TAG, "DTMF received: %s", key.c_str()); + this->dtmf_received_callback_.call(key); + } + return; } } @@ -93,6 +104,14 @@ void Sim800LComponent::parse_cmd_(std::string message) { // Expected ack but AT echo received this->state_ = STATE_DISABLE_ECHO; this->expect_ack_ = true; + } else if (this->state_ == STATE_DDET_SENT) { + if (message != "ERROR" && !message.starts_with("+CME ERROR:")) { + // Unrelated unsolicited line, keep waiting for the DDET response + this->expect_ack_ = true; + return; + } + // Module firmware without DTMF support, carry on with the setup sequence + ESP_LOGW(TAG, "DTMF detection not supported by the module: %s", message.c_str()); } else { ESP_LOGW(TAG, "Not ack. %d %s", this->state_, message.c_str()); this->state_ = STATE_IDLE; // Let it timeout @@ -145,7 +164,12 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; case STATE_SETUP_CLIP: send_cmd_("AT+CLIP=1"); - this->state_ = STATE_CREG; + this->state_ = this->dtmf_enabled_ && !this->dtmf_ready_ ? STATE_SETUP_DDET : STATE_CREG; + this->expect_ack_ = true; + break; + case STATE_SETUP_DDET: + send_cmd_("AT+DDET=1"); + this->state_ = STATE_DDET_SENT; this->expect_ack_ = true; break; case STATE_SETUP_USSD: @@ -188,6 +212,11 @@ void Sim800LComponent::parse_cmd_(std::string message) { if (message == "OK") this->state_ = STATE_INIT; break; + case STATE_DDET_SENT: + // Tried once per module reset; firmware without DDET only logs a warning, and a passing error is retried + // after the next reset + this->dtmf_ready_ = true; + [[fallthrough]]; case STATE_CREG: send_cmd_("AT+CREG?"); this->state_ = STATE_CREG_WAIT; diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index a6f9544781d..1dff373235d 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -44,7 +44,9 @@ enum State { STATE_SEND_USSD1, STATE_SEND_USSD2, STATE_CHECK_USSD, - STATE_RECEIVED_USSD + STATE_RECEIVED_USSD, + STATE_SETUP_DDET, + STATE_DDET_SENT }; class Sim800LComponent final : public uart::UARTDevice, public PollingComponent { @@ -76,6 +78,11 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent template void add_on_ussd_received_callback(F &&callback) { this->ussd_received_callback_.add(std::forward(callback)); } + template void add_on_dtmf_received_callback(F &&callback) { + // DTMF detection is only enabled on the module when someone listens for it + this->dtmf_enabled_ = true; + this->dtmf_received_callback_.add(std::forward(callback)); + } void send_sms(const std::string &recipient, const std::string &message); void send_ussd(const std::string &ussd_code); void dial(const std::string &recipient); @@ -111,6 +118,8 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent bool connect_pending_{false}; bool disconnect_pending_{false}; bool send_ussd_pending_{false}; + bool dtmf_enabled_{false}; + bool dtmf_ready_{false}; uint8_t call_state_{6}; CallbackManager sms_received_callback_; @@ -118,6 +127,7 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent CallbackManager call_connected_callback_; CallbackManager call_disconnected_callback_; CallbackManager ussd_received_callback_; + CallbackManager dtmf_received_callback_; }; } // namespace esphome::sim800l diff --git a/tests/components/sim800l/common.yaml b/tests/components/sim800l/common.yaml index 12603e9e06f..cc454415240 100644 --- a/tests/components/sim800l/common.yaml +++ b/tests/components/sim800l/common.yaml @@ -29,6 +29,10 @@ sim800l: - sim800l.disconnect on_ussd_received: - logger.log: "ussd_received" + on_dtmf_received: + - logger.log: + format: "DTMF key '%s'" + args: ["key.c_str()"] on_call_connected: - logger.log: "call connected" on_call_disconnected: From 3c1e53628f903d1eecb07de6e20b09ae1547057a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E9=B3=B3=E9=A7=BF?= Date: Sat, 24 Oct 2026 11:32:27 +0800 Subject: [PATCH 33/37] [hitachi_ac344] Add mildew-proof switch (#18230) 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 --- .../hitachi_ac344/hitachi_ac344.cpp | 52 +++++++++++++++++-- .../components/hitachi_ac344/hitachi_ac344.h | 17 ++++++ .../hitachi_ac344/switch/__init__.py | 30 +++++++++++ tests/components/hitachi_ac344/common.yaml | 7 +++ 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 esphome/components/hitachi_ac344/switch/__init__.py diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index 9173ca5f467..c47c82d7568 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -1,6 +1,8 @@ #include "hitachi_ac344.h" #include "esphome/core/helpers.h" +#include + namespace esphome::hitachi_ac344 { ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac344"); @@ -140,7 +142,42 @@ uint8_t HitachiClimate::get_button_() { return remote_state_[HITACHI_AC344_BUTTO void HitachiClimate::set_button_(uint8_t button) { remote_state_[HITACHI_AC344_BUTTON_BYTE] = button; } -void HitachiClimate::transmit_state() { +bool HitachiClimate::get_mildew_proof() const { + return GETBIT8(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], HITACHI_AC344_MILDEWPROOF_OFFSET); +} + +void HitachiClimate::set_mildew_proof(bool on) { + // A repeated write resends the frame, so a missed burst can be retried + assign_bits(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], 1 << HITACHI_AC344_MILDEWPROOF_OFFSET, on); + this->transmit_state_(HITACHI_AC344_BUTTON_MILDEWPROOF); + this->publish_mildew_proof_(); +} + +void HitachiClimate::publish_mildew_proof_() { +#ifdef USE_SWITCH + if (this->mildew_proof_switch_ != nullptr) + this->mildew_proof_switch_->publish_state(this->get_mildew_proof()); +#endif +} + +void HitachiClimate::setup() { + climate_ir::ClimateIR::setup(); +#ifdef USE_SWITCH + // The restored setting rides along with the next frame; nothing is sent at boot. The restored value is the switch + // state, which an inverted switch flips relative to the frame bit. + if (this->mildew_proof_switch_ != nullptr) { + if (auto initial = this->mildew_proof_switch_->get_initial_state_with_restore_mode()) { + assign_bits(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], 1 << HITACHI_AC344_MILDEWPROOF_OFFSET, + *initial != this->mildew_proof_switch_->is_inverted()); + } + } +#endif + this->publish_mildew_proof_(); +} + +void HitachiClimate::transmit_state() { this->transmit_state_(HITACHI_AC344_BUTTON_POWER); } + +void HitachiClimate::transmit_state_(uint8_t button) { switch (this->mode) { case climate::CLIMATE_MODE_COOL: set_mode_(HITACHI_AC344_MODE_COOL); @@ -201,8 +238,12 @@ void HitachiClimate::transmit_state() { break; } - // TODO: find change value to set button, now always set to power button - set_button_(HITACHI_AC344_BUTTON_POWER); + // TODO: climate changes always send the power button; find the per-change button code. + set_button_(button); + + // The remote sends this when powering off with mildew proof on + if (this->mode == climate::CLIMATE_MODE_OFF && this->get_mildew_proof()) + remote_state_[9] = 0xA5; invert_byte_pairs(remote_state_ + 3, HITACHI_AC344_STATE_LENGTH - 3); @@ -345,9 +386,10 @@ bool HitachiClimate::on_receive(remote_base::RemoteReceiveData data) { this->parse_fan_(recv_state); // parse swingv this->parse_swing_(recv_state); + // Copy first so an on_state automation transmits from the received frame + memcpy(remote_state_, recv_state, HITACHI_AC344_STATE_LENGTH); this->publish_state(); - for (uint8_t i = 0; i < HITACHI_AC344_STATE_LENGTH; i++) - remote_state_[i] = recv_state[i]; + this->publish_mildew_proof_(); return true; } diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index c5773ac222b..e634f6a7d61 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -2,6 +2,9 @@ #include "esphome/core/log.h" #include "esphome/components/climate_ir/climate_ir.h" +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif namespace esphome::hitachi_ac344 { @@ -82,6 +85,14 @@ class HitachiClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL}) {} + bool get_mildew_proof() const; + /// Mildew proof keeps the fan running after power off to dry the unit + void set_mildew_proof(bool on); +#ifdef USE_SWITCH + void set_mildew_proof_switch(switch_::Switch *sw) { this->mildew_proof_switch_ = sw; } +#endif + + void setup() override; protected: uint8_t remote_state_[HITACHI_AC344_STATE_LENGTH]{0x01, 0x10, 0x00, 0x40, 0x00, 0xFF, 0x00, 0xCC, 0x00, 0x00, 0x00, @@ -89,8 +100,14 @@ class HitachiClimate final : public climate_ir::ClimateIR { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t previous_temp_{27}; +#ifdef USE_SWITCH + switch_::Switch *mildew_proof_switch_{nullptr}; +#endif // Transmit via IR the state of this climate controller. void transmit_state() override; + // The frame with the button byte the remote sends for this change + void transmit_state_(uint8_t button); + void publish_mildew_proof_(); bool get_power_(); void set_power_(bool on); uint8_t get_mode_(); diff --git a/esphome/components/hitachi_ac344/switch/__init__.py b/esphome/components/hitachi_ac344/switch/__init__.py new file mode 100644 index 00000000000..62d454f0a92 --- /dev/null +++ b/esphome/components/hitachi_ac344/switch/__init__.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import HitachiClimate + +CONF_HITACHI_AC344_ID = "hitachi_ac344_id" +CONF_MILDEW_PROOF = "mildew_proof" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HITACHI_AC344_ID): cv.use_id(HitachiClimate), + cv.Required(CONF_MILDEW_PROOF): switch.switch_schema( + switch.ApplySwitch, + icon="mdi:shield-check-outline", + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_OFF", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HITACHI_AC344_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_MILDEW_PROOF, hub.set_mildew_proof_switch, apply="set_mildew_proof(state)" + ) diff --git a/tests/components/hitachi_ac344/common.yaml b/tests/components/hitachi_ac344/common.yaml index 797e7875e57..02f7297c2b3 100644 --- a/tests/components/hitachi_ac344/common.yaml +++ b/tests/components/hitachi_ac344/common.yaml @@ -1,4 +1,11 @@ climate: - platform: hitachi_ac344 + id: hitachi_climate name: Hitachi Climate transmitter_id: xmitr + +switch: + - platform: hitachi_ac344 + hitachi_ac344_id: hitachi_climate + mildew_proof: + name: Hitachi Mildew Proof From bb07d04c9027dfd25eb7f548e728244ba44bc49d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 17:41:33 -1000 Subject: [PATCH 34/37] [tests] Drive the clock in the UDP OTA no-progress test (#21192) --- tests/unit_tests/test_espota2.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9b599717ed9..3f6a946eb4f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1782,14 +1782,18 @@ 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") + # A hand-driven clock, so a slow runner cannot pass the timeout before the first send + now = [100.0] with ( + patch.object(espota2, "time", Mock(monotonic=lambda: now[0])), patch("select.select", return_value=([], [], [])), - pytest.raises(espota2.OTANetworkError, match="no progress.*full"), ): - while True: + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.settimeout(0.2) + channel.sendall(b"x") + channel._pump() + now[0] += 1.0 + with pytest.raises(espota2.OTANetworkError, match="no progress.*full"): channel._pump() From 253db1e0d534d20ef8d0806d1af09950f4466cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E9=B3=B3=E9=A7=BF?= Date: Sat, 24 Oct 2026 11:50:48 +0800 Subject: [PATCH 35/37] [hitachi_ac344] Add quiet and vertical swing mode (#18229) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/climate_ir/climate_ir.cpp | 5 +- esphome/components/climate_ir/climate_ir.h | 4 + .../hitachi_ac344/hitachi_ac344.cpp | 74 ++++----- .../components/hitachi_ac344/hitachi_ac344.h | 10 +- tests/components/hitachi_ac344/__init__.py | 6 + .../hitachi_ac344/hitachi_ac344_test.cpp | 148 ++++++++++++++++++ 6 files changed, 195 insertions(+), 52 deletions(-) create mode 100644 tests/components/hitachi_ac344/__init__.py create mode 100644 tests/components/hitachi_ac344/hitachi_ac344_test.cpp diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index 622aff243cd..3cf8222ab97 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -24,7 +24,9 @@ climate::ClimateTraits ClimateIR::traits() { return traits; } -void ClimateIR::setup() { +void ClimateIR::setup() { this->setup_(this->restore_state_()); } + +void ClimateIR::setup_(optional restore) { if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { this->current_temperature = state; @@ -43,7 +45,6 @@ void ClimateIR::setup() { } // restore set points - auto restore = this->restore_state_(); if (restore.has_value()) { restore->apply(this); } else { diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 060564b4ba3..20dd49d28c8 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -60,6 +60,10 @@ class ClimateIR : public Component, /// Transmit via IR the state of this climate controller. virtual void transmit_state() = 0; + /// setup() with the restored state already loaded, for subclasses that read it first. Load it only once: + /// every restore_state_() call takes a new preference slot on some platforms. + void setup_(optional restore); + // Dummy implement on_receive so implementation is optional for inheritors bool on_receive(remote_base::RemoteReceiveData data) override { return false; }; diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index c47c82d7568..8bff0cfa072 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -103,21 +103,7 @@ void HitachiClimate::set_fan_(uint8_t speed) { remote_state_[29] = 0x01; } -void HitachiClimate::set_swing_v_toggle_(bool on) { - uint8_t button = get_button_(); // Get the current button value. - if (on) { - button = HITACHI_AC344_BUTTON_SWINGV; // Set the button to SwingV. - } else if (button == HITACHI_AC344_BUTTON_SWINGV) { // Asked to unset it - // It was set previous, so use Power as a default - button = HITACHI_AC344_BUTTON_POWER; - } - set_button_(button); -} - -bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC344_BUTTON_SWINGV; } - void HitachiClimate::set_swing_v_(bool on) { - set_swing_v_toggle_(on); // Set the button value. assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on); } @@ -131,17 +117,18 @@ void HitachiClimate::set_swing_h_(uint8_t position) { return; } set_bits(&remote_state_[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE, position); - set_button_(HITACHI_AC344_BUTTON_SWINGH); } uint8_t HitachiClimate::get_swing_h_() { return GETBITS8(remote_state_[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE); } -uint8_t HitachiClimate::get_button_() { return remote_state_[HITACHI_AC344_BUTTON_BYTE]; } - void HitachiClimate::set_button_(uint8_t button) { remote_state_[HITACHI_AC344_BUTTON_BYTE] = button; } +static bool swing_vertical(climate::ClimateSwingMode mode) { + return mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH; +} + bool HitachiClimate::get_mildew_proof() const { return GETBIT8(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], HITACHI_AC344_MILDEWPROOF_OFFSET); } @@ -161,10 +148,14 @@ void HitachiClimate::publish_mildew_proof_() { } void HitachiClimate::setup() { - climate_ir::ClimateIR::setup(); + // Seed the frame before the base publishes the restored state; an on_state automation may transmit from that + // callback. Nothing is sent at boot, the restored settings ride along with the next frame. + auto restore = this->restore_state_(); + if (restore.has_value()) { + set_swing_v_(swing_vertical(restore->swing_mode)); + } #ifdef USE_SWITCH - // The restored setting rides along with the next frame; nothing is sent at boot. The restored value is the switch - // state, which an inverted switch flips relative to the frame bit. + // The restored value is the switch state, which an inverted switch flips relative to the frame bit if (this->mildew_proof_switch_ != nullptr) { if (auto initial = this->mildew_proof_switch_->get_initial_state_with_restore_mode()) { assign_bits(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], 1 << HITACHI_AC344_MILDEWPROOF_OFFSET, @@ -172,6 +163,7 @@ void HitachiClimate::setup() { } } #endif + this->setup_(restore); this->publish_mildew_proof_(); } @@ -204,6 +196,9 @@ void HitachiClimate::transmit_state_(uint8_t button) { set_temp_(static_cast(this->target_temperature)); switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { + case climate::CLIMATE_FAN_QUIET: + set_fan_(HITACHI_AC344_FAN_MIN); + break; case climate::CLIMATE_FAN_LOW: set_fan_(HITACHI_AC344_FAN_LOW); break; @@ -219,27 +214,14 @@ void HitachiClimate::transmit_state_(uint8_t button) { set_fan_(HITACHI_AC344_FAN_AUTO); } - switch (this->swing_mode) { - case climate::CLIMATE_SWING_BOTH: - set_swing_v_(true); - set_swing_h_(HITACHI_AC344_SWINGH_AUTO); - break; - case climate::CLIMATE_SWING_VERTICAL: - set_swing_v_(true); - set_swing_h_(HITACHI_AC344_SWINGH_MIDDLE); - break; - case climate::CLIMATE_SWING_HORIZONTAL: - set_swing_v_(false); - set_swing_h_(HITACHI_AC344_SWINGH_AUTO); - break; - case climate::CLIMATE_SWING_OFF: - set_swing_v_(false); - set_swing_h_(HITACHI_AC344_SWINGH_MIDDLE); - break; - } - - // TODO: climate changes always send the power button; find the per-change button code. - set_button_(button); + const bool swing_v = swing_vertical(this->swing_mode); + const bool swing_h = + this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL || this->swing_mode == climate::CLIMATE_SWING_BOTH; + set_swing_h_(swing_h ? HITACHI_AC344_SWINGH_AUTO : HITACHI_AC344_SWINGH_MIDDLE); + // The unit ignores the swing bit and toggles on the swing button, so only a vertical swing change presses it; + // everything else sends the given button + set_button_(swing_v != get_swing_v_() ? HITACHI_AC344_BUTTON_SWINGV : button); + set_swing_v_(swing_v); // The remote sends this when powering off with mildew proof on if (this->mode == climate::CLIMATE_MODE_OFF && this->get_mildew_proof()) @@ -316,6 +298,8 @@ bool HitachiClimate::parse_fan_(const uint8_t remote_state[]) { ESP_LOGV(TAG, "Fan: %02X %02X", remote_state[HITACHI_AC344_FAN_BYTE], fan_mode); switch (fan_mode) { case HITACHI_AC344_FAN_MIN: + this->fan_mode = climate::CLIMATE_FAN_QUIET; + break; case HITACHI_AC344_FAN_LOW: this->fan_mode = climate::CLIMATE_FAN_LOW; break; @@ -339,11 +323,13 @@ bool HitachiClimate::parse_swing_(const uint8_t remote_state[]) { uint8_t swing_modeh = GETBITS8(remote_state[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE); ESP_LOGV(TAG, "SwingH: %02X %02X", remote_state[HITACHI_AC344_SWINGH_BYTE], swing_modeh); + bool swing_v = GETBIT8(remote_state[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET); + ESP_LOGV(TAG, "SwingV: %02X %s", remote_state[HITACHI_AC344_SWINGV_BYTE], ONOFF(swing_v)); - if ((swing_modeh & 0x3) == 0x3) { - this->swing_mode = climate::CLIMATE_SWING_OFF; + if (swing_modeh != HITACHI_AC344_SWINGH_AUTO) { + this->swing_mode = swing_v ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; } else { - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + this->swing_mode = swing_v ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_HORIZONTAL; } return true; diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index e634f6a7d61..0f7004a026d 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -82,9 +82,10 @@ class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true, - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_HIGH}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL}) {} + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} bool get_mildew_proof() const; /// Mildew proof keeps the fan running after power off to dry the unit void set_mildew_proof(bool on); @@ -115,13 +116,10 @@ class HitachiClimate final : public climate_ir::ClimateIR { void set_temp_(uint8_t celsius, bool set_previous = true); uint8_t get_fan_(); void set_fan_(uint8_t speed); - void set_swing_v_toggle_(bool on); - bool get_swing_v_toggle_(); void set_swing_v_(bool on); bool get_swing_v_(); void set_swing_h_(uint8_t position); uint8_t get_swing_h_(); - uint8_t get_button_(); void set_button_(uint8_t button); // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; diff --git a/tests/components/hitachi_ac344/__init__.py b/tests/components/hitachi_ac344/__init__.py new file mode 100644 index 00000000000..8e15f6d479b --- /dev/null +++ b/tests/components/hitachi_ac344/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/hitachi_ac344/hitachi_ac344_test.cpp b/tests/components/hitachi_ac344/hitachi_ac344_test.cpp new file mode 100644 index 00000000000..683e6731a4f --- /dev/null +++ b/tests/components/hitachi_ac344/hitachi_ac344_test.cpp @@ -0,0 +1,148 @@ +#include +#include +#include "esphome/components/hitachi_ac344/hitachi_ac344.h" + +namespace esphome::hitachi_ac344::testing { + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + const remote_base::RemoteTransmitData &data() const { return this->temp_; } + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override {} +}; + +class HitachiSwingTest : public ::testing::Test { + protected: + void SetUp() override { + this->climate_.set_transmitter(&this->transmitter_); + this->climate_.mode = climate::CLIMATE_MODE_COOL; + this->climate_.target_temperature = 24; + this->climate_.fan_mode = climate::CLIMATE_FAN_AUTO; + this->climate_.swing_mode = climate::CLIMATE_SWING_OFF; + } + + // The byte the frame carries at the given position: the header takes two timings and every bit two more. + uint8_t frame_byte_(size_t index) const { + const auto &timings = this->transmitter_.data().get_data(); + uint8_t value = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + if (timings[2 + (index * 8 + bit) * 2 + 1] == -static_cast(HITACHI_AC344_ONE_SPACE)) { + value |= 1 << bit; + } + } + return value; + } + + uint8_t button_after_swing_(climate::ClimateSwingMode mode) { + auto call = this->climate_.make_call(); + call.set_swing_mode(mode); + call.perform(); + return this->frame_byte_(HITACHI_AC344_BUTTON_BYTE); + } + + // Feed the unit's own frame to the receiver: header, 43 bytes least significant bit first, footer mark. + void receive_(const std::array &frame) { + remote_base::RawTimings timings{HITACHI_AC344_HDR_MARK, -static_cast(HITACHI_AC344_HDR_SPACE)}; + for (uint8_t byte : frame) { + for (uint8_t bit = 0; bit < 8; bit++) { + timings.push_back(HITACHI_AC344_BIT_MARK); + timings.push_back( + -static_cast(byte & (1 << bit) ? HITACHI_AC344_ONE_SPACE : HITACHI_AC344_ZERO_SPACE)); + } + } + timings.push_back(HITACHI_AC344_BIT_MARK); + remote_base::RemoteReceiveData data(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(this->climate_).on_receive(data)); + } + + HitachiClimate climate_; + CapturingTransmitter transmitter_; +}; + +TEST_F(HitachiSwingTest, VerticalSwingIsPressedOnlyWhenItChanges) { + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_POWER); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_BOTH), HITACHI_AC344_BUTTON_POWER); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_HORIZONTAL), HITACHI_AC344_BUTTON_SWINGV); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_OFF), HITACHI_AC344_BUTTON_POWER); +} + +TEST_F(HitachiSwingTest, HorizontalSwingTravelsInTheFrameBits) { + this->button_after_swing_(climate::CLIMATE_SWING_HORIZONTAL); + EXPECT_EQ(this->frame_byte_(HITACHI_AC344_SWINGH_BYTE) & 0x07, HITACHI_AC344_SWINGH_AUTO); + this->button_after_swing_(climate::CLIMATE_SWING_OFF); + EXPECT_EQ(this->frame_byte_(HITACHI_AC344_SWINGH_BYTE) & 0x07, HITACHI_AC344_SWINGH_MIDDLE); +} + +TEST_F(HitachiSwingTest, ReceivedFrameResyncsTheVerticalSwingState) { + // A cooling frame from the remote with vertical swing on: power on, mode cool, swing bit set. + std::array frame{}; + frame[HITACHI_AC344_POWER_BYTE] = HITACHI_AC344_POWER_ON; + frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_AUTO << 4); + frame[HITACHI_AC344_TEMP_BYTE] = 24 << HITACHI_AC344_TEMP_OFFSET; + frame[HITACHI_AC344_SWINGH_BYTE] = HITACHI_AC344_SWINGH_MIDDLE; + frame[HITACHI_AC344_SWINGV_BYTE] = 1 << HITACHI_AC344_SWINGV_OFFSET; + // An on_state automation that transmits from the publish inside on_receive must already see the received frame, + // so its temperature change goes out as a power press and not another swing press. + bool transmitted = false; + uint8_t button_from_callback = 0; + this->climate_.add_on_state_callback([&](climate::Climate &) { + if (transmitted) + return; + transmitted = true; + auto call = this->climate_.make_call(); + call.set_target_temperature(22); + call.perform(); + button_from_callback = this->frame_byte_(HITACHI_AC344_BUTTON_BYTE); + }); + this->receive_(frame); + EXPECT_EQ(this->climate_.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_TRUE(transmitted); + EXPECT_EQ(button_from_callback, HITACHI_AC344_BUTTON_POWER); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_OFF), HITACHI_AC344_BUTTON_SWINGV); +} + +TEST_F(HitachiSwingTest, MildewProofSendsItsOwnButtonAndKeepsTheSwingState) { + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV); + this->climate_.set_mildew_proof(true); + EXPECT_EQ(this->frame_byte_(HITACHI_AC344_BUTTON_BYTE), HITACHI_AC344_BUTTON_MILDEWPROOF); + EXPECT_TRUE(this->climate_.get_mildew_proof()); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_POWER); +} + +TEST_F(HitachiSwingTest, QuietFanUsesTheMinimumSpeedBothWays) { + auto call = this->climate_.make_call(); + call.set_fan_mode(climate::CLIMATE_FAN_QUIET); + call.perform(); + EXPECT_EQ(this->frame_byte_(HITACHI_AC344_FAN_BYTE) >> 4, HITACHI_AC344_FAN_MIN); + + std::array frame{}; + frame[HITACHI_AC344_POWER_BYTE] = HITACHI_AC344_POWER_ON; + frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_HIGH << 4); + frame[HITACHI_AC344_TEMP_BYTE] = 24 << HITACHI_AC344_TEMP_OFFSET; + frame[HITACHI_AC344_SWINGH_BYTE] = HITACHI_AC344_SWINGH_MIDDLE; + this->receive_(frame); + EXPECT_EQ(this->climate_.fan_mode, climate::CLIMATE_FAN_HIGH); + frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_MIN << 4); + this->receive_(frame); + EXPECT_EQ(this->climate_.fan_mode, climate::CLIMATE_FAN_QUIET); +} + +TEST_F(HitachiSwingTest, RestoredVerticalSwingSeedsTheTrackedState) { + static_cast(this->climate_).setup(); + EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV); + + // After a reboot the unit still swings vertically, so a temperature change must not press the swing button. + HitachiClimate rebooted; + rebooted.set_transmitter(&this->transmitter_); + static_cast(rebooted).setup(); + EXPECT_EQ(rebooted.swing_mode, climate::CLIMATE_SWING_VERTICAL); + auto call = rebooted.make_call(); + call.set_target_temperature(22); + call.perform(); + EXPECT_EQ(this->frame_byte_(HITACHI_AC344_BUTTON_BYTE), HITACHI_AC344_BUTTON_POWER); +} + +} // namespace esphome::hitachi_ac344::testing From 08d231991b0c8b21eaf71129ea1c35443da99201 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 23 Oct 2026 22:55:48 -0500 Subject: [PATCH 36/37] [core] Tighten SBOM source detection for local installs (#21529) Co-authored-by: Claude Opus 5.5 (1M context) --- docker/Dockerfile | 2 ++ esphome/espidf/component.py | 15 ++++++--- esphome/espidf/sbom.py | 32 +++++++++++------- tests/unit_tests/test_espidf_component.py | 19 +++++++++-- tests/unit_tests/test_espidf_sbom.py | 40 ++++++++++++++++++++--- 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6c2ab7bae84..a6211cd000b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -84,3 +84,5 @@ FROM base-${BUILD_TYPE} AS final # Copy esphome and install COPY . /esphome RUN uv pip install --no-cache-dir -e /esphome +# The tree has no .git; lets SBOM manifests describe it as the release it was built from +ENV ESPHOME_IS_DOCKER_IMAGE=true diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 1a4722dadbf..854f60e8a30 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -309,11 +309,16 @@ def _write_sbom_yml(component: IDFComponent) -> None: target.unlink() -def _remove_generated_sbom_yml(target: Path) -> None: - """Remove ``target`` if ESPHome wrote it, so a failed run leaves no stale version.""" +def _remove_stale_sbom_yml(component: IDFComponent) -> None: + """Remove the sbom.yml ESPHome wrote or copied, so a failed run leaves no stale one.""" + target = component.path / "sbom.yml" with contextlib.suppress(OSError, ValueError): - if target.read_text(encoding="utf-8").startswith(SBOM_YML_HEADER): - target.unlink() + # A local library's build dir holds only ESPHome's files; elsewhere the + # library may ship its own, which only the header tells apart + if component.source_dir.resolve() != component.path.resolve() or ( + target.read_text(encoding="utf-8").startswith(SBOM_YML_HEADER) + ): + target.unlink(missing_ok=True) def _emit_idf_component(component: IDFComponent) -> None: @@ -341,7 +346,7 @@ def _emit_idf_component(component: IDFComponent) -> None: _LOGGER.warning( "SBOM: could not write sbom.yml for %s: %s", component.name, err ) - _remove_generated_sbom_yml(component.path / "sbom.yml") + _remove_stale_sbom_yml(component) def generate_idf_components( diff --git a/esphome/espidf/sbom.py b/esphome/espidf/sbom.py index 0d7540d96d3..f682ba8a3b2 100644 --- a/esphome/espidf/sbom.py +++ b/esphome/espidf/sbom.py @@ -33,7 +33,7 @@ from esphome.const import ( from esphome.core import CORE from esphome.core.config import CONF_CONTACT, CONF_SBOM, CONF_SUPPLIER from esphome.git import GitException, run_git_command, strip_url_credentials -from esphome.helpers import write_file_if_changed +from esphome.helpers import get_bool_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -101,16 +101,18 @@ def _https_repo_url(url: str) -> str: def _checkout_remote(path: Path) -> tuple[str, str] | None: """Return the name and URL of the remote the branch tracks (else origin), if any.""" + remote = "origin" try: branch = run_git_command( ["git", "rev-parse", "--abbrev-ref", "HEAD"], git_dir=path ) - remote = run_git_command( - ["git", "config", "--get", f"branch.{branch}.remote"], git_dir=path - ) - except GitException: - # Detached HEAD or a branch that tracks nothing - remote = "origin" + if branch != "HEAD": # HEAD means detached, which tracks nothing + remote = run_git_command( + ["git", "config", "--get", f"branch.{branch}.remote"], git_dir=path + ) + except GitException as err: + # Usually a branch that tracks nothing + _LOGGER.debug("SBOM: no tracked remote in %s, using origin: %s", path, err) try: return remote, run_git_command( ["git", "remote", "get-url", remote], git_dir=path @@ -182,8 +184,10 @@ def _esphome_vcs() -> _Source | None: ) except importlib.metadata.PackageNotFoundError: return None - if not direct_url: + if direct_url is None: return None + if not direct_url.strip(): + return _unknown_install("empty direct_url.json") # Not installed from a package index, so the release version alone may not # describe it. The raw file is never logged: its URL can hold a token. try: @@ -197,9 +201,15 @@ def _esphome_vcs() -> _Source | None: if commit: return _Source(strip_url_credentials(url), commit) if "dir_info" in info and url.startswith("file:"): - # An editable or local install may be a fork or a patched clone. The - # Docker image's tree has no .git, so it keeps the release description. - return _checkout_vcs(Path(url2pathname(urlparse(url).path))) + # An editable or local install may be a fork or a patched clone + if ( + source := _checkout_vcs(Path(url2pathname(urlparse(url).path))) + ) is not None: + return source + # The official image's tree has no .git and is the release it was built from + if get_bool_env("ESPHOME_IS_DOCKER_IMAGE"): + return None + return _unknown_install("local folder without git") return _unknown_install("not a git or local folder install") diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 6c9d9081d42..6f6eee619fa 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.core import CORE, Library from esphome.espidf.component import ( _emit_idf_component, + _remove_stale_sbom_yml, generate_cmakelists_txt, generate_idf_component_yml, generate_idf_components, @@ -1401,10 +1402,24 @@ def test_emit_idf_component_sbom_yml_error_removes_generated(tmp_path, monkeypat _emit_idf_component(c) assert not (build / "sbom.yml").exists() - # A manifest copied from the library is not ESPHome's to remove + # A local library's build dir is ESPHome's, so an earlier copy goes too (build / "sbom.yml").write_text("name: copied\n") _emit_idf_component(c) - assert (build / "sbom.yml").read_text() == "name: copied\n" + assert not (build / "sbom.yml").exists() + + +@pytest.mark.parametrize( + ("content", "removed"), + [("name: shipped\n", False), ("# Generated by ESPHome\nname: Lib\n", True)], + ids=["shipped", "generated"], +) +def test_remove_stale_sbom_yml_in_library_dir( + tmp_component, content: str, removed: bool +) -> None: + """Where the library's own files live, only ESPHome's generated file is removed.""" + (tmp_component.path / "sbom.yml").write_text(content) + _remove_stale_sbom_yml(tmp_component) + assert (tmp_component.path / "sbom.yml").exists() is not removed def test_build_flags_dangling_flag_does_not_cross_entries( diff --git a/tests/unit_tests/test_espidf_sbom.py b/tests/unit_tests/test_espidf_sbom.py index bf12accc3e9..82a9b7d5c62 100644 --- a/tests/unit_tests/test_espidf_sbom.py +++ b/tests/unit_tests/test_espidf_sbom.py @@ -111,6 +111,8 @@ def _direct_url(url: str | None, commit: str | None) -> str: ), (json.dumps([]), (None, None)), ("not json", (None, None)), + ("", (None, None)), + (" \n", (None, None)), # No direct_url.json: installed from a package index (None, None), ], @@ -481,7 +483,7 @@ def test_checkout_vcs_tracked_remote(tmp_path: Path) -> None: @pytest.mark.parametrize( ("overrides", "expected"), [ - # Detached HEAD: falls back to origin + # A branch that tracks nothing: falls back to origin ( {"config --get branch.dev.remote": GitCommandError("unset")}, ("https://github.com/someone/esphome.git", "abc"), @@ -494,7 +496,7 @@ def test_checkout_vcs_tracked_remote(tmp_path: Path) -> None: ("https://github.com/someone/esphome.git", None), ), ], - ids=["detached", "no-remote", "pushed-check-fails"], + ids=["untracked-branch", "no-remote", "pushed-check-fails"], ) def test_checkout_vcs_remote_fallbacks( tmp_path: Path, overrides: dict[str, str | Exception], expected: tuple @@ -506,16 +508,32 @@ def test_checkout_vcs_remote_fallbacks( assert sbom._checkout_vcs(tmp_path) == expected -def test_esphome_vcs_editable_without_git(tmp_path: Path) -> None: - """A local tree without .git, like the Docker image's, keeps the release description.""" +@pytest.mark.parametrize( + ("docker", "expected"), + [("true", None), (None, (None, None))], + ids=["docker", "other"], +) +def test_esphome_vcs_editable_without_git( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + docker: str | None, + expected: tuple | None, +) -> None: + """Only the official image's tree without .git keeps the release description.""" + if docker is None: + monkeypatch.delenv("ESPHOME_IS_DOCKER_IMAGE", raising=False) + else: + monkeypatch.setenv("ESPHOME_IS_DOCKER_IMAGE", docker) dist = MagicMock() dist.read_text.return_value = _editable_direct_url(tmp_path) with ( patch("importlib.metadata.distribution", return_value=dist), patch.object(sbom, "run_git_command") as run_git, ): - assert sbom._esphome_vcs() is None + assert sbom._esphome_vcs() == expected run_git.assert_not_called() + assert ("local folder without git" in caplog.text) is (docker is None) @pytest.mark.parametrize( @@ -673,3 +691,15 @@ def test_checkout_vcs_pushed_check_failure_warns( with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)): assert sbom._checkout_vcs(tmp_path)[1] is None assert "cannot check whether origin has HEAD" in caplog.text + + +def test_checkout_remote_detached(tmp_path: Path) -> None: + """A detached HEAD uses origin without looking up a tracked remote.""" + outputs = _checkout_outputs() + outputs["rev-parse --abbrev-ref HEAD"] = "HEAD" + with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)) as run: + assert sbom._checkout_remote(tmp_path) == ( + "origin", + "https://user:token@github.com/someone/esphome.git", + ) + assert not any(call.args[0][1] == "config" for call in run.call_args_list) From acb89a86767acc5793fd3ee093f40bdc4edab56a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Oct 2026 19:03:42 -1000 Subject: [PATCH 37/37] [duco] Use the shared sntp_time id in the component test (#21548) --- tests/components/duco/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/duco/common.yaml b/tests/components/duco/common.yaml index 5e422259b71..f68f07db9b6 100644 --- a/tests/components/duco/common.yaml +++ b/tests/components/duco/common.yaml @@ -4,13 +4,13 @@ wifi: time: - platform: sntp - id: duco_sntp_time + id: sntp_time duco: id: duco_box discovery: {} time: - time_id: duco_sntp_time + time_id: sntp_time text_sensor: - platform: duco