From 0d5b7df77d11b48ad58029adea4dfb88743e2932 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:32:02 -0500 Subject: [PATCH 01/42] [sensor] Fix delta filter percentage mode regression (#14302) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/sensor/__init__.py | 2 +- .../fixtures/sensor_filters_delta.yaml | 40 +++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 27 ++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b0e0c28bda..770c044efb 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -603,7 +603,7 @@ DELTA_SCHEMA = cv.Any( def _get_delta(value): if isinstance(value, str): assert value.endswith("%") - return 0.0, float(value[:-1]) + return 0.0, float(value[:-1]) / 100.0 return value, 0.0 diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 19bd2d5ca4..2494a430da 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -28,6 +28,11 @@ sensor: id: source_sensor_4 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 5" + id: source_sensor_5 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -69,6 +74,13 @@ sensor: filters: - delta: 0 + - platform: copy + source_id: source_sensor_5 + name: "Filter Percentage" + id: filter_percentage + filters: + - delta: 50% + script: - id: test_filter_min then: @@ -154,6 +166,28 @@ script: id: source_sensor_4 state: 2.0 + - id: test_filter_percentage + then: + - sensor.template.publish: + id: source_sensor_5 + state: 100.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 120.0 # Filtered out (delta=20, need >50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 160.0 # Passes (delta=60 > 50% of 100=50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 200.0 # Filtered out (delta=40, need >50% of 160=80) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 250.0 # Passes (delta=90 > 80) + button: - platform: template name: "Test Filter Min" @@ -178,3 +212,9 @@ button: id: btn_filter_zero_delta on_press: - script.execute: test_filter_zero_delta + + - platform: template + name: "Test Filter Percentage" + id: btn_filter_percentage + on_press: + - script.execute: test_filter_percentage diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index c7a26bf9d1..9d0114e0c4 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -24,12 +24,14 @@ async def test_sensor_filters_delta( "filter_max": [], "filter_baseline_max": [], "filter_zero_delta": [], + "filter_percentage": [], } filter_min_done = loop.create_future() filter_max_done = loop.create_future() filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() + filter_percentage_done = loop.create_future() def on_state(state: EntityState) -> None: if not isinstance(state, SensorState) or state.missing_state: @@ -66,6 +68,12 @@ async def test_sensor_filters_delta( and not filter_zero_delta_done.done() ): filter_zero_delta_done.set_result(True) + elif ( + sensor_name == "filter_percentage" + and len(sensor_values[sensor_name]) == 3 + and not filter_percentage_done.done() + ): + filter_percentage_done.set_result(True) async with ( run_compiled(yaml_config), @@ -80,6 +88,7 @@ async def test_sensor_filters_delta( "filter_max": "Filter Max", "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", + "filter_percentage": "Filter Percentage", }, ) @@ -98,13 +107,14 @@ async def test_sensor_filters_delta( "Test Filter Max": "filter_max", "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", + "Test Filter Percentage": "filter_percentage", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}" + assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -161,3 +171,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_zero_delta"] == pytest.approx(expected), ( f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}" ) + + # Test 5: Percentage (delta: 50%) + sensor_values["filter_percentage"].clear() + client.button_command(buttons["filter_percentage"]) + try: + await asyncio.wait_for(filter_percentage_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 5 timed out. Values: {sensor_values['filter_percentage']}" + ) + + expected = [100.0, 160.0, 250.0] + assert sensor_values["filter_percentage"] == pytest.approx(expected), ( + f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" + ) From 19f4845185c5669d88c395e323f892fd6a0dbb2c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:06:46 -0500 Subject: [PATCH 02/42] [max7219digit] Fix typo in action names (#14162) Co-authored-by: Claude Opus 4.6 --- esphome/components/max7219digit/display.py | 22 +++++++++++----------- tests/components/max7219digit/common.yaml | 14 +++++++------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index e6d53efc5d..a251eaccea 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -133,12 +133,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "max7129digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA ) @automation.register_action( - "max7129digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA ) -async def max7129digit_invert_to_code(config, action_id, template_arg, args): +async def max7219digit_invert_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) cg.add(var.set_state(config[CONF_STATE])) @@ -146,12 +146,12 @@ async def max7129digit_invert_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7129digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA ) @automation.register_action( - "max7129digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA ) -async def max7129digit_visible_to_code(config, action_id, template_arg, args): +async def max7219digit_visible_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) cg.add(var.set_state(config[CONF_STATE])) @@ -159,12 +159,12 @@ async def max7129digit_visible_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7129digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA ) @automation.register_action( - "max7129digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA ) -async def max7129digit_reverse_to_code(config, action_id, template_arg, args): +async def max7219digit_reverse_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) cg.add(var.set_state(config[CONF_STATE])) @@ -183,9 +183,9 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "max7129digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA + "max7219digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA ) -async def max7129digit_intensity_to_code(config, action_id, template_arg, args): +async def max7219digit_intensity_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) diff --git a/tests/components/max7219digit/common.yaml b/tests/components/max7219digit/common.yaml index 525b7b8d3e..4d2dbb781d 100644 --- a/tests/components/max7219digit/common.yaml +++ b/tests/components/max7219digit/common.yaml @@ -13,10 +13,10 @@ esphome: on_boot: - priority: 100 then: - - max7129digit.invert_off: - - max7129digit.invert_on: - - max7129digit.turn_on: - - max7129digit.turn_off: - - max7129digit.reverse_on: - - max7129digit.reverse_off: - - max7129digit.intensity: 10 + - max7219digit.invert_off: + - max7219digit.invert_on: + - max7219digit.turn_on: + - max7219digit.turn_off: + - max7219digit.reverse_on: + - max7219digit.reverse_off: + - max7219digit.intensity: 10 From 0975755a9d13de364cb780abdc6bd854d7419b64 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:51:13 -1000 Subject: [PATCH 03/42] [mipi_dsi] Disallow swap_xy (#14124) --- esphome/components/mipi_dsi/display.py | 26 +++++-------------- .../mipi_dsi/fixtures/mipi_dsi.yaml | 7 ++++- .../mipi_dsi/test_mipi_dsi_config.py | 6 +++-- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index c288b33cd2..de3791b3a4 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -87,38 +87,24 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] + model.defaults[CONF_SWAP_XY] = cv.UNDEFINED transform = cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY): cv.invalid( + "Axis swapping not supported by DSI displays" + ), } ) - if model.get_default(CONF_SWAP_XY) != cv.UNDEFINED: - transform = transform.extend( - { - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by this model" - ) - } - ) - else: - transform = transform.extend( - { - cv.Required(CONF_SWAP_XY): cv.boolean, - } - ) # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) if model.initsequence is None else cv.Optional(CONF_INIT_SEQUENCE) ) - swap_xy = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY, False) - - # Dimensions are optional if the model has a default width and the swap_xy transform is not overridden - cv_dimensions = ( - cv.Optional if model.get_default(CONF_WIDTH) and not swap_xy else cv.Required - ) + # Dimensions are optional if the model has a default width + cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24") schema = display.FULL_DISPLAY_SCHEMA.extend( { diff --git a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml index 7d1fc84121..6de2bd5a77 100644 --- a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml +++ b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml @@ -15,8 +15,13 @@ esp_ldo: display: - platform: mipi_dsi + id: p4_nano model: WAVESHARE-P4-NANO-10.1 - + rotation: 90 + - platform: mipi_dsi + id: p4_86 + model: "WAVESHARE-P4-86-PANEL" + rotation: 180 i2c: sda: GPIO7 scl: GPIO8 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index f8a9af0279..d465a8c81b 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -119,9 +119,11 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "mipi_dsi_mipi_dsi_id = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" + "p4_nano = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp - assert "mipi_dsi_mipi_dsi_id->set_lane_bit_rate(1500);" in main_cpp + assert "p4_nano->set_lane_bit_rate(1500);" in main_cpp + assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp + assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp From 1f5a35a99f599045a397d8df7316e4f1626fed74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 11:08:13 -0600 Subject: [PATCH 04/42] [dsmr] Add deprecated std::string overload for set_decryption_key (#14180) --- esphome/components/dsmr/dsmr.h | 3 +++ tests/components/dsmr/common.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index fafcf62b87..dc81ba9b2a 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -64,6 +64,9 @@ class Dsmr : public Component, public uart::UARTDevice { void dump_config() override; void set_decryption_key(const char *decryption_key); + // Remove before 2026.8.0 + ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0") + void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); } void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; } void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; } void set_request_interval(uint32_t interval) { this->request_interval_ = interval; } diff --git a/tests/components/dsmr/common.yaml b/tests/components/dsmr/common.yaml index 038bf2806b..d11ce37d59 100644 --- a/tests/components/dsmr/common.yaml +++ b/tests/components/dsmr/common.yaml @@ -1,4 +1,13 @@ +esphome: + on_boot: + then: + - lambda: |- + // Test deprecated std::string overload still compiles + std::string key = "00112233445566778899aabbccddeeff"; + id(dsmr_instance).set_decryption_key(key); + dsmr: + id: dsmr_instance decryption_key: 00112233445566778899aabbccddeeff max_telegram_length: 1000 request_pin: ${request_pin} From 15e2a778d406ccdfdfcc8b6a39709d9ca84cadc4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:20 -0600 Subject: [PATCH 05/42] [api] Fix build error when lambda returns StringRef in homeassistant.event data (#14187) --- esphome/components/api/homeassistant_service.h | 2 ++ tests/components/homeassistant/common.yaml | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 2322d96eef..340699e1a6 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -36,6 +36,8 @@ template class TemplatableStringValue : public TemplatableValue() {} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 9c6cb71b8b..60e3defd49 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -90,6 +90,19 @@ text_sensor: id: ha_hello_world_text2 attribute: some_attribute +event: + - platform: template + name: Test Event + id: test_event + event_types: + - test_event_type + on_event: + - homeassistant.event: + event: esphome.test_event + data: + event_name: !lambda |- + return event_type; + time: - platform: homeassistant on_time: From c5c6ce6b0e71a932621fbb5848cc3909fbe9634a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:43 -0600 Subject: [PATCH 06/42] [haier] Fix uninitialized HonSettings causing API connection failures (#14188) --- esphome/components/haier/hon_climate.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a567ab1d89..608d5e7f21 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -29,10 +29,10 @@ enum class CleaningState : uint8_t { enum class HonControlMethod { MONITOR_ONLY = 0, SET_GROUP_PARAMETERS, SET_SINGLE_PARAMETER }; struct HonSettings { - hon_protocol::VerticalSwingMode last_vertiacal_swing; - hon_protocol::HorizontalSwingMode last_horizontal_swing; - bool beeper_state; - bool quiet_mode_state; + hon_protocol::VerticalSwingMode last_vertiacal_swing{hon_protocol::VerticalSwingMode::CENTER}; + hon_protocol::HorizontalSwingMode last_horizontal_swing{hon_protocol::HorizontalSwingMode::CENTER}; + bool beeper_state{true}; + bool quiet_mode_state{false}; }; class HonClimate : public HaierClimateBase { @@ -189,7 +189,7 @@ class HonClimate : public HaierClimateBase { int big_data_sensors_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; - HonSettings settings_; + HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::OFF}; }; From 27fe866d5e5776317a3700cd98b00adf7e2fc52e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 23:22:59 -0600 Subject: [PATCH 07/42] [bme68x_bsec2] Fix compilation on ESP32 Arduino (#14194) --- esphome/components/bme68x_bsec2/__init__.py | 5 ++++- tests/components/bme68x_bsec2_i2c/test.esp32-ard.yaml | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-ard.yaml diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index e421efb2d6..4200b2f0b8 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -178,8 +178,11 @@ async def to_code_base(config): bsec2_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) cg.add(var.set_bsec2_configuration(bsec2_arr, len(rhs))) - # Although this component does not use SPI, the BSEC2 Arduino library requires the SPI library + # The BSEC2 and BME68x Arduino libraries unconditionally include Wire.h and + # SPI.h in their source files, so these libraries must be available even though + # ESPHome uses its own I2C/SPI abstractions instead of the Arduino ones. if core.CORE.using_arduino: + cg.add_library("Wire", None) cg.add_library("SPI", None) cg.add_library( "BME68x Sensor library", diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-ard.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml From 997f825cd3c03ee1a7a07c07306b37e1d37e4fe2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Feb 2026 15:56:09 -0600 Subject: [PATCH 08/42] [network] Improve IPAddress::str() deprecation warning with usage example (#14195) --- esphome/components/network/ip_address.h | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index d0ac8164af..b2a2c563e2 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -61,7 +61,9 @@ struct IPAddress { IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } // Remove before 2026.8.0 - ESPDEPRECATED("Use str_to() instead. Removed in 2026.8.0", "2026.2.0") + ESPDEPRECATED( + "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", + "2026.2.0") std::string str() const { char buf[IP_ADDRESS_BUFFER_SIZE]; this->str_to(buf); @@ -150,7 +152,9 @@ struct IPAddress { bool is_ip6() const { return IP_IS_V6(&ip_addr_); } bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } // Remove before 2026.8.0 - ESPDEPRECATED("Use str_to() instead. Removed in 2026.8.0", "2026.2.0") + ESPDEPRECATED( + "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", + "2026.2.0") std::string str() const { char buf[IP_ADDRESS_BUFFER_SIZE]; this->str_to(buf); From 4b57ac323666c8278e93bf20af20268077cab2c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Feb 2026 17:07:56 -0600 Subject: [PATCH 09/42] [water_heater] Fix device_id missing from state responses (#14212) --- esphome/components/api/api_connection.cpp | 3 +- .../fixtures/device_id_in_state.yaml | 115 ++++++++++++ tests/integration/test_device_id_in_state.py | 173 ++++++++++++------ 3 files changed, 232 insertions(+), 59 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4bc3c9b307..b388bf5971 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1334,9 +1334,8 @@ uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConne resp.target_temperature_low = wh->get_target_temperature_low(); resp.target_temperature_high = wh->get_target_temperature_high(); resp.state = wh->get_state(); - resp.key = wh->get_object_id_hash(); - return encode_message_to_buffer(resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size); + return fill_and_encode_entity_state(wh, resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size); } uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *wh = static_cast(entity); diff --git a/tests/integration/fixtures/device_id_in_state.yaml b/tests/integration/fixtures/device_id_in_state.yaml index c8548617b8..a5dfb7ee45 100644 --- a/tests/integration/fixtures/device_id_in_state.yaml +++ b/tests/integration/fixtures/device_id_in_state.yaml @@ -46,6 +46,7 @@ sensor: binary_sensor: - platform: template + id: motion_detected name: Motion Detected device_id: motion_sensor lambda: return true; @@ -82,3 +83,117 @@ output: write_action: - lambda: |- ESP_LOGD("test", "Light output: %d", state); + +cover: + - platform: template + name: Garage Door + device_id: motion_sensor + optimistic: true + +fan: + - platform: template + name: Ceiling Fan + device_id: humidity_monitor + speed_count: 3 + has_oscillating: false + has_direction: false + +lock: + - platform: template + name: Front Door Lock + device_id: motion_sensor + optimistic: true + +number: + - platform: template + name: Target Temperature + device_id: temperature_monitor + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + +select: + - platform: template + name: Mode Select + device_id: humidity_monitor + optimistic: true + options: + - "Auto" + - "Manual" + +text: + - platform: template + name: Device Label + device_id: temperature_monitor + optimistic: true + mode: text + +valve: + - platform: template + name: Water Valve + device_id: humidity_monitor + optimistic: true + +globals: + - id: global_away + type: bool + initial_value: "false" + - id: global_is_on + type: bool + initial_value: "true" + +water_heater: + - platform: template + name: Test Boiler + device_id: temperature_monitor + optimistic: true + current_temperature: !lambda "return 45.0f;" + target_temperature: !lambda "return 60.0f;" + away: !lambda "return id(global_away);" + is_on: !lambda "return id(global_is_on);" + supported_modes: + - "off" + - electric + visual: + min_temperature: 30.0 + max_temperature: 85.0 + target_temperature_step: 0.5 + set_action: + - lambda: |- + ESP_LOGD("test", "Water heater set"); + +alarm_control_panel: + - platform: template + name: House Alarm + device_id: motion_sensor + codes: + - "1234" + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: motion_detected + +datetime: + - platform: template + name: Schedule Date + device_id: temperature_monitor + type: date + optimistic: true + - platform: template + name: Schedule Time + device_id: humidity_monitor + type: time + optimistic: true + - platform: template + name: Schedule DateTime + device_id: motion_sensor + type: datetime + optimistic: true + +event: + - platform: template + name: Doorbell + device_id: motion_sensor + event_types: + - "press" + - "double_press" diff --git a/tests/integration/test_device_id_in_state.py b/tests/integration/test_device_id_in_state.py index 51088bcbf7..48de94a85a 100644 --- a/tests/integration/test_device_id_in_state.py +++ b/tests/integration/test_device_id_in_state.py @@ -4,11 +4,80 @@ from __future__ import annotations import asyncio -from aioesphomeapi import BinarySensorState, EntityState, SensorState, TextSensorState +from aioesphomeapi import ( + AlarmControlPanelEntityState, + BinarySensorState, + CoverState, + DateState, + DateTimeState, + EntityState, + FanState, + LightState, + LockEntityState, + NumberState, + SelectState, + SensorState, + SwitchState, + TextSensorState, + TextState, + TimeState, + ValveState, + WaterHeaterState, +) import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +# Mapping of entity name to device name for all entities with device_id +ENTITY_TO_DEVICE = { + # Original entities + "Temperature": "Temperature Monitor", + "Humidity": "Humidity Monitor", + "Motion Detected": "Motion Sensor", + "Temperature Monitor Power": "Temperature Monitor", + "Temperature Status": "Temperature Monitor", + "Motion Light": "Motion Sensor", + # New entity types + "Garage Door": "Motion Sensor", + "Ceiling Fan": "Humidity Monitor", + "Front Door Lock": "Motion Sensor", + "Target Temperature": "Temperature Monitor", + "Mode Select": "Humidity Monitor", + "Device Label": "Temperature Monitor", + "Water Valve": "Humidity Monitor", + "Test Boiler": "Temperature Monitor", + "House Alarm": "Motion Sensor", + "Schedule Date": "Temperature Monitor", + "Schedule Time": "Humidity Monitor", + "Schedule DateTime": "Motion Sensor", + "Doorbell": "Motion Sensor", +} + +# Entities without device_id (should have device_id 0) +NO_DEVICE_ENTITIES = {"No Device Sensor"} + +# State types that should have non-zero device_id, mapped by their aioesphomeapi class +EXPECTED_STATE_TYPES = [ + (SensorState, "sensor"), + (BinarySensorState, "binary_sensor"), + (SwitchState, "switch"), + (TextSensorState, "text_sensor"), + (LightState, "light"), + (CoverState, "cover"), + (FanState, "fan"), + (LockEntityState, "lock"), + (NumberState, "number"), + (SelectState, "select"), + (TextState, "text"), + (ValveState, "valve"), + (WaterHeaterState, "water_heater"), + (AlarmControlPanelEntityState, "alarm_control_panel"), + (DateState, "date"), + (TimeState, "time"), + (DateTimeState, "datetime"), + # Event is stateless (no initial state sent on subscribe) +] + @pytest.mark.asyncio async def test_device_id_in_state( @@ -40,34 +109,35 @@ async def test_device_id_in_state( entity_device_mapping: dict[int, int] = {} for entity in all_entities: - # All entities have name and key attributes - if entity.name == "Temperature": - entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] - elif entity.name == "Humidity": - entity_device_mapping[entity.key] = device_ids["Humidity Monitor"] - elif entity.name == "Motion Detected": - entity_device_mapping[entity.key] = device_ids["Motion Sensor"] - elif entity.name in {"Temperature Monitor Power", "Temperature Status"}: - entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] - elif entity.name == "Motion Light": - entity_device_mapping[entity.key] = device_ids["Motion Sensor"] - elif entity.name == "No Device Sensor": - # Entity without device_id should have device_id 0 + if entity.name in ENTITY_TO_DEVICE: + expected_device = ENTITY_TO_DEVICE[entity.name] + entity_device_mapping[entity.key] = device_ids[expected_device] + elif entity.name in NO_DEVICE_ENTITIES: entity_device_mapping[entity.key] = 0 - assert len(entity_device_mapping) >= 6, ( - f"Expected at least 6 mapped entities, got {len(entity_device_mapping)}" + expected_count = len(ENTITY_TO_DEVICE) + len(NO_DEVICE_ENTITIES) + assert len(entity_device_mapping) >= expected_count, ( + f"Expected at least {expected_count} mapped entities, " + f"got {len(entity_device_mapping)}. " + f"Missing: {set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES - {e.name for e in all_entities}}" + ) + + # Subscribe to states and wait for all mapped entities + # Event entities are stateless (no initial state on subscribe), + # so exclude them from the expected count + stateless_keys = {e.key for e in all_entities if e.name == "Doorbell"} + stateful_count = len(entity_device_mapping) - len( + stateless_keys & entity_device_mapping.keys() ) - # Subscribe to states loop = asyncio.get_running_loop() states: dict[int, EntityState] = {} states_future: asyncio.Future[bool] = loop.create_future() def on_state(state: EntityState) -> None: - states[state.key] = state - # Check if we have states for all mapped entities - if len(states) >= len(entity_device_mapping) and not states_future.done(): + if state.key in entity_device_mapping: + states[state.key] = state + if len(states) >= stateful_count and not states_future.done(): states_future.set_result(True) client.subscribe_states(on_state) @@ -76,9 +146,16 @@ async def test_device_id_in_state( try: await asyncio.wait_for(states_future, timeout=10.0) except TimeoutError: + received_names = {e.name for e in all_entities if e.key in states} + missing_names = ( + (set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES) + - received_names + - {"Doorbell"} + ) pytest.fail( f"Did not receive all entity states within 10 seconds. " - f"Received {len(states)} states, expected {len(entity_device_mapping)}" + f"Received {len(states)} states. " + f"Missing: {missing_names}" ) # Verify each state has the correct device_id @@ -86,51 +163,33 @@ async def test_device_id_in_state( for key, expected_device_id in entity_device_mapping.items(): if key in states: state = states[key] + entity_name = next( + (e.name for e in all_entities if e.key == key), f"key={key}" + ) assert state.device_id == expected_device_id, ( - f"State for key {key} has device_id {state.device_id}, " - f"expected {expected_device_id}" + f"State for '{entity_name}' (type={type(state).__name__}) " + f"has device_id {state.device_id}, expected {expected_device_id}" ) verified_count += 1 - assert verified_count >= 6, ( - f"Only verified {verified_count} states, expected at least 6" + # All stateful entities should be verified (everything except Doorbell event) + expected_verified = expected_count - 1 # exclude Doorbell + assert verified_count >= expected_verified, ( + f"Only verified {verified_count} states, expected at least {expected_verified}" ) - # Test specific state types to ensure device_id is present - # Find a sensor state with device_id - sensor_state = next( - ( + # Verify each expected state type has at least one instance with non-zero device_id + for state_type, type_name in EXPECTED_STATE_TYPES: + matching = [ s for s in states.values() - if isinstance(s, SensorState) - and isinstance(s.state, float) - and s.device_id != 0 - ), - None, - ) - assert sensor_state is not None, "No sensor state with device_id found" - assert sensor_state.device_id > 0, "Sensor state should have non-zero device_id" - - # Find a binary sensor state - binary_sensor_state = next( - (s for s in states.values() if isinstance(s, BinarySensorState)), - None, - ) - assert binary_sensor_state is not None, "No binary sensor state found" - assert binary_sensor_state.device_id > 0, ( - "Binary sensor state should have non-zero device_id" - ) - - # Find a text sensor state - text_sensor_state = next( - (s for s in states.values() if isinstance(s, TextSensorState)), - None, - ) - assert text_sensor_state is not None, "No text sensor state found" - assert text_sensor_state.device_id > 0, ( - "Text sensor state should have non-zero device_id" - ) + if isinstance(s, state_type) and s.device_id != 0 + ] + assert matching, ( + f"No {type_name} state (type={state_type.__name__}) " + f"with non-zero device_id found" + ) # Verify the "No Device Sensor" has device_id = 0 no_device_key = next( From efa39ae591ea3aedc5ea7b41d048df33ac15d0d7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:33:33 +1100 Subject: [PATCH 10/42] [mipi_dsi] Allow transform disable; fix warnings (#14216) --- esphome/components/mipi_dsi/display.py | 24 +++++++++++-------- esphome/components/mipi_dsi/mipi_dsi.cpp | 4 ++-- esphome/components/mipi_dsi/mipi_dsi.h | 10 ++++---- .../mipi_dsi/fixtures/mipi_dsi.yaml | 17 +++++++++++++ .../mipi_dsi/test_mipi_dsi_config.py | 3 ++- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index de3791b3a4..85bfad7f1a 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -39,6 +39,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_ORDER, CONF_DIMENSIONS, + CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, @@ -88,14 +89,17 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } + transform = cv.Any( + cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY): cv.invalid( + "Axis swapping not supported by DSI displays" + ), + } + ), + cv.one_of(CONF_DISABLED, lower=True), ) # CUSTOM model will need to provide a custom init sequence iseqconf = ( @@ -199,9 +203,9 @@ async def to_code(config): cg.add(var.set_vsync_pulse_width(config[CONF_VSYNC_PULSE_WIDTH])) cg.add(var.set_vsync_back_porch(config[CONF_VSYNC_BACK_PORCH])) cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) - cg.add(var.set_pclk_frequency(int(config[CONF_PCLK_FREQUENCY] / 1e6))) + cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY] / 1.0e6)) cg.add(var.set_lanes(int(config[CONF_LANES]))) - cg.add(var.set_lane_bit_rate(int(config[CONF_LANE_BIT_RATE] / 1e6))) + cg.add(var.set_lane_bit_rate(config[CONF_LANE_BIT_RATE] / 1.0e6)) if reset_pin := config.get(CONF_RESET_PIN): reset = await cg.gpio_pin_expression(reset_pin) cg.add(var.set_reset_pin(reset)) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 18cafab684..4d45cfb799 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -374,7 +374,7 @@ void MIPI_DSI::dump_config() { "\n Swap X/Y: %s" "\n Rotation: %d degrees" "\n DSI Lanes: %u" - "\n Lane Bit Rate: %uMbps" + "\n Lane Bit Rate: %.0fMbps" "\n HSync Pulse Width: %u" "\n HSync Back Porch: %u" "\n HSync Front Porch: %u" @@ -385,7 +385,7 @@ void MIPI_DSI::dump_config() { "\n Display Pixel Mode: %d bit" "\n Color Order: %s" "\n Invert Colors: %s" - "\n Pixel Clock: %dMHz", + "\n Pixel Clock: %.1fMHz", this->model_, this->width_, this->height_, YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY)), YESNO(this->madctl_ & MADCTL_MV), this->rotation_, this->lanes_, this->lane_bit_rate_, this->hsync_pulse_width_, this->hsync_back_porch_, diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 1cffe3b178..6e27912aa5 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -47,7 +47,7 @@ class MIPI_DSI : public display::Display { void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } - void set_pclk_frequency(uint32_t pclk_frequency) { this->pclk_frequency_ = pclk_frequency; } + void set_pclk_frequency(float pclk_frequency) { this->pclk_frequency_ = pclk_frequency; } int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void set_hsync_back_porch(uint16_t hsync_back_porch) { this->hsync_back_porch_ = hsync_back_porch; } @@ -58,7 +58,7 @@ class MIPI_DSI : public display::Display { void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; } void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } void set_model(const char *model) { this->model_ = model; } - void set_lane_bit_rate(uint16_t lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } + void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } @@ -95,9 +95,9 @@ class MIPI_DSI : public display::Display { uint16_t vsync_front_porch_ = 10; const char *model_{"Unknown"}; std::vector init_sequence_{}; - uint16_t pclk_frequency_ = 16; // in MHz - uint16_t lane_bit_rate_{1500}; // in Mbps - uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes + float pclk_frequency_ = 16; // in MHz + float lane_bit_rate_{1500}; // in Mbps + uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes bool invert_colors_{}; display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; diff --git a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml index 6de2bd5a77..6f76dcb1d1 100644 --- a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml +++ b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml @@ -22,6 +22,23 @@ display: id: p4_86 model: "WAVESHARE-P4-86-PANEL" rotation: 180 + - platform: mipi_dsi + model: custom + id: custom_id + dimensions: + width: 400 + height: 1280 + hsync_back_porch: 40 + hsync_pulse_width: 30 + hsync_front_porch: 40 + vsync_back_porch: 20 + vsync_pulse_width: 10 + vsync_front_porch: 20 + pclk_frequency: 48Mhz + lane_bit_rate: 1.2Gbps + rotation: 180 + transform: disabled + init_sequence: i2c: sda: GPIO7 scl: GPIO8 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index d465a8c81b..92f56b5451 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -123,7 +123,8 @@ def test_code_generation( in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp - assert "p4_nano->set_lane_bit_rate(1500);" in main_cpp + assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp + assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp From 29d890bb0f89af6257a471e9256aa1bc042f381b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:15:22 -0500 Subject: [PATCH 11/42] [http_request.ota] Percent-encode credentials in URL (#14257) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http_request/ota/ota_http_request.cpp | 22 +++++++++++++++++++ .../http_request/ota/ota_http_request.h | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 882def4d7f..bbe128aad9 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -1,5 +1,7 @@ #include "ota_http_request.h" +#include + #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -210,6 +212,26 @@ uint8_t OtaHttpRequestComponent::do_ota_() { return ota::OTA_RESPONSE_OK; } +// URL-encode characters that are not unreserved per RFC 3986 section 2.3. +// This is needed for embedding userinfo (username/password) in URLs safely. +static std::string url_encode(const std::string &str) { + std::string result; + result.reserve(str.size()); + for (char c : str) { + if (std::isalnum(static_cast(c)) || c == '-' || c == '_' || c == '.' || c == '~') { + result += c; + } else { + result += '%'; + result += format_hex_pretty_char((static_cast(c) >> 4) & 0x0F); + result += format_hex_pretty_char(static_cast(c) & 0x0F); + } + } + return result; +} + +void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); } +void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); } + std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) { if (this->username_.empty() || this->password_.empty()) { return url; diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index 8735189e99..e3f1a4aa90 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -29,9 +29,9 @@ class OtaHttpRequestComponent : public ota::OTAComponent, public Parentedmd5_expected_ = md5; } - void set_password(const std::string &password) { this->password_ = password; } + void set_password(const std::string &password); void set_url(const std::string &url); - void set_username(const std::string &username) { this->username_ = username; } + void set_username(const std::string &username); std::string md5_computed() { return this->md5_computed_; } std::string md5_expected() { return this->md5_expected_; } From 2c11c65faff2edec0224ca0bbe7624a9d3adc147 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:28:19 +1300 Subject: [PATCH 12/42] Don't get stuck forever on a failed component can_proceed (#14267) --- esphome/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 449acc64cf..2b84b1c1e4 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -132,7 +132,7 @@ void Application::setup() { this->after_loop_tasks_(); this->app_state_ = new_app_state; yield(); - } while (!component->can_proceed()); + } while (!component->can_proceed() && !component->is_failed()); } ESP_LOGI(TAG, "setup() finished successfully!"); From af296eb600262ca2a3a4f9e47aab3e65bbc8826d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:26:00 -0500 Subject: [PATCH 13/42] [pid] Fix deadband threshold conversion for Fahrenheit (#14268) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/pid/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 5919d2cac8..5fa3166f9d 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -50,8 +50,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput), cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema( { - cv.Required(CONF_THRESHOLD_HIGH): cv.temperature, - cv.Required(CONF_THRESHOLD_LOW): cv.temperature, + cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta, + cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta, cv.Optional(CONF_KP_MULTIPLIER, default=0.1): cv.float_, cv.Optional(CONF_KI_MULTIPLIER, default=0.0): cv.float_, cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_, From da930310b1e2e1175118126cd25680fa3ca2eeb2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:19:20 -0500 Subject: [PATCH 14/42] [ld2420] Fix sizeof vs value bug in register memcpy (#14286) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/ld2420/ld2420.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index cf78a1a460..b653f4ae88 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -590,7 +590,7 @@ void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) { for (uint16_t index = 0; index < (CMD_REG_DATA_REPLY_SIZE * // NOLINT ((buffer[CMD_FRAME_DATA_LENGTH] - 4) / CMD_REG_DATA_REPLY_SIZE)); index += CMD_REG_DATA_REPLY_SIZE) { - memcpy(&this->cmd_reply_.data[reg_element], &buffer[data_pos + index], sizeof(CMD_REG_DATA_REPLY_SIZE)); + memcpy(&this->cmd_reply_.data[reg_element], &buffer[data_pos + index], CMD_REG_DATA_REPLY_SIZE); byteswap(this->cmd_reply_.data[reg_element]); reg_element++; } @@ -729,9 +729,9 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { cmd_frame.data_length = 0; cmd_frame.header = CMD_FRAME_HEADER; cmd_frame.command = CMD_WRITE_REGISTER; - memcpy(&cmd_frame.data[cmd_frame.data_length], ®, sizeof(CMD_REG_DATA_REPLY_SIZE)); + memcpy(&cmd_frame.data[cmd_frame.data_length], ®, CMD_REG_DATA_REPLY_SIZE); cmd_frame.data_length += 2; - memcpy(&cmd_frame.data[cmd_frame.data_length], &value, sizeof(CMD_REG_DATA_REPLY_SIZE)); + memcpy(&cmd_frame.data[cmd_frame.data_length], &value, CMD_REG_DATA_REPLY_SIZE); cmd_frame.data_length += 2; cmd_frame.footer = CMD_FRAME_FOOTER; ESP_LOGV(TAG, "Sending write register %4X command: %2X data = %4X", reg, cmd_frame.command, value); From a39be5a4619817d0b3a244b04cbac5150cb4b540 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:34:38 -0500 Subject: [PATCH 15/42] [rtttl] Fix speaker playback bugs (#14280) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/rtttl/rtttl.cpp | 21 +++++++++++---------- esphome/components/rtttl/rtttl.h | 6 +++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 6e86405b74..ab95067f45 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -139,9 +139,10 @@ void Rtttl::loop() { x++; } if (x > 0) { - int send = this->speaker_->play((uint8_t *) (&sample), x * 2); - if (send != x * 4) { - this->samples_sent_ -= (x - (send / 2)); + size_t bytes_to_send = x * sizeof(SpeakerSample); + size_t send = this->speaker_->play((uint8_t *) (&sample), bytes_to_send); + if (send != bytes_to_send) { + this->samples_sent_ -= (x - (send / sizeof(SpeakerSample))); } return; } @@ -201,9 +202,9 @@ void Rtttl::loop() { bool need_note_gap = false; if (note) { auto note_index = (scale - 4) * 12 + note; - if (note_index < 0 || note_index >= (int) sizeof(NOTES)) { + if (note_index < 0 || note_index >= (int) (sizeof(NOTES) / sizeof(NOTES[0]))) { ESP_LOGE(TAG, "Note out of range (note: %d, scale: %d, index: %d, max: %d)", note, scale, note_index, - (int) sizeof(NOTES)); + (int) (sizeof(NOTES) / sizeof(NOTES[0]))); this->finish_(); return; } @@ -221,7 +222,7 @@ void Rtttl::loop() { #ifdef USE_OUTPUT if (this->output_ != nullptr) { - if (need_note_gap) { + if (need_note_gap && this->note_duration_ > DOUBLE_NOTE_GAP_MS) { this->output_->set_level(0.0); delay(DOUBLE_NOTE_GAP_MS); this->note_duration_ -= DOUBLE_NOTE_GAP_MS; @@ -240,9 +241,9 @@ void Rtttl::loop() { this->samples_sent_ = 0; this->samples_gap_ = 0; this->samples_per_wave_ = 0; - this->samples_count_ = (this->sample_rate_ * this->note_duration_) / 1600; //(ms); + this->samples_count_ = (this->sample_rate_ * this->note_duration_) / 1000; if (need_note_gap) { - this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / 1600; //(ms); + this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / 1000; } if (this->output_freq_ != 0) { // make sure there is enough samples to add a full last sinus. @@ -279,7 +280,7 @@ void Rtttl::play(std::string rtttl) { this->note_duration_ = 0; int bpm = 63; - uint8_t num; + uint16_t num; // Get name this->position_ = this->rtttl_.find(':'); @@ -395,7 +396,7 @@ void Rtttl::finish_() { sample[0].right = 0; sample[1].left = 0; sample[1].right = 0; - this->speaker_->play((uint8_t *) (&sample), 8); + this->speaker_->play((uint8_t *) (&sample), sizeof(sample)); this->speaker_->finish(); this->set_state_(State::STOPPING); } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 6f5df07766..4d4a652c51 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -46,8 +46,8 @@ class Rtttl : public Component { } protected: - inline uint8_t get_integer_() { - uint8_t ret = 0; + inline uint16_t get_integer_() { + uint16_t ret = 0; while (isdigit(this->rtttl_[this->position_])) { ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); } @@ -87,7 +87,7 @@ class Rtttl : public Component { #ifdef USE_OUTPUT /// The output to write the sound to. - output::FloatOutput *output_; + output::FloatOutput *output_{nullptr}; #endif // USE_OUTPUT #ifdef USE_SPEAKER From 5a1d6428b20163de46cc428246002ac1dd845b4e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:35:27 -0500 Subject: [PATCH 16/42] [hmc5883l] Fix wrong gain for 88uT range (#14281) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/hmc5883l/hmc5883l.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index b62381a287..bee5282125 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -95,7 +95,7 @@ void HMC5883LComponent::update() { float mg_per_bit; switch (this->range_) { case HMC5883L_RANGE_88_UT: - mg_per_bit = 0.073f; + mg_per_bit = 0.73f; break; case HMC5883L_RANGE_130_UT: mg_per_bit = 0.92f; From 8479664df104b85ab3bce2e9c6ca3377ed02eb24 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:32:02 -0500 Subject: [PATCH 17/42] [sensor] Fix delta filter percentage mode regression (#14302) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/sensor/__init__.py | 2 +- .../fixtures/sensor_filters_delta.yaml | 40 +++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 27 ++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index ebbe0fbccc..03784ba76b 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -603,7 +603,7 @@ DELTA_SCHEMA = cv.Any( def _get_delta(value): if isinstance(value, str): assert value.endswith("%") - return 0.0, float(value[:-1]) + return 0.0, float(value[:-1]) / 100.0 return value, 0.0 diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 19bd2d5ca4..2494a430da 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -28,6 +28,11 @@ sensor: id: source_sensor_4 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 5" + id: source_sensor_5 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -69,6 +74,13 @@ sensor: filters: - delta: 0 + - platform: copy + source_id: source_sensor_5 + name: "Filter Percentage" + id: filter_percentage + filters: + - delta: 50% + script: - id: test_filter_min then: @@ -154,6 +166,28 @@ script: id: source_sensor_4 state: 2.0 + - id: test_filter_percentage + then: + - sensor.template.publish: + id: source_sensor_5 + state: 100.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 120.0 # Filtered out (delta=20, need >50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 160.0 # Passes (delta=60 > 50% of 100=50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 200.0 # Filtered out (delta=40, need >50% of 160=80) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_5 + state: 250.0 # Passes (delta=90 > 80) + button: - platform: template name: "Test Filter Min" @@ -178,3 +212,9 @@ button: id: btn_filter_zero_delta on_press: - script.execute: test_filter_zero_delta + + - platform: template + name: "Test Filter Percentage" + id: btn_filter_percentage + on_press: + - script.execute: test_filter_percentage diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index c7a26bf9d1..9d0114e0c4 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -24,12 +24,14 @@ async def test_sensor_filters_delta( "filter_max": [], "filter_baseline_max": [], "filter_zero_delta": [], + "filter_percentage": [], } filter_min_done = loop.create_future() filter_max_done = loop.create_future() filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() + filter_percentage_done = loop.create_future() def on_state(state: EntityState) -> None: if not isinstance(state, SensorState) or state.missing_state: @@ -66,6 +68,12 @@ async def test_sensor_filters_delta( and not filter_zero_delta_done.done() ): filter_zero_delta_done.set_result(True) + elif ( + sensor_name == "filter_percentage" + and len(sensor_values[sensor_name]) == 3 + and not filter_percentage_done.done() + ): + filter_percentage_done.set_result(True) async with ( run_compiled(yaml_config), @@ -80,6 +88,7 @@ async def test_sensor_filters_delta( "filter_max": "Filter Max", "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", + "filter_percentage": "Filter Percentage", }, ) @@ -98,13 +107,14 @@ async def test_sensor_filters_delta( "Test Filter Max": "filter_max", "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", + "Test Filter Percentage": "filter_percentage", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}" + assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -161,3 +171,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_zero_delta"] == pytest.approx(expected), ( f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}" ) + + # Test 5: Percentage (delta: 50%) + sensor_values["filter_percentage"].clear() + client.button_command(buttons["filter_percentage"]) + try: + await asyncio.wait_for(filter_percentage_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 5 timed out. Values: {sensor_values['filter_percentage']}" + ) + + expected = [100.0, 160.0, 250.0] + assert sensor_values["filter_percentage"] == pytest.approx(expected), ( + f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" + ) From 2c749e9dbefa3a3b0caf3b2275d2cadb46ee5165 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:45:13 +1300 Subject: [PATCH 18/42] Bump version to 2026.2.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d41a79b0dc..2de0460ef1 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.2.1 +PROJECT_NUMBER = 2026.2.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b3c15b1e27..7d15964eab 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.2.1" +__version__ = "2026.2.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 789da5fdf822a7332adb7e670ff2de9353907bc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 20:13:44 -0700 Subject: [PATCH 19/42] [logger] Mark Logger and LoggerMessageTrigger as final (#14291) --- esphome/components/logger/logger.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 90722ee79c..263d12b444 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -141,7 +141,7 @@ enum UARTSelection : uint8_t { * 2. Works with ESP-IDF's pthread implementation that uses a linked list for TLS variables * 3. Avoids the limitations of the fixed FreeRTOS task local storage slots */ -class Logger : public Component { +class Logger final : public Component { public: explicit Logger(uint32_t baud_rate); #ifdef USE_ESPHOME_TASK_LOG_BUFFER @@ -481,7 +481,7 @@ class Logger : public Component { }; extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class LoggerMessageTrigger : public Trigger { +class LoggerMessageTrigger final : public Trigger { public: explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) { parent->add_log_callback(this, From 478a876b0156763e27605ed22e034fb2ed442a3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 20:13:51 -0700 Subject: [PATCH 20/42] [mdns] Mark MDNSComponent as final (#14290) --- esphome/components/mdns/mdns_component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 32f8f16ec1..13c8ccf288 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -40,7 +40,7 @@ struct MDNSService { FixedVector txt_records; }; -class MDNSComponent : public Component { +class MDNSComponent final : public Component { public: void setup() override; void dump_config() override; From cced0a82b55d17ae402425c86dfd9654ed4f7d86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 20:14:04 -0700 Subject: [PATCH 21/42] [ota] Mark OTA backend and component leaf classes as final (#14287) --- esphome/components/esphome/ota/ota_esphome.h | 2 +- esphome/components/http_request/ota/ota_http_request.h | 2 +- esphome/components/ota/ota_backend_arduino_libretiny.h | 2 +- esphome/components/ota/ota_backend_arduino_rp2040.h | 2 +- esphome/components/ota/ota_backend_esp8266.h | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/components/ota/ota_backend_host.h | 2 +- esphome/components/web_server/ota/ota_web_server.h | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c9e89c82ba..53715cfe6a 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -12,7 +12,7 @@ namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. -class ESPHomeOTAComponent : public ota::OTAComponent { +class ESPHomeOTAComponent final : public ota::OTAComponent { public: enum class OTAState : uint8_t { IDLE, diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index e3f1a4aa90..6d39b0d466 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -22,7 +22,7 @@ enum OtaHttpRequestError : uint8_t { OTA_CONNECTION_ERROR = 0x12, }; -class OtaHttpRequestComponent : public ota::OTAComponent, public Parented { +class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented { public: void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 6d9b7a96d5..8f9d268eec 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -7,7 +7,7 @@ namespace esphome { namespace ota { -class ArduinoLibreTinyOTABackend : public OTABackend { +class ArduinoLibreTinyOTABackend final : public OTABackend { public: OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index b9e10d506c..6a708f9c57 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -9,7 +9,7 @@ namespace esphome { namespace ota { -class ArduinoRP2040OTABackend : public OTABackend { +class ArduinoRP2040OTABackend final : public OTABackend { public: OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index a9d6dd2ccc..52f657f006 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -12,7 +12,7 @@ namespace esphome::ota { /// OTA backend for ESP8266 using native SDK functions. /// This implementation bypasses the Arduino Updater library to save ~228 bytes of RAM /// by not having a global Update object in .bss. -class ESP8266OTABackend : public OTABackend { +class ESP8266OTABackend final : public OTABackend { public: OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 764010e614..7f7f6115c5 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,7 +10,7 @@ namespace esphome { namespace ota { -class IDFOTABackend : public OTABackend { +class IDFOTABackend final : public OTABackend { public: OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index ae7d0cb0b3..5a2dcfcf39 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -7,7 +7,7 @@ namespace esphome::ota { /// Stub OTA backend for host platform - allows compilation but does not implement OTA. /// All operations return error codes immediately. This enables configurations with /// OTA triggers to compile for host platform during development. -class HostOTABackend : public OTABackend { +class HostOTABackend final : public OTABackend { public: OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; diff --git a/esphome/components/web_server/ota/ota_web_server.h b/esphome/components/web_server/ota/ota_web_server.h index 53ff99899c..0857c31c5d 100644 --- a/esphome/components/web_server/ota/ota_web_server.h +++ b/esphome/components/web_server/ota/ota_web_server.h @@ -9,7 +9,7 @@ namespace esphome::web_server { -class WebServerOTAComponent : public ota::OTAComponent { +class WebServerOTAComponent final : public ota::OTAComponent { public: void setup() override; void dump_config() override; From ee4d67930f11a4af1997190f9dc576db8a0d05c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 20:14:16 -0700 Subject: [PATCH 22/42] [api] Mark ListEntitiesIterator and InitialStateIterator as final (#14284) --- esphome/components/api/list_entities.h | 2 +- esphome/components/api/subscribe_state.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 90769f9a81..7d0eb5bb13 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -15,7 +15,7 @@ class APIConnection; return this->client_->schedule_message_(entity, ResponseType::MESSAGE_TYPE, ResponseType::ESTIMATED_SIZE); \ } -class ListEntitiesIterator : public ComponentIterator { +class ListEntitiesIterator final : public ComponentIterator { public: ListEntitiesIterator(APIConnection *client); #ifdef USE_BINARY_SENSOR diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 6f8577ca7b..9edf0f0f0c 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -16,7 +16,7 @@ class APIConnection; return this->client_->send_##entity_type##_state(entity); \ } -class InitialStateIterator : public ComponentIterator { +class InitialStateIterator final : public ComponentIterator { public: InitialStateIterator(APIConnection *client); #ifdef USE_BINARY_SENSOR From d52f8c9c6f250024f8ce4a353100919cb2b25215 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 20:14:33 -0700 Subject: [PATCH 23/42] [web_server] Mark classes as final (#14283) --- esphome/components/web_server/list_entities.cpp | 2 -- esphome/components/web_server/list_entities.h | 3 +-- esphome/components/web_server/web_server.h | 6 +++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 8458298062..ebe7bf4450 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -14,8 +14,6 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es) : web_server_(ws), events_(es) {} #endif -ListEntitiesIterator::~ListEntitiesIterator() {} - #ifdef USE_BINARY_SENSOR bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) { this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator); diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index d0a4fa2725..6a84066109 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -17,14 +17,13 @@ class DeferredUpdateEventSource; #endif class WebServer; -class ListEntitiesIterator : public ComponentIterator { +class ListEntitiesIterator final : public ComponentIterator { public: #ifdef USE_ESP32 ListEntitiesIterator(const WebServer *ws, esphome::web_server_idf::AsyncEventSource *es); #elif defined(USE_ARDUINO) ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es); #endif - virtual ~ListEntitiesIterator(); #ifdef USE_BINARY_SENSOR bool on_binary_sensor(binary_sensor::BinarySensor *obj) override; #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 76c1c8b0bd..64c492f82b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -107,7 +107,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE }; using message_generator_t = json::SerializationBuffer<>(WebServer *, void *); class DeferredUpdateEventSourceList; -class DeferredUpdateEventSource : public AsyncEventSource { +class DeferredUpdateEventSource final : public AsyncEventSource { friend class DeferredUpdateEventSourceList; /* @@ -163,7 +163,7 @@ class DeferredUpdateEventSource : public AsyncEventSource { void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); }; -class DeferredUpdateEventSourceList : public std::list { +class DeferredUpdateEventSourceList final : public std::list { protected: void on_client_connect_(DeferredUpdateEventSource *source); void on_client_disconnect_(DeferredUpdateEventSource *source); @@ -187,7 +187,7 @@ class DeferredUpdateEventSourceList : public std::list Date: Wed, 25 Feb 2026 20:14:53 -0700 Subject: [PATCH 24/42] [safe_mode] Mark SafeModeComponent and SafeModeTrigger as final (#14282) --- esphome/components/safe_mode/automation.h | 2 +- esphome/components/safe_mode/safe_mode.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 952ed4da33..1d82ac45f1 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -8,7 +8,7 @@ namespace esphome::safe_mode { -class SafeModeTrigger : public Trigger<> { +class SafeModeTrigger final : public Trigger<> { public: explicit SafeModeTrigger(SafeModeComponent *parent) { parent->add_on_safe_mode_callback([this]() { trigger(); }); diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index a4d27c15da..902b8c415d 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -15,7 +15,7 @@ namespace esphome::safe_mode { constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures -class SafeModeComponent : public Component { +class SafeModeComponent final : public Component { public: bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after); From 6c253f0c71958a182cf1d9c4ad64a10b2b62957f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:40:43 -0500 Subject: [PATCH 25/42] [sprinkler] Fix millis overflow and underflow bugs (#14299) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sprinkler/sprinkler.cpp | 81 +++++++++++----------- esphome/components/sprinkler/sprinkler.h | 4 +- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9e423c1760..d82d7baaf6 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -84,32 +84,30 @@ SprinklerValveOperator::SprinklerValveOperator(SprinklerValve *valve, Sprinkler : controller_(controller), valve_(valve) {} void SprinklerValveOperator::loop() { + // Use wrapping subtraction so 32-bit millis() rollover is handled correctly: + // (now - start) yields the true elapsed time even across the 49.7-day boundary. uint32_t now = App.get_loop_component_start_time(); - if (now >= this->start_millis_) { // dummy check - switch (this->state_) { - case STARTING: - if (now > (this->start_millis_ + this->start_delay_)) { - this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state - } - break; + switch (this->state_) { + case STARTING: + if ((now - *this->start_millis_) > this->start_delay_) { + this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state + } + break; - case ACTIVE: - if (now > (this->start_millis_ + this->start_delay_ + this->run_duration_)) { - this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down - } - break; + case ACTIVE: + if ((now - *this->start_millis_) > (this->start_delay_ + this->run_duration_)) { + this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down + } + break; - case STOPPING: - if (now > (this->stop_millis_ + this->stop_delay_)) { - this->kill_(); // stop_delay_has been exceeded, ensure all valves are off - } - break; + case STOPPING: + if ((now - *this->stop_millis_) > this->stop_delay_) { + this->kill_(); // stop_delay_has been exceeded, ensure all valves are off + } + break; - default: - break; - } - } else { // perhaps millis() rolled over...or something else is horribly wrong! - this->stop(); // bail out (TODO: handle this highly unlikely situation better...) + default: + break; } } @@ -124,11 +122,11 @@ void SprinklerValveOperator::set_valve(SprinklerValve *valve) { if (this->state_ != IDLE) { // Only kill if not already idle this->kill_(); // ensure everything is off before we let go! } - this->state_ = IDLE; // reset state - this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it - this->start_millis_ = 0; // reset because (new) valve has not been started yet - this->stop_millis_ = 0; // reset because (new) valve has not been started yet - this->valve_ = valve; // finally, set the pointer to the new valve + this->state_ = IDLE; // reset state + this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it + this->start_millis_.reset(); // reset because (new) valve has not been started yet + this->stop_millis_.reset(); // reset because (new) valve has not been started yet + this->valve_ = valve; // finally, set the pointer to the new valve } } @@ -162,7 +160,7 @@ void SprinklerValveOperator::start() { } else { this->run_(); // there is no start_delay_, so just start the pump and valve } - this->stop_millis_ = 0; + this->stop_millis_.reset(); this->start_millis_ = millis(); // save the time the start request was made } @@ -189,22 +187,25 @@ void SprinklerValveOperator::stop() { uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_ / 1000; } uint32_t SprinklerValveOperator::time_remaining() { - if (this->start_millis_ == 0) { + if (!this->start_millis_.has_value()) { return this->run_duration(); // hasn't been started yet } - if (this->stop_millis_) { - if (this->stop_millis_ - this->start_millis_ >= this->start_delay_ + this->run_duration_) { + if (this->stop_millis_.has_value()) { + uint32_t elapsed = *this->stop_millis_ - *this->start_millis_; + if (elapsed >= this->start_delay_ + this->run_duration_) { return 0; // valve was active for more than its configured duration, so we are done - } else { - // we're stopped; return time remaining - return (this->run_duration_ - (this->stop_millis_ - this->start_millis_)) / 1000; } + if (elapsed <= this->start_delay_) { + return this->run_duration_ / 1000; // stopped during start delay, full run duration remains + } + return (this->run_duration_ - (elapsed - this->start_delay_)) / 1000; } - auto completed_millis = this->start_millis_ + this->start_delay_ + this->run_duration_; - if (completed_millis > millis()) { - return (completed_millis - millis()) / 1000; // running now + uint32_t elapsed = millis() - *this->start_millis_; + uint32_t total_duration = this->start_delay_ + this->run_duration_; + if (elapsed < total_duration) { + return (total_duration - elapsed) / 1000; // running now } return 0; // run completed } @@ -593,7 +594,7 @@ void Sprinkler::set_repeat(optional repeat) { if (this->repeat_number_ == nullptr) { return; } - if (this->repeat_number_->state == repeat.value()) { + if (this->repeat_number_->state == repeat.value_or(0)) { return; } auto call = this->repeat_number_->make_call(); @@ -793,7 +794,7 @@ void Sprinkler::start_single_valve(const optional valve_number, optional void Sprinkler::queue_valve(optional valve_number, optional run_duration) { if (valve_number.has_value()) { if (this->is_a_valid_valve(valve_number.value()) && (this->queued_valves_.size() < this->max_queue_size_)) { - SprinklerQueueItem item{valve_number.value(), run_duration.value()}; + SprinklerQueueItem item{valve_number.value(), run_duration.value_or(0)}; this->queued_valves_.insert(this->queued_valves_.begin(), item); ESP_LOGD(TAG, "Valve %zu placed into queue with run duration of %" PRIu32 " seconds", valve_number.value_or(0), run_duration.value_or(0)); @@ -1080,7 +1081,7 @@ uint32_t Sprinkler::total_cycle_time_enabled_incomplete_valves() { } } - if (incomplete_valve_count >= enabled_valve_count) { + if (incomplete_valve_count > 0 && incomplete_valve_count >= enabled_valve_count) { incomplete_valve_count--; } if (incomplete_valve_count) { diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index a3cdef5b1a..2598a5606a 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -141,8 +141,8 @@ class SprinklerValveOperator { uint32_t start_delay_{0}; uint32_t stop_delay_{0}; uint32_t run_duration_{0}; - uint64_t start_millis_{0}; - uint64_t stop_millis_{0}; + optional start_millis_{}; + optional stop_millis_{}; Sprinkler *controller_{nullptr}; SprinklerValve *valve_{nullptr}; SprinklerState state_{IDLE}; From a05d0202e696b5498787b3b895acd71915844ac5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 09:21:27 -0700 Subject: [PATCH 26/42] [core] ESP32: massively reduce main loop socket polling overhead by replacing select() (#14249) --- esphome/components/socket/__init__.py | 8 +- esphome/components/socket/socket.h | 2 +- esphome/core/application.cpp | 102 ++++++--- esphome/core/application.h | 47 ++-- esphome/core/lwip_fast_select.c | 216 ++++++++++++++++++ esphome/core/lwip_fast_select.h | 33 +++ .../socket/test_wake_loop_threadsafe.py | 39 ++++ 7 files changed, 397 insertions(+), 50 deletions(-) create mode 100644 esphome/core/lwip_fast_select.c create mode 100644 esphome/core/lwip_fast_select.h diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index d82f0c7aba..5f4d04eb44 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -134,6 +134,8 @@ def require_wake_loop_threadsafe() -> None: IMPORTANT: This is for background thread context only, NOT ISR context. Socket operations are not safe to call from ISR handlers. + On ESP32, FreeRTOS task notifications are used instead (no socket needed). + Example: from esphome.components import socket @@ -147,8 +149,10 @@ def require_wake_loop_threadsafe() -> None: ): CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") - # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) + if not CORE.is_esp32: + # Only non-ESP32 platforms need a UDP socket for wake notifications. + # ESP32 uses FreeRTOS task notifications instead (no socket needed). + consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index c0098d689a..a771e2fe1a 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -71,7 +71,7 @@ class Socket { int get_fd() const { return -1; } #endif - /// Check if socket has data ready to read + /// Check if socket has data ready to read. Must only be called from the main loop thread. /// For select()-based sockets: non-virtual, checks Application's select() results /// For LWIP raw TCP sockets: virtual, checks internal buffer state #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 6a7683a987..fd6a14b50f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -9,6 +9,9 @@ #endif #ifdef USE_ESP32 #include +#include "esphome/core/lwip_fast_select.h" +#include +#include #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -144,8 +147,14 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - // Set up wake socket for waking main loop from tasks +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake. + // Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used + // unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled. + esphome_lwip_fast_select_init(); +#endif +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) + // Set up wake socket for waking main loop from tasks (non-ESP32 only) this->setup_wake_loop_threadsafe_(); #endif @@ -523,7 +532,7 @@ void Application::enable_pending_loops_() { } void Application::before_loop_tasks_(uint32_t loop_start_time) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -576,11 +585,15 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); +#ifdef USE_ESP32 + // Hook the socket's netconn callback for instant wake on receive events + esphome_lwip_hook_socket(fd); +#else this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { this->max_fd_ = fd; } +#endif return true; } @@ -595,12 +608,14 @@ void Application::unregister_socket_fd(int fd) { if (this->socket_fds_[i] != fd) continue; - // Swap with last element and pop - O(1) removal since order doesn't matter + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback on ESP32 — all LwIP sockets share + // the same static event_callback, and the socket will be closed by the caller. if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); +#ifndef USE_ESP32 this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { this->max_fd_ = -1; @@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } +#endif return; } } @@ -616,16 +632,41 @@ void Application::unregister_socket_fd(int fd) { #endif void Application::yield_with_select_(uint32_t delay_ms) { - // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run - // since select() with 0 timeout only polls without yielding. -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!this->socket_fds_.empty()) { + // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // ESP32 fast path: reads rcvevent directly via lwip_socket_dbg_get_socket() (~215 ns per socket). + // Safe because this runs on the main loop which owns socket lifetime (create, read, close). + if (delay_ms == 0) [[unlikely]] { + yield(); + return; + } + + // Check if any socket already has pending data before sleeping. + // If a socket still has unread data (rcvevent > 0) but the task notification was already + // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. + // This scan preserves select() semantics: return immediately when any fd is ready. + for (int fd : this->socket_fds_) { + if (esphome_lwip_socket_has_data(fd)) { + yield(); + return; + } + } + + // Sleep with instant wake via FreeRTOS task notification. + // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. + // Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task — + // background tasks won't call wake, so this degrades to a pure timeout (same as old select path). + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); + +#elif defined(USE_SOCKET_SELECT_SUPPORT) + // Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform). + // ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32 + // use LwIP under the hood, so the fast path handles all ESP32 socket implementations. + if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed - if (this->socket_fds_changed_) { + if (this->socket_fds_changed_) [[unlikely]] { FD_ZERO(&this->base_read_fds_); - // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: - // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) - // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE + // fd bounds are validated in register_socket_fd() for (int fd : this->socket_fds_) { FD_SET(fd, &this->base_read_fds_); } @@ -641,7 +682,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; // Call select with timeout -#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS)) +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); #else int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); @@ -651,19 +692,18 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret < 0 && errno != EINTR) { - // Actual error - log and fall back to delay - ESP_LOGW(TAG, "select() failed with errno %d", errno); - delay(delay_ms); + if (ret >= 0 || errno == EINTR) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding + if (delay_ms == 0) [[unlikely]] { + yield(); + } + return; } - // When delay_ms is 0, we need to yield since select(0) doesn't yield - if (delay_ms == 0) { - yield(); - } - } else { - // No sockets registered, use regular delay - delay(delay_ms); + // select() error - log and fall through to delay() + ESP_LOGW(TAG, "select() failed with errno %d", errno); } + // No sockets registered or select() failed - use regular delay + delay(delay_ms); #elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) // No select support but can wake on socket activity via esp_schedule() socket::socket_delay(delay_ms); @@ -676,6 +716,14 @@ void Application::yield_with_select_(uint32_t delay_ms) { Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + +#ifdef USE_ESP32 +void Application::wake_loop_threadsafe() { + // Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe) + esphome_lwip_wake_main_loop(); +} +#else // !USE_ESP32 + void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); @@ -742,6 +790,8 @@ void Application::wake_loop_threadsafe() { lwip_send(this->wake_socket_fd_, &dummy, 1, 0); } } +#endif // USE_ESP32 + #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) void Application::get_build_time_string(std::span buffer) { diff --git a/esphome/core/application.h b/esphome/core/application.h index cd275bb97f..f5df5e7bdf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,10 +24,14 @@ #endif #ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_ESP32 +#include "esphome/core/lwip_fast_select.h" +#else #include #ifdef USE_WAKE_LOOP_THREADSAFE #include #endif +#endif #endif // USE_SOCKET_SELECT_SUPPORT #ifdef USE_BINARY_SENSOR @@ -491,15 +495,12 @@ class Application { /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); - /// Check if there's data available on a socket without blocking - /// This function is thread-safe for reading, but should be called after select() has run - /// The read_fds_ is only modified by select() in the main loop - bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from a FreeRTOS task - /// Thread-safe, can be called from task context to immediately wake select() - /// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe) + /// Wake the main event loop from another FreeRTOS task. + /// Thread-safe, but must only be called from task context (NOT ISR-safe). + /// On ESP32: uses xTaskNotifyGive (<1 us) + /// On other platforms: uses UDP loopback socket void wake_loop_threadsafe(); #endif #endif @@ -510,10 +511,14 @@ class Application { #ifdef USE_SOCKET_SELECT_SUPPORT /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// Safe because: fd was validated in register_socket_fd() at registration time, - /// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded). - /// FD_ISSET may include its own upper bounds check depending on platform. + /// Main loop only — on ESP32, reads rcvevent via lwip_socket_dbg_get_socket() + /// which has no refcount; safe only because the main loop owns socket lifetime + /// (creates, reads, and closes sockets on the same thread). +#ifdef USE_ESP32 + bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } +#else bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } +#endif #endif void register_component_(Component *comp); @@ -541,7 +546,7 @@ class Application { /// Perform a delay while also monitoring socket file descriptors for readiness void yield_with_select_(uint32_t delay_ms); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) void setup_wake_loop_threadsafe_(); // Create wake notification socket inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif @@ -571,7 +576,7 @@ class Application { FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif #endif @@ -584,7 +589,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -600,14 +605,14 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes #endif -#ifdef USE_SOCKET_SELECT_SUPPORT - // Variable-sized members +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) + // Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly) + fd_set read_fds_{}; // Working fd_set: populated by select() fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ #endif // StaticVectors (largest members - contain actual array data inline) @@ -694,7 +699,7 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -704,8 +709,8 @@ static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; inline void Application::drain_wake_notifications_() { // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready(this->wake_socket_fd_)) { + // Must check is_socket_ready_() to avoid blocking on empty socket + if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) { char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; // Drain all pending notifications with non-blocking reads // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK @@ -716,6 +721,6 @@ inline void Application::drain_wake_notifications_() { } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) } // namespace esphome diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c new file mode 100644 index 0000000000..70a6482d48 --- /dev/null +++ b/esphome/core/lwip_fast_select.c @@ -0,0 +1,216 @@ +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// +// This must be a .c file (not .cpp) because: +// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers +// 2. The netconn callback is a C function pointer +// +// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. +// +// Thread safety analysis +// ====================== +// Three threads interact with this code: +// 1. Main loop task — calls init, has_data, hook +// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent +// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex) +// 3. Background tasks — call wake_main_loop +// +// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524): +// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c +// - event_callback (static, same for all sockets): L327 +// - DEFAULT_SOCKET_EVENTCB = event_callback: L328 +// - tryget_socket_unconn_nouse (direct array lookup): L450 +// - lwip_socket_dbg_get_socket (thin wrapper): L461 +// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759 +// - event_callback definition: L2538 +// - SYS_ARCH_PROTECT before rcvevent switch: L2578 +// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582 +// - SYS_ARCH_UNPROTECT after switch: L2615 +// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h +// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495 +// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506 +// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock) +// +// Socket slot lifetime +// ==================== +// This code reads struct lwip_sock fields without SYS_ARCH_PROTECT. The safety +// argument requires that the slot cannot be freed while we read it. +// +// In LwIP, the socket table is a static array and slots are only freed via: +// lwip_close() -> lwip_close_internal() -> free_socket_free_elements() -> free_socket() +// The TCP/IP thread does NOT call free_socket(). On link loss, RST, or timeout +// it frees the TCP PCB and signals the netconn (rcvevent++ to indicate EOF), but +// the netconn and lwip_sock slot remain allocated until the application calls +// lwip_close(). ESPHome removes the fd from the monitored set before calling +// lwip_close(). +// +// Therefore lwip_socket_dbg_get_socket(fd) plus a volatile read of rcvevent +// (to prevent compiler reordering or caching) is safe as long as the application +// is single-writer for close. ESPHome guarantees this by design: all socket +// create/read/close happens on the main loop. fd numbers are not reused while +// the slot remains allocated, and the slot remains allocated until lwip_close(). +// Any change in LwIP that allows free_socket() to be called outside lwip_close() +// would invalidate this assumption. +// +// LwIP source references for slot lifetime: +// sockets.c (same commit as above): +// - alloc_socket (slot allocation): L419 +// - free_socket (slot deallocation): L384 +// - free_socket_free_elements (called from lwip_close_internal): L393 +// - lwip_close_internal (only caller of free_socket_free_elements): L2355 +// - lwip_close (only caller of lwip_close_internal): L2450 +// +// Shared state and safety rationale: +// +// s_main_loop_task (TaskHandle_t, 4 bytes): +// Written once by main loop in init(). Read by TCP/IP thread (in callback) +// and background tasks (in wake). +// Safe: write-once-then-read pattern. Socket hooks may run before init(), +// but the NULL check on s_main_loop_task in the callback provides correct +// degraded behavior — notifications are simply skipped until init() completes. +// +// s_original_callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket() (only when NULL — set once). +// Read by TCP/IP thread in esphome_socket_event_callback(). +// Safe: set-once pattern. The first hook_socket() captures the original callback. +// All subsequent hooks see it already set and skip the write. The TCP/IP thread +// only reads this after the callback pointer has been swapped (which happens after +// the write), so it always sees the initialized value. +// +// sock->conn->callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket(). Never restored — all LwIP sockets share +// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently. +// Read by TCP/IP thread when invoking the callback. +// Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). +// The TCP/IP thread will see either the old or new pointer atomically — never a +// torn value. Both the wrapper and original callbacks are valid at all times +// (the wrapper itself calls the original), so either value is correct. +// +// sock->rcvevent (s16_t, 2 bytes): +// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT. +// Read by main loop in has_data() via volatile cast. +// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex, which internally +// uses a critical section with memory barrier (rsync on dual-core Xtensa; on +// single-core builds the spinlock is compiled out, but cross-core visibility is +// not an issue). The volatile cast prevents the compiler +// from caching the read. Aligned 16-bit reads are single-instruction loads on +// Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values. +// +// FreeRTOS task notification value: +// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks +// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Safe: FreeRTOS notification APIs are thread-safe by design (use internal +// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// the notification count simply increments. + +#ifdef USE_ESP32 + +// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. +#include +#include +#include +#include + +#include "esphome/core/lwip_fast_select.h" + +#include + +// Compile-time verification of thread safety assumptions. +// On ESP32 (Xtensa/RISC-V), naturally-aligned reads/writes up to 32 bits are atomic. +// These asserts ensure our cross-thread shared state meets those requirements. + +// Pointer types must fit in a single 32-bit store (atomic write) +_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access"); +_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access"); + +// rcvevent must fit in a single atomic read +_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access"); + +// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V. +// Misaligned access would not be atomic even if the size is <= 4 bytes. +_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0, + "netconn.callback must be naturally aligned for atomic access"); +_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0, + "lwip_sock.rcvevent must be naturally aligned for atomic access"); + +// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. +static TaskHandle_t s_main_loop_task = NULL; + +// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. +static netconn_callback s_original_callback = NULL; + +// Wrapper callback: calls original event_callback + notifies main loop task. +// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). +static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { + // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, + // signals any select() waiters. This preserves all LwIP behavior. + // s_original_callback is always valid here: hook_socket() sets it before swapping + // the callback pointer, so this wrapper cannot run until it's initialized. + s_original_callback(conn, evt, len); + // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Only notify on receive events to avoid spurious wakeups from send-ready events. + // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS + // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions + // already wake the main loop through the RCVPLUS path. + if (evt == NETCONN_EVT_RCVPLUS) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } + } +} + +void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } + +// lwip_socket_dbg_get_socket() is a thin wrapper around the static +// tryget_socket_unconn_nouse() — a direct array lookup without the refcount +// that get_socket()/done_socket() uses. This is safe because: +// 1. The only path to free_socket() is lwip_close(), called exclusively from the main loop +// 2. The TCP/IP thread never frees socket slots (see "Socket slot lifetime" above) +// 3. Both has_data() reads and lwip_close() run on the main loop — no concurrent free +// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select(). +// Returns the sock only if both the sock and its netconn are valid, NULL otherwise. +static inline struct lwip_sock *get_sock(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return NULL; + return sock; +} + +bool esphome_lwip_socket_has_data(int fd) { + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) + return false; + // volatile prevents the compiler from caching/reordering this cross-thread read. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a + // FreeRTOS mutex with a memory barrier (rsync on Xtensa), ensuring the value is + // visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH) on + // Xtensa/RISC-V and cannot produce torn values. + return *(volatile s16_t *) &sock->rcvevent > 0; +} + +void esphome_lwip_hook_socket(int fd) { + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) + return; + + // Save original callback once — all LwIP sockets share the same static event_callback + // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). + if (s_original_callback == NULL) { + s_original_callback = sock->conn->callback; + } + + // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). + // TCP/IP thread sees either old or new pointer — both are valid. + sock->conn->callback = esphome_socket_event_callback; +} + +// Wake the main loop from another FreeRTOS task. NOT ISR-safe. +void esphome_lwip_wake_main_loop(void) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +#endif // USE_ESP32 diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h new file mode 100644 index 0000000000..73a89fdc3d --- /dev/null +++ b/esphome/core/lwip_fast_select.h @@ -0,0 +1,33 @@ +#pragma once + +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Initialize fast select — must be called from the main loop task during setup(). +/// Saves the current task handle for xTaskNotifyGive() wake notifications. +void esphome_lwip_fast_select_init(void); + +/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). +/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that +/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime: +/// both has_data reads and socket close/unregister happen on the main loop thread. +bool esphome_lwip_socket_has_data(int fd); + +/// Hook a socket's netconn callback to notify the main loop task on receive events. +/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Must be called from the main loop after socket creation. +void esphome_lwip_hook_socket(int fd); + +/// Wake the main loop task from another FreeRTOS task — costs <1 us. +/// NOT ISR-safe — must only be called from task context. +void esphome_lwip_wake_main_loop(void); + +#ifdef __cplusplus +} +#endif diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index a40b6068a8..28b4ee564f 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,9 +1,21 @@ from esphome.components import socket +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE +def _setup_platform(platform=PLATFORM_ESP8266) -> None: + """Set up CORE.data with a platform for testing.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_require_wake_loop_threadsafe__first_call() -> None: """Test that first call sets up define and consumes socket.""" + _setup_platform() CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -32,6 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None: def test_require_wake_loop_threadsafe__multiple_calls() -> None: """Test that multiple calls only set up once.""" + _setup_platform() # Call three times CORE.config = {"openthread": True} socket.require_wake_loop_threadsafe() @@ -75,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) assert "socket.wake_loop_threadsafe" not in udp_consumers assert udp_consumers == initial_udp + + +def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None: + """Test that ESP32 uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_ESP32) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify the define was added + assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True + assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) + + # Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications) + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers + + +def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None: + """Test that non-ESP32 platforms consume a UDP socket for wake notifications.""" + _setup_platform(PLATFORM_ESP8266) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify UDP socket was consumed + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert udp_consumers.get("socket.wake_loop_threadsafe") == 1 From be000eab4e0cb4f9eaa3807b069c143c4eeee5b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 11:02:52 -0700 Subject: [PATCH 27/42] [ci] Add undocumented C++ API change checkbox and auto-label (#14317) --- .github/PULL_REQUEST_TEMPLATE.md | 5 +++-- .github/scripts/auto-label-pr/constants.js | 1 + .github/scripts/auto-label-pr/detectors.js | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d1ef3bd822..965b186c31 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,8 +6,9 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] Developer breaking change (an API change that could break external components) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change) +- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api) +- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations) - [ ] Code quality improvements to existing code or addition of tests - [ ] Other diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index bd60d8c766..8c3a62cf19 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -27,6 +27,7 @@ module.exports = { 'new-feature', 'breaking-change', 'developer-breaking-change', + 'undocumented-api-change', 'code-quality', 'deprecated-component' ], diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index f502a85666..a45a84f219 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -238,6 +238,7 @@ async function detectPRTemplateCheckboxes(context) { { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, + { pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' }, { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } ]; From ae16c3bae7450db7bba0b1c06755022e5b2696e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 11:25:36 -0700 Subject: [PATCH 28/42] Add socket compile tests for libretiny platforms (#14314) --- tests/components/socket/test.bk72xx-ard.yaml | 1 + tests/components/socket/test.ln882x-ard.yaml | 1 + tests/components/socket/test.rtl87xx-ard.yaml | 1 + 3 files changed, 3 insertions(+) create mode 100644 tests/components/socket/test.bk72xx-ard.yaml create mode 100644 tests/components/socket/test.ln882x-ard.yaml create mode 100644 tests/components/socket/test.rtl87xx-ard.yaml diff --git a/tests/components/socket/test.bk72xx-ard.yaml b/tests/components/socket/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/socket/test.ln882x-ard.yaml b/tests/components/socket/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/socket/test.rtl87xx-ard.yaml b/tests/components/socket/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 1912dcf03da6f0d1590f3708cf8ddad9b404ef04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 12:07:42 -0700 Subject: [PATCH 29/42] [core] Use placement new for global Application instance (#14052) --- esphome/core/application.cpp | 10 +++++++++- esphome/core/config.py | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index fd6a14b50f..a9753da1b5 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -713,7 +713,15 @@ void Application::yield_with_select_(uint32_t delay_ms) { #endif } -Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// App storage — asm label shares the linker symbol with "extern Application App". +// char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted. +// Constructed via placement new in the generated setup(). +#ifndef __GXX_ABI_VERSION +#error "Application placement new requires Itanium C++ ABI (GCC/Clang)" +#endif +static_assert(std::is_default_constructible::value, "Application must be default-constructible"); +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +alignas(Application) char app_storage[sizeof(Application)] asm("_ZN7esphome3AppE"); #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) diff --git a/esphome/core/config.py b/esphome/core/config.py index 21ed8ced1a..215432835a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -512,6 +512,9 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) + # Construct App via placement new — see application.cpp for storage details + cg.add_global(cg.RawStatement("#include ")) + cg.add(cg.RawExpression("new (&App) Application()")) cg.add( cg.App.pre_setup( config[CONF_NAME], From 4c3bb1596e876259f496cafc682dfb75e34d3089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 12:14:46 -0700 Subject: [PATCH 30/42] [wifi] Use memcpy-based insertion sort for scan results (#13960) --- esphome/components/wifi/wifi_component.cpp | 50 ++++++++++++++++++++-- esphome/components/wifi/wifi_component.h | 9 ++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d5d0419395..1e6961b8bd 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -1334,20 +1335,61 @@ void WiFiComponent::start_scanning() { // Using insertion sort instead of std::stable_sort saves flash memory // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements) +// +// Uses raw memcpy instead of copy assignment to avoid CompactString's +// destructor/constructor overhead (heap delete[]/new[] for long SSIDs). +// Copy assignment calls ~CompactString() then placement-new for every shift, +// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+ +// networks (e.g., captive portal showing full scan results), this caused +// event loop blocking from hundreds of heap operations in a tight loop. +// +// This is safe because we're permuting elements within the same array — +// each slot is overwritten exactly once, so no ownership duplication occurs. +// All members of WiFiScanResult are either trivially copyable (bssid, channel, +// rssi, priority, flags) or CompactString, which stores either inline data or +// a heap pointer — never a self-referential pointer (unlike std::string's SSO +// on some implementations). This was not possible before PR#13472 replaced +// std::string with CompactString, since std::string's internal layout is +// implementation-defined and may use self-referential pointers. +// +// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for +// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee. template static void insertion_sort_scan_results(VectorType &results) { + // memcpy-based sort requires no self-referential pointers or virtual dispatch. + // These static_asserts guard the assumptions. If any fire, the memcpy sort + // must be reviewed for safety before updating the expected values. + // + // No vtable pointers (memcpy would corrupt vptr) + static_assert(!std::is_polymorphic::value, "WiFiScanResult must not have vtable"); + static_assert(!std::is_polymorphic::value, "CompactString must not have vtable"); + // Standard layout ensures predictable memory layout with no virtual bases + // and no mixed-access-specifier reordering + static_assert(std::is_standard_layout::value, "WiFiScanResult must be standard layout"); + static_assert(std::is_standard_layout::value, "CompactString must be standard layout"); + // Size checks catch added/removed fields that may need safety review + static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe"); + static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe"); + // Alignment must match for reinterpret_cast of key_buf to be valid + static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t"); const size_t size = results.size(); + constexpr size_t elem_size = sizeof(WiFiScanResult); + // Suppress warnings for intentional memcpy on non-trivially-copyable type. + // Safety is guaranteed by the static_asserts above and the permutation invariant. + // NOLINTNEXTLINE(bugprone-undefined-memory-manipulation) + auto *memcpy_fn = &memcpy; for (size_t i = 1; i < size; i++) { - // Make a copy to avoid issues with move semantics during comparison - WiFiScanResult key = results[i]; + alignas(WiFiScanResult) uint8_t key_buf[elem_size]; + memcpy_fn(key_buf, &results[i], elem_size); + const auto &key = *reinterpret_cast(key_buf); int32_t j = i - 1; // Move elements that are worse than key to the right // For stability, we only move if key is strictly better than results[j] while (j >= 0 && wifi_scan_result_is_better(key, results[j])) { - results[j + 1] = results[j]; + memcpy_fn(&results[j + 1], &results[j], elem_size); j--; } - results[j + 1] = key; + memcpy_fn(&results[j + 1], key_buf, elem_size); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 984930c80c..63c7039f21 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -10,6 +10,7 @@ #include #include +#include #include #ifdef USE_LIBRETINY @@ -223,6 +224,14 @@ class CompactString { }; static_assert(sizeof(CompactString) == 20, "CompactString must be exactly 20 bytes"); +// CompactString is not trivially copyable (non-trivial destructor/copy for heap case). +// However, its layout has no self-referential pointers: storage_[] contains either inline +// data or an external heap pointer — never a pointer to itself. This is unlike libstdc++ +// std::string SSO where _M_p points to _M_local_buf within the same object. +// This property allows memcpy-based permutation sorting where each element ends up in +// exactly one slot (no ownership duplication). These asserts document that layout property. +static_assert(std::is_standard_layout::value, "CompactString must be standard layout"); +static_assert(!std::is_polymorphic::value, "CompactString must not have vtable"); class WiFiAP { friend class WiFiComponent; From c149be20fcf8d2eecc8a4c518673f2bb8cc1bc42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:31:47 +0000 Subject: [PATCH 31/42] Bump aioesphomeapi from 44.1.0 to 44.2.0 (#14324) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d22097b3ca..95e3710f9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.1.7 esphome-dashboard==20260210.0 -aioesphomeapi==44.1.0 +aioesphomeapi==44.2.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 8da1e3ce21de2e720294c1ff05d3021a07b0ddb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:32:53 +0000 Subject: [PATCH 32/42] Bump ruff from 0.15.2 to 0.15.3 (#14323) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07d02e0e3c..d70dd9d0e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.2 + rev: v0.15.3 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 3e5dc8a90c..88a38ffa99 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.2 # also change in .pre-commit-config.yaml when updating +ruff==0.15.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From d325890148091a682c31185ccf1c5d3864e8c303 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:48:05 -0500 Subject: [PATCH 33/42] [cc1101] Transition through IDLE in begin_tx/begin_rx for reliable state changes (#14321) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/cc1101/cc1101.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index b6973da78d..51aa88b8f7 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -242,6 +242,9 @@ void CC1101Component::begin_tx() { if (this->gdo0_pin_ != nullptr) { this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } + // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can + // block TX entry when strobing from RX, and to ensure FS_AUTOCAL calibration + this->enter_idle_(); if (!this->enter_tx_()) { ESP_LOGW(TAG, "Failed to enter TX state!"); } @@ -252,6 +255,8 @@ void CC1101Component::begin_rx() { if (this->gdo0_pin_ != nullptr) { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); } + // Transition through IDLE to ensure FS_AUTOCAL calibration occurs + this->enter_idle_(); if (!this->enter_rx_()) { ESP_LOGW(TAG, "Failed to enter RX state!"); } From e8b45e53fd4020ecc65500e9df8c0c7145b4f1fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 13:02:25 -0700 Subject: [PATCH 34/42] [libretiny] Use -Os optimization for ESPHome source on BK72xx (SDK remains at -O1) (#14322) --- esphome/components/libretiny/__init__.py | 10 +++++++++- esphome/components/libretiny/lt_component.cpp | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 2291114d9a..8f99124604 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -27,6 +27,7 @@ from esphome.storage_json import StorageJSON from . import gpio # noqa from .const import ( + COMPONENT_BK72XX, CONF_GPIO_RECOVER, CONF_LOGLEVEL, CONF_SDK_SILENT, @@ -453,7 +454,14 @@ async def component_to_code(config): cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "soft") # include in every file - cg.add_platformio_option("build_src_flags", "-include Arduino.h") + build_src_flags = "-include Arduino.h" + if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_BK72XX: + # LibreTiny forces -O1 globally for BK72xx because the Beken SDK + # has issues with higher optimization levels. However, ESPHome code + # works fine with -Os (used on every other platform), so override + # it for project source files only. GCC uses the last -O flag. + build_src_flags += " -Os" + cg.add_platformio_option("build_src_flags", build_src_flags) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index ffccd0ad7a..834245c147 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -15,6 +15,9 @@ void LTComponent::dump_config() { " Version: %s\n" " Loglevel: %u", LT_BANNER_STR + 10, LT_LOGLEVEL); +#if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3 + ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); +#endif #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { From 08035261b85617b6b978336c8ca56629a86bb5cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 13:02:36 -0700 Subject: [PATCH 35/42] [libretiny] Use C++17 nested namespace syntax (#14325) --- esphome/components/libretiny/core.h | 4 +--- esphome/components/libretiny/gpio_arduino.cpp | 7 ++++--- esphome/components/libretiny/gpio_arduino.h | 6 ++---- esphome/components/libretiny/lt_component.cpp | 6 ++---- esphome/components/libretiny/lt_component.h | 6 ++---- esphome/components/libretiny/preferences.cpp | 7 ++++--- esphome/components/libretiny/preferences.h | 6 ++---- 7 files changed, 17 insertions(+), 25 deletions(-) diff --git a/esphome/components/libretiny/core.h b/esphome/components/libretiny/core.h index 9458df1f16..f909db4f0f 100644 --- a/esphome/components/libretiny/core.h +++ b/esphome/components/libretiny/core.h @@ -4,8 +4,6 @@ #include -namespace esphome { -namespace libretiny {} // namespace libretiny -} // namespace esphome +namespace esphome::libretiny {} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 0b14c77cf2..1af0dce16d 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -3,8 +3,7 @@ #include "gpio_arduino.h" #include "esphome/core/log.h" -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { static const char *const TAG = "lt.gpio"; @@ -77,7 +76,9 @@ void ArduinoInternalGPIOPin::detach_interrupt() const { detachInterrupt(pin_); // NOLINT } -} // namespace libretiny +} // namespace esphome::libretiny + +namespace esphome { using namespace libretiny; diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 30c7c33869..5f1fa3fec7 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -3,8 +3,7 @@ #ifdef USE_LIBRETINY #include "esphome/core/hal.h" -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { class ArduinoInternalGPIOPin : public InternalGPIOPin { public: @@ -31,7 +30,6 @@ class ArduinoInternalGPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace libretiny -} // namespace esphome +} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 834245c147..c01661b3a6 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { static const char *const TAG = "lt.component"; @@ -28,7 +27,6 @@ void LTComponent::dump_config() { float LTComponent::get_setup_priority() const { return setup_priority::LATE; } -} // namespace libretiny -} // namespace esphome +} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/lt_component.h b/esphome/components/libretiny/lt_component.h index 3d4483ab5d..896f1901e3 100644 --- a/esphome/components/libretiny/lt_component.h +++ b/esphome/components/libretiny/lt_component.h @@ -12,8 +12,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #endif -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { class LTComponent : public Component { public: @@ -30,7 +29,6 @@ class LTComponent : public Component { #endif // USE_TEXT_SENSOR }; -} // namespace libretiny -} // namespace esphome +} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 8549631e46..740c1a233a 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { static const char *const TAG = "lt.preferences"; @@ -194,7 +193,9 @@ void setup_preferences() { global_preferences = &s_preferences; } -} // namespace libretiny +} // namespace esphome::libretiny + +namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index 8ec3cd31b1..68f377bd3e 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -2,12 +2,10 @@ #ifdef USE_LIBRETINY -namespace esphome { -namespace libretiny { +namespace esphome::libretiny { void setup_preferences(); -} // namespace libretiny -} // namespace esphome +} // namespace esphome::libretiny #endif // USE_LIBRETINY From 54edc46c7f95ee35ec3c681276aa6a789fdeafdc Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 26 Feb 2026 21:12:52 +0100 Subject: [PATCH 36/42] [esp_ldo] Add channels 1&2 support and passthrough mode (#14177) --- esphome/components/esp_ldo/__init__.py | 67 ++++++++++++++++--- esphome/components/esp_ldo/esp_ldo.cpp | 20 +++--- esphome/components/esp_ldo/esp_ldo.h | 4 +- .../components/esp_ldo/test.esp32-p4-idf.yaml | 9 ++- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index f136dd149b..5235a9411e 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -13,22 +13,63 @@ esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo") EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component) AdjustAction = esp_ldo_ns.class_("AdjustAction", Action) -CHANNELS = (3, 4) +CHANNELS = (1, 2, 3, 4) +CHANNELS_INTERNAL = (1, 2) CONF_ADJUSTABLE = "adjustable" +CONF_ALLOW_INTERNAL_CHANNEL = "allow_internal_channel" +CONF_PASSTHROUGH = "passthrough" adjusted_ids = set() + +def validate_ldo_voltage(value): + if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH: + return CONF_PASSTHROUGH + value = cv.voltage(value) + if 0.5 <= value <= 2.7: + return value + raise cv.Invalid( + f"LDO voltage must be in range 0.5V-2.7V or 'passthrough' (bypass mode), got {value}V" + ) + + +def validate_ldo_config(config): + channel = config[CONF_CHANNEL] + allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL] + if allow_internal and channel not in CHANNELS_INTERNAL: + raise cv.Invalid( + f"'{CONF_ALLOW_INTERNAL_CHANNEL}' is only valid for internal channels (1, 2). " + f"Channel {channel} is a user-configurable channel — its usage depends on your board schematic.", + path=[CONF_ALLOW_INTERNAL_CHANNEL], + ) + if channel in CHANNELS_INTERNAL and not allow_internal: + raise cv.Invalid( + f"LDO channel {channel} is normally used internally by the chip (flash/PSRAM). " + f"Set '{CONF_ALLOW_INTERNAL_CHANNEL}: true' to confirm you know what you are doing.", + path=[CONF_CHANNEL], + ) + if config[CONF_VOLTAGE] == CONF_PASSTHROUGH and config[CONF_ADJUSTABLE]: + raise cv.Invalid( + "Passthrough mode passes the supply voltage directly to the output and does not support " + "runtime voltage adjustment.", + path=[CONF_ADJUSTABLE], + ) + return config + + CONFIG_SCHEMA = cv.All( cv.ensure_list( - cv.COMPONENT_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(EspLdo), - cv.Required(CONF_VOLTAGE): cv.All( - cv.voltage, cv.float_range(min=0.5, max=2.7) - ), - cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True), - cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean, - } + cv.All( + cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(EspLdo), + cv.Required(CONF_VOLTAGE): validate_ldo_voltage, + cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True), + cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean, + cv.Optional(CONF_ALLOW_INTERNAL_CHANNEL, default=False): cv.boolean, + } + ), + validate_ldo_config, ) ), cv.only_on_esp32, @@ -40,7 +81,11 @@ async def to_code(configs): for config in configs: var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL]) await cg.register_component(var, config) - cg.add(var.set_voltage(config[CONF_VOLTAGE])) + voltage = config[CONF_VOLTAGE] + if voltage == CONF_PASSTHROUGH: + cg.add(var.set_voltage(3300)) + else: + cg.add(var.set_voltage(int(round(voltage * 1000)))) cg.add(var.set_adjustable(config[CONF_ADJUSTABLE])) diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index 2eee855b46..f8ebec1903 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -10,32 +10,34 @@ static const char *const TAG = "esp_ldo"; void EspLdo::setup() { esp_ldo_channel_config_t config{}; config.chan_id = this->channel_; - config.voltage_mv = (int) (this->voltage_ * 1000.0f); + config.voltage_mv = this->voltage_mv_; config.flags.adjustable = this->adjustable_; auto err = esp_ldo_acquire_channel(&config, &this->handle_); if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); + ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_); this->mark_failed(LOG_STR("Failed to acquire LDO channel")); } else { - ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_); + ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_); } } void EspLdo::dump_config() { ESP_LOGCONFIG(TAG, "ESP LDO Channel %d:\n" - " Voltage: %fV\n" + " Voltage: %dmV\n" " Adjustable: %s", - this->channel_, this->voltage_, YESNO(this->adjustable_)); + this->channel_, this->voltage_mv_, YESNO(this->adjustable_)); } void EspLdo::adjust_voltage(float voltage) { if (!std::isfinite(voltage) || voltage < 0.5f || voltage > 2.7f) { - ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d", voltage, this->channel_); + ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d (must be 0.5V-2.7V)", voltage, this->channel_); return; } - auto erro = esp_ldo_channel_adjust_voltage(this->handle_, (int) (voltage * 1000.0f)); - if (erro != ESP_OK) { - ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %fV: %s", this->channel_, voltage, esp_err_to_name(erro)); + int voltage_mv = (int) roundf(voltage * 1000.0f); + auto err = esp_ldo_channel_adjust_voltage(this->handle_, voltage_mv); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %dmV: %s", this->channel_, voltage_mv, + esp_err_to_name(err)); } } diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index 9edd303e16..1a20f1d08a 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -15,7 +15,7 @@ class EspLdo : public Component { void dump_config() override; void set_adjustable(bool adjustable) { this->adjustable_ = adjustable; } - void set_voltage(float voltage) { this->voltage_ = voltage; } + void set_voltage(int voltage_mv) { this->voltage_mv_ = voltage_mv; } void adjust_voltage(float voltage); float get_setup_priority() const override { return setup_priority::BUS; // LDO setup should be done early @@ -23,7 +23,7 @@ class EspLdo : public Component { protected: int channel_; - float voltage_{2.7}; + int voltage_mv_{2700}; bool adjustable_{false}; esp_ldo_channel_handle_t handle_{}; }; diff --git a/tests/components/esp_ldo/test.esp32-p4-idf.yaml b/tests/components/esp_ldo/test.esp32-p4-idf.yaml index 38bd6ac411..31d2b8cf7a 100644 --- a/tests/components/esp_ldo/test.esp32-p4-idf.yaml +++ b/tests/components/esp_ldo/test.esp32-p4-idf.yaml @@ -3,10 +3,13 @@ esp_ldo: channel: 3 voltage: 2.5V adjustable: true - - id: ldo_4 + - id: ldo_4_passthrough channel: 4 - voltage: 2.0V - setup_priority: 900 + voltage: passthrough + - id: ldo_1_internal + channel: 1 + voltage: 1.8V + allow_internal_channel: true esphome: on_boot: From 8bd474fd017d31f4f8504f4c96db5a8e1a7f2f80 Mon Sep 17 00:00:00 2001 From: lyubomirtraykov Date: Thu, 26 Feb 2026 22:27:18 +0200 Subject: [PATCH 37/42] [api] Add DEFROSTING to ClimateAction (#13976) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/climate/climate_mode.cpp | 6 ++++-- esphome/components/climate/climate_mode.h | 2 ++ esphome/components/mqtt/mqtt_climate.cpp | 5 +++-- 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 18dac6a2d1..d7f32cd8d1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -989,6 +989,7 @@ enum ClimateAction { CLIMATE_ACTION_IDLE = 4; CLIMATE_ACTION_DRYING = 5; CLIMATE_ACTION_FAN = 6; + CLIMATE_ACTION_DEFROSTING = 7; } enum ClimatePreset { CLIMATE_PRESET_NONE = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c2675cefe4..22dc3de995 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -116,6 +116,7 @@ enum ClimateAction : uint32_t { CLIMATE_ACTION_IDLE = 4, CLIMATE_ACTION_DRYING = 5, CLIMATE_ACTION_FAN = 6, + CLIMATE_ACTION_DEFROSTING = 7, }; enum ClimatePreset : uint32_t { CLIMATE_PRESET_NONE = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 73690610ed..52d2486410 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -321,6 +321,8 @@ template<> const char *proto_enum_to_string(enums::Climate return "CLIMATE_ACTION_DRYING"; case enums::CLIMATE_ACTION_FAN: return "CLIMATE_ACTION_FAN"; + case enums::CLIMATE_ACTION_DEFROSTING: + return "CLIMATE_ACTION_DEFROSTING"; default: return "UNKNOWN"; } diff --git a/esphome/components/climate/climate_mode.cpp b/esphome/components/climate/climate_mode.cpp index c4dd19d503..8e443f4146 100644 --- a/esphome/components/climate/climate_mode.cpp +++ b/esphome/components/climate/climate_mode.cpp @@ -10,8 +10,10 @@ const LogString *climate_mode_to_string(ClimateMode mode) { return ClimateModeStrings::get_log_str(static_cast(mode), ClimateModeStrings::LAST_INDEX); } -// Climate action strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN -PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", "UNKNOWN"); +// Climate action strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN, +// DEFROSTING +PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", + "DEFROSTING", "UNKNOWN"); const LogString *climate_action_to_string(ClimateAction action) { return ClimateActionStrings::get_log_str(static_cast(action), ClimateActionStrings::LAST_INDEX); diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index c961c44248..014b1a9e64 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -41,6 +41,8 @@ enum ClimateAction : uint8_t { CLIMATE_ACTION_DRYING = 5, /// The climate device is in fan only mode CLIMATE_ACTION_FAN = 6, + /// The climate device is defrosting + CLIMATE_ACTION_DEFROSTING = 7, }; /// NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 81b2e0e8db..443c983efe 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -20,9 +20,10 @@ static ProgmemStr climate_mode_to_mqtt_str(ClimateMode mode) { return ClimateMqttModeStrings::get_progmem_str(static_cast(mode), ClimateMqttModeStrings::LAST_INDEX); } -// Climate action MQTT strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN +// Climate action MQTT strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN, +// DEFROSTING PROGMEM_STRING_TABLE(ClimateMqttActionStrings, "off", "unknown", "cooling", "heating", "idle", "drying", "fan", - "unknown"); + "defrosting", "unknown"); static ProgmemStr climate_action_to_mqtt_str(ClimateAction action) { return ClimateMqttActionStrings::get_progmem_str(static_cast(action), ClimateMqttActionStrings::LAST_INDEX); From 67ba68a1a09adb4cf3cb8178d625645a59a33c29 Mon Sep 17 00:00:00 2001 From: esphomebot Date: Fri, 27 Feb 2026 11:21:40 +1300 Subject: [PATCH 38/42] Update webserver local assets to 20260226-220330 (#14330) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/captive_portal/captive_index.h | 4 ++-- esphome/components/web_server/server_index_v2.h | 4 ++-- esphome/components/web_server/server_index_v3.h | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index 645ebb7a2f..a81edc1900 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -6,7 +6,7 @@ namespace esphome::captive_portal { #ifdef USE_CAPTIVE_PORTAL_GZIP -const uint8_t INDEX_GZ[] PROGMEM = { +constexpr uint8_t INDEX_GZ[] PROGMEM = { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, @@ -86,7 +86,7 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; #else // Brotli (default, smaller) -const uint8_t INDEX_BR[] PROGMEM = { +constexpr uint8_t INDEX_BR[] PROGMEM = { 0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b, 0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48, 0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78, diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ffa9c87b3a..ac2195f387 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -9,7 +9,7 @@ namespace esphome::web_server { #ifdef USE_WEBSERVER_GZIP -const uint8_t INDEX_GZ[] PROGMEM = { +constexpr uint8_t INDEX_GZ[] PROGMEM = { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xd9, 0x72, 0xdb, 0x48, 0xb6, 0xe0, 0xf3, 0xd4, 0x57, 0x40, 0x28, 0xb5, 0x8c, 0x2c, 0x26, 0xc1, 0x45, 0x92, 0x2d, 0x83, 0x4a, 0xb2, 0x65, 0xd9, 0xd5, 0x76, 0x97, 0xb7, 0xb6, 0xec, 0xda, 0x58, 0x6c, 0x09, 0x02, 0x92, 0x44, 0x96, 0x41, 0x80, 0x05, 0x24, 0xb5, 0x14, 0x89, @@ -698,7 +698,7 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0x01, 0x65, 0x21, 0x07, 0x4b, 0xe3, 0x97, 0x00, 0x00}; #else // Brotli (default, smaller) -const uint8_t INDEX_BR[] PROGMEM = { +constexpr uint8_t INDEX_BR[] PROGMEM = { 0x1b, 0xe2, 0x97, 0xa3, 0x90, 0xa2, 0x95, 0x55, 0x51, 0x04, 0x1b, 0x07, 0x80, 0x20, 0x79, 0x0e, 0x50, 0xab, 0x02, 0xdb, 0x98, 0x16, 0xf4, 0x7b, 0x22, 0xa3, 0x4d, 0xd3, 0x86, 0xc1, 0x26, 0x48, 0x49, 0x60, 0xbe, 0xb3, 0xc9, 0xa1, 0x8c, 0x96, 0x10, 0x1b, 0x21, 0xcf, 0x48, 0x68, 0xce, 0x10, 0x34, 0x32, 0x7c, 0xbf, 0x71, 0x7b, 0x03, 0x8f, 0xdd, diff --git a/esphome/components/web_server/server_index_v3.h b/esphome/components/web_server/server_index_v3.h index b7c15df32b..a1cafe8707 100644 --- a/esphome/components/web_server/server_index_v3.h +++ b/esphome/components/web_server/server_index_v3.h @@ -9,7 +9,7 @@ namespace esphome::web_server { #ifdef USE_WEBSERVER_GZIP -const uint8_t INDEX_GZ[] PROGMEM = { +constexpr uint8_t INDEX_GZ[] PROGMEM = { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xcc, 0xbd, 0x7b, 0x7f, 0x1a, 0xb9, 0xb2, 0x28, 0xfa, 0xf7, 0x3d, 0x9f, 0xc2, 0xee, 0x9d, 0xf1, 0xb4, 0x8c, 0x68, 0x03, 0x36, 0x8e, 0xd3, 0x58, 0xe6, 0xe4, 0x39, 0xc9, 0x3c, 0x92, 0x4c, 0x9c, 0x64, 0x26, 0xc3, 0xb0, 0x33, 0xa2, 0x11, 0xa0, 0xa4, 0x91, 0x98, 0x96, 0x88, 0xed, 0x01, @@ -4107,7 +4107,7 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0xe8, 0xcd, 0xfe, 0x2c, 0x9d, 0x07, 0xfd, 0xff, 0x05, 0x64, 0x23, 0xa6, 0xdb, 0x06, 0x7b, 0x03, 0x00}; #else // Brotli (default, smaller) -const uint8_t INDEX_BR[] PROGMEM = { +constexpr uint8_t INDEX_BR[] PROGMEM = { 0x5b, 0x05, 0x7b, 0x53, 0xc1, 0xb6, 0x69, 0x3d, 0x41, 0xeb, 0x04, 0x30, 0xf6, 0xd6, 0x77, 0x35, 0xdb, 0xa3, 0x08, 0x36, 0x0e, 0x04, 0x80, 0x90, 0x4f, 0xf1, 0xb2, 0x21, 0xa4, 0x82, 0xee, 0x00, 0xaa, 0x20, 0x7f, 0x3b, 0xff, 0x00, 0xaa, 0x9a, 0x73, 0x74, 0x8c, 0xe1, 0xa6, 0x1f, 0xa0, 0xa2, 0x59, 0xf5, 0xaa, 0x92, 0x79, 0x50, 0x43, 0x1f, 0xe8, From 527d4964f61b77439c469d41207527c5677b813b Mon Sep 17 00:00:00 2001 From: George Joseph Date: Thu, 26 Feb 2026 17:38:07 -0700 Subject: [PATCH 39/42] [mipi_dsi] Add more Waveshare panels and comments (#14023) --- .../components/mipi_dsi/models/waveshare.py | 139 +++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index bf4f9063bb..69414065f1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -2,7 +2,11 @@ from esphome.components.mipi import DriverChip import esphome.config_validation as cv # fmt: off -DriverChip( + +# Source for parameters and initsequence: +# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 +# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage +JD9365_10_1_DSI_TOUCH_A = DriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -52,6 +56,15 @@ DriverChip( ], ) +# Standalone display +# Product page: https://www.waveshare.com/wiki/10.1-DSI-TOUCH-A +JD9365_10_1_DSI_TOUCH_A.extend( + "WAVESHARE-10.1-DSI-TOUCH-A", +) + +# Source for parameters and initsequence: +# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 +# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO DriverChip( "WAVESHARE-P4-86-PANEL", height=720, @@ -95,6 +108,9 @@ DriverChip( ], ) +# Source for parameters and initsequence: +# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 +# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B DriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, @@ -121,7 +137,10 @@ DriverChip( ], ) -DriverChip( +# Source for parameters and initsequence: +# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 +# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C +JD9365_3_4_DSI_TOUCH_C = DriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -170,7 +189,16 @@ DriverChip( ], ) -DriverChip( +# Standalone display +# Product page: https://www.waveshare.com/wiki/3.4-DSI-TOUCH-C +JD9365_3_4_DSI_TOUCH_C.extend( + "WAVESHARE-3.4-DSI-TOUCH-C", +) + +# Source for parameters and initsequence: +# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 +# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C +JD9365_4_DSI_TOUCH_C = DriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -218,3 +246,108 @@ DriverChip( (0xE0, 0x00), # select userpage ] ) + +# Standalone display +# Product page: https://www.waveshare.com/wiki/4-DSI-TOUCH-C +JD9365_4_DSI_TOUCH_C.extend( + "WAVESHARE-4-DSI-TOUCH-C", +) + +# Source for parameters and initsequence: +# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 +# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A +DriverChip( + "WAVESHARE-8-DSI-TOUCH-A", + height=1280, + width=800, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=12, + vsync_pulse_width=4, + vsync_front_porch=30, + pclk_frequency="80MHz", + lane_bit_rate="1.5Gbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + initsequence=[ + (0xE0, 0x00), # select userpage + (0xE1, 0x93), (0xE2, 0x65), (0xE3, 0xF8), + (0x80, 0x01), # Select number of lanes (2) + (0xE0, 0x01), # select page 1 + (0x00, 0x00), (0x01, 0x4E), (0x03, 0x00), (0x04, 0x65), (0x0C, 0x74), (0x17, 0x00), (0x18, 0xB7), (0x19, 0x00), + (0x1A, 0x00), (0x1B, 0xB7), (0x1C, 0x00), (0x24, 0xFE), (0x37, 0x19), (0x38, 0x05), (0x39, 0x00), (0x3A, 0x01), + (0x3B, 0x01), (0x3C, 0x70), (0x3D, 0xFF), (0x3E, 0xFF), (0x3F, 0xFF), (0x40, 0x06), (0x41, 0xA0), (0x43, 0x1E), + (0x44, 0x0F), (0x45, 0x28), (0x4B, 0x04), (0x55, 0x02), (0x56, 0x01), (0x57, 0xA9), (0x58, 0x0A), (0x59, 0x0A), + (0x5A, 0x37), (0x5B, 0x19), (0x5D, 0x78), (0x5E, 0x63), (0x5F, 0x54), (0x60, 0x49), (0x61, 0x45), (0x62, 0x38), + (0x63, 0x3D), (0x64, 0x28), (0x65, 0x43), (0x66, 0x41), (0x67, 0x43), (0x68, 0x62), (0x69, 0x50), (0x6A, 0x57), + (0x6B, 0x49), (0x6C, 0x44), (0x6D, 0x37), (0x6E, 0x23), (0x6F, 0x10), (0x70, 0x78), (0x71, 0x63), (0x72, 0x54), + (0x73, 0x49), (0x74, 0x45), (0x75, 0x38), (0x76, 0x3D), (0x77, 0x28), (0x78, 0x43), (0x79, 0x41), (0x7A, 0x43), + (0x7B, 0x62), (0x7C, 0x50), (0x7D, 0x57), (0x7E, 0x49), (0x7F, 0x44), (0x80, 0x37), (0x81, 0x23), (0x82, 0x10), + (0xE0, 0x02), # select page 2 + (0x00, 0x47), (0x01, 0x47), (0x02, 0x45), (0x03, 0x45), (0x04, 0x4B), (0x05, 0x4B), (0x06, 0x49), (0x07, 0x49), + (0x08, 0x41), (0x09, 0x1F), (0x0A, 0x1F), (0x0B, 0x1F), (0x0C, 0x1F), (0x0D, 0x1F), (0x0E, 0x1F), (0x0F, 0x5F), + (0x10, 0x5F), (0x11, 0x57), (0x12, 0x77), (0x13, 0x35), (0x14, 0x1F), (0x15, 0x1F), (0x16, 0x46), (0x17, 0x46), + (0x18, 0x44), (0x19, 0x44), (0x1A, 0x4A), (0x1B, 0x4A), (0x1C, 0x48), (0x1D, 0x48), (0x1E, 0x40), (0x1F, 0x1F), + (0x20, 0x1F), (0x21, 0x1F), (0x22, 0x1F), (0x23, 0x1F), (0x24, 0x1F), (0x25, 0x5F), (0x26, 0x5F), (0x27, 0x57), + (0x28, 0x77), (0x29, 0x35), (0x2A, 0x1F), (0x2B, 0x1F), (0x58, 0x40), (0x59, 0x00), (0x5A, 0x00), (0x5B, 0x10), + (0x5C, 0x06), (0x5D, 0x40), (0x5E, 0x01), (0x5F, 0x02), (0x60, 0x30), (0x61, 0x01), (0x62, 0x02), (0x63, 0x03), + (0x64, 0x6B), (0x65, 0x05), (0x66, 0x0C), (0x67, 0x73), (0x68, 0x09), (0x69, 0x03), (0x6A, 0x56), (0x6B, 0x08), + (0x6C, 0x00), (0x6D, 0x04), (0x6E, 0x04), (0x6F, 0x88), (0x70, 0x00), (0x71, 0x00), (0x72, 0x06), (0x73, 0x7B), + (0x74, 0x00), (0x75, 0xF8), (0x76, 0x00), (0x77, 0xD5), (0x78, 0x2E), (0x79, 0x12), (0x7A, 0x03), (0x7B, 0x00), + (0x7C, 0x00), (0x7D, 0x03), (0x7E, 0x7B), + (0xE0, 0x04), # select page 4 + (0x00, 0x0E), (0x02, 0xB3), (0x09, 0x60), (0x0E, 0x2A), (0x36, 0x59), (0x37, 0x58), (0x2B, 0x0F), + (0xE0, 0x00), # select userpage + ] +) + +# Source for parameters and initsequence: +# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c +# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A +DriverChip( + "WAVESHARE-7-DSI-TOUCH-A", + height=1280, + width=720, + hsync_back_porch=239, + hsync_pulse_width=50, + hsync_front_porch=33, + vsync_back_porch=20, + vsync_pulse_width=30, + vsync_front_porch=2, + pclk_frequency="80MHz", + lane_bit_rate="1000Mbps", + no_transform=True, + color_order="RGB", + initsequence=[ + (0xFF, 0x98, 0x81, 0x03), + (0x01, 0x00), (0x02, 0x00), (0x03, 0x73), (0x04, 0x00), (0x05, 0x00), (0x06, 0x0A), (0x07, 0x00), (0x08, 0x00), + (0x09, 0x61), (0x0A, 0x00), (0x0B, 0x00), (0x0C, 0x01), (0x0D, 0x00), (0x0E, 0x00), (0x0F, 0x61), (0x10, 0x61), + (0x11, 0x00), (0x12, 0x00), (0x13, 0x00), (0x14, 0x00), (0x15, 0x00), (0x16, 0x00), (0x17, 0x00), (0x18, 0x00), + (0x19, 0x00), (0x1A, 0x00), (0x1B, 0x00), (0x1C, 0x00), (0x1D, 0x00), (0x1E, 0x40), (0x1F, 0x80), (0x20, 0x06), + (0x21, 0x01), (0x22, 0x00), (0x23, 0x00), (0x24, 0x00), (0x25, 0x00), (0x26, 0x00), (0x27, 0x00), (0x28, 0x33), + (0x29, 0x03), (0x2A, 0x00), (0x2B, 0x00), (0x2C, 0x00), (0x2D, 0x00), (0x2E, 0x00), (0x2F, 0x00), (0x30, 0x00), + (0x31, 0x00), (0x32, 0x00), (0x33, 0x00), (0x34, 0x04), (0x35, 0x00), (0x36, 0x00), (0x37, 0x00), (0x38, 0x3C), + (0x39, 0x00), (0x3A, 0x00), (0x3B, 0x00), (0x3C, 0x00), (0x3D, 0x00), (0x3E, 0x00), (0x3F, 0x00), (0x40, 0x00), + (0x41, 0x00), (0x42, 0x00), (0x43, 0x00), (0x44, 0x00), (0x50, 0x10), (0x51, 0x32), (0x52, 0x54), (0x53, 0x76), + (0x54, 0x98), (0x55, 0xBA), (0x56, 0x10), (0x57, 0x32), (0x58, 0x54), (0x59, 0x76), (0x5A, 0x98), (0x5B, 0xBA), + (0x5C, 0xDC), (0x5D, 0xFE), (0x5E, 0x00), (0x5F, 0x0E), (0x60, 0x0F), (0x61, 0x0C), (0x62, 0x0D), (0x63, 0x06), + (0x64, 0x07), (0x65, 0x02), (0x66, 0x02), (0x67, 0x02), (0x68, 0x02), (0x69, 0x01), (0x6A, 0x00), (0x6B, 0x02), + (0x6C, 0x15), (0x6D, 0x14), (0x6E, 0x02), (0x6F, 0x02), (0x70, 0x02), (0x71, 0x02), (0x72, 0x02), (0x73, 0x02), + (0x74, 0x02), (0x75, 0x0E), (0x76, 0x0F), (0x77, 0x0C), (0x78, 0x0D), (0x79, 0x06), (0x7A, 0x07), (0x7B, 0x02), + (0x7C, 0x02), (0x7D, 0x02), (0x7E, 0x02), (0x7F, 0x01), (0x80, 0x00), (0x81, 0x02), (0x82, 0x14), (0x83, 0x15), + (0x84, 0x02), (0x85, 0x02), (0x86, 0x02), (0x87, 0x02), (0x88, 0x02), (0x89, 0x02), (0x8A, 0x02), + (0xFF, 0x98, 0x81, 0x04), + (0x38, 0x01), (0x39, 0x00), (0x6C, 0x15), (0x6E, 0x2A), (0x6F, 0x33), (0x3A, 0x94), (0x8D, 0x14), (0x87, 0xBA), + (0x26, 0x76), (0xB2, 0xD1), (0xB5, 0x06), (0x3B, 0x98), + (0xFF, 0x98, 0x81, 0x01), + (0x22, 0x0A), (0x31, 0x00), (0x53, 0x71), (0x55, 0x8F), (0x40, 0x33), (0x50, 0x96), (0x51, 0x96), (0x60, 0x23), + (0xA0, 0x08), (0xA1, 0x1D), (0xA2, 0x2A), (0xA3, 0x10), (0xA4, 0x15), (0xA5, 0x28), (0xA6, 0x1C), (0xA7, 0x1D), + (0xA8, 0x7E), (0xA9, 0x1D), (0xAA, 0x29), (0xAB, 0x6B), (0xAC, 0x1A), (0xAD, 0x18), (0xAE, 0x4B), (0xAF, 0x20), + (0xB0, 0x27), (0xB1, 0x50), (0xB2, 0x64), (0xB3, 0x39), (0xC0, 0x08), (0xC1, 0x1D), (0xC2, 0x2A), (0xC3, 0x10), + (0xC4, 0x15), (0xC5, 0x28), (0xC6, 0x1C), (0xC7, 0x1D), (0xC8, 0x7E), (0xC9, 0x1D), (0xCA, 0x29), (0xCB, 0x6B), + (0xCC, 0x1A), (0xCD, 0x18), (0xCE, 0x4B), (0xCF, 0x20), (0xD0, 0x27), (0xD1, 0x50), (0xD2, 0x64), (0xD3, 0x39), + (0xFF, 0x98, 0x81, 0x00), + (0x3A, 0x77), (0x36, 0x00), (0x35, 0x00), (0x35, 0x00), + ], +) From 1ccfcfc8d85593f81e96ea40cd4e8a162392a678 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 18:12:44 -0700 Subject: [PATCH 40/42] [time] Eliminate libc timezone bloat (~9.5KB flash ESP32, ~2% RAM on ESP8266) (#13635) Co-authored-by: Claude Opus 4.6 --- esphome/components/time/posix_tz.cpp | 488 +++++++ esphome/components/time/posix_tz.h | 144 +++ esphome/components/time/real_time_clock.cpp | 61 +- esphome/components/time/real_time_clock.h | 35 +- esphome/core/time.cpp | 232 +++- esphome/core/time.h | 18 +- script/cpp_unit_test.py | 1 + tests/components/time/posix_tz_parser.cpp | 1275 +++++++++++++++++++ 8 files changed, 2163 insertions(+), 91 deletions(-) create mode 100644 esphome/components/time/posix_tz.cpp create mode 100644 esphome/components/time/posix_tz.h create mode 100644 tests/components/time/posix_tz_parser.cpp diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp new file mode 100644 index 0000000000..4d1f0c74c2 --- /dev/null +++ b/esphome/components/time/posix_tz.cpp @@ -0,0 +1,488 @@ +#include "esphome/core/defines.h" + +#ifdef USE_TIME_TIMEZONE + +#include "posix_tz.h" +#include + +namespace esphome::time { + +// Global timezone - set once at startup, rarely changes +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +static ParsedTimezone global_tz_{}; + +void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; } + +const ParsedTimezone &get_global_tz() { return global_tz_; } + +namespace internal { + +// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule, +// and parse_transition_time are only used by parse_posix_tz() (bridge code). +static uint32_t parse_uint(const char *&p) { + uint32_t value = 0; + while (std::isdigit(static_cast(*p))) { + value = value * 10 + (*p - '0'); + p++; + } + return value; +} + +bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } + +// Get days in year (avoids duplicate is_leap_year calls) +static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; } + +// Convert days since epoch to year, updating days to remainder +static int __attribute__((noinline)) days_to_year(int64_t &days) { + int year = 1970; + int diy; + while (days >= (diy = days_in_year(year)) && year < 2200) { + days -= diy; + year++; + } + while (days < 0 && year > 1900) { + year--; + days += days_in_year(year); + } + return year; +} + +// Extract just the year from a UTC epoch +static int epoch_to_year(time_t epoch) { + int64_t days = epoch / 86400; + if (epoch < 0 && epoch % 86400 != 0) + days--; + return days_to_year(days); +} + +int days_in_month(int year, int month) { + switch (month) { + case 2: + return is_leap_year(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } +} + +// Zeller-like algorithm for day of week (0 = Sunday) +int __attribute__((noinline)) day_of_week(int year, int month, int day) { + // Adjust for January/February + if (month < 3) { + month += 12; + year--; + } + int k = year % 100; + int j = year / 100; + int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7; + // Convert from Zeller (0=Sat) to standard (0=Sun) + return ((h + 6) % 7); +} + +void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { + // Days since epoch + int64_t days = epoch / 86400; + int32_t remaining_secs = epoch % 86400; + if (remaining_secs < 0) { + days--; + remaining_secs += 86400; + } + + out_tm->tm_sec = remaining_secs % 60; + remaining_secs /= 60; + out_tm->tm_min = remaining_secs % 60; + out_tm->tm_hour = remaining_secs / 60; + + // Day of week (Jan 1, 1970 was Thursday = 4) + out_tm->tm_wday = static_cast((days + 4) % 7); + if (out_tm->tm_wday < 0) + out_tm->tm_wday += 7; + + // Calculate year (updates days to day-of-year) + int year = days_to_year(days); + out_tm->tm_year = year - 1900; + out_tm->tm_yday = static_cast(days); + + // Calculate month and day + int month = 1; + int dim; + while (days >= (dim = days_in_month(year, month))) { + days -= dim; + month++; + } + + out_tm->tm_mon = month - 1; + out_tm->tm_mday = static_cast(days) + 1; + out_tm->tm_isdst = 0; +} + +bool skip_tz_name(const char *&p) { + if (*p == '<') { + // Angle-bracket quoted name: <+07>, <-03>, + p++; // skip '<' + while (*p && *p != '>') { + p++; + } + if (*p == '>') { + p++; // skip '>' + return true; + } + return false; // Unterminated + } + + // Standard name: 3+ letters + const char *start = p; + while (*p && std::isalpha(static_cast(*p))) { + p++; + } + return (p - start) >= 3; +} + +int32_t __attribute__((noinline)) parse_offset(const char *&p) { + int sign = 1; + if (*p == '-') { + sign = -1; + p++; + } else if (*p == '+') { + p++; + } + + int hours = parse_uint(p); + int minutes = 0; + int seconds = 0; + + if (*p == ':') { + p++; + minutes = parse_uint(p); + if (*p == ':') { + p++; + seconds = parse_uint(p); + } + } + + return sign * (hours * 3600 + minutes * 60 + seconds); +} + +// Helper to parse the optional /time suffix (reuses parse_offset logic) +static void parse_transition_time(const char *&p, DSTRule &rule) { + rule.time_seconds = 2 * 3600; // Default 02:00 + if (*p == '/') { + p++; + rule.time_seconds = parse_offset(p); + } +} + +void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) { + // J format: day 1-365, Feb 29 is NOT counted even in leap years + // So day 60 is always March 1 + // Iterate forward through months (no array needed) + int remaining = julian_day; + out_month = 1; + while (out_month <= 12) { + // Days in month for non-leap year (J format ignores leap years) + int dim = days_in_month(2001, out_month); // 2001 is non-leap year + if (remaining <= dim) { + out_day = remaining; + return; + } + remaining -= dim; + out_month++; + } + out_day = remaining; +} + +void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) { + // Plain format: day 0-365, Feb 29 IS counted in leap years + // Day 0 = Jan 1 + int remaining = day_of_year; + out_month = 1; + + while (out_month <= 12) { + int days_this_month = days_in_month(year, out_month); + if (remaining < days_this_month) { + out_day = remaining + 1; + return; + } + remaining -= days_this_month; + out_month++; + } + + // Shouldn't reach here with valid input + out_month = 12; + out_day = 31; +} + +bool parse_dst_rule(const char *&p, DSTRule &rule) { + rule = {}; // Zero initialize + + if (*p == 'M' || *p == 'm') { + // M format: Mm.w.d (month.week.day) + rule.type = DSTRuleType::MONTH_WEEK_DAY; + p++; + + rule.month = parse_uint(p); + if (rule.month < 1 || rule.month > 12) + return false; + + if (*p++ != '.') + return false; + + rule.week = parse_uint(p); + if (rule.week < 1 || rule.week > 5) + return false; + + if (*p++ != '.') + return false; + + rule.day_of_week = parse_uint(p); + if (rule.day_of_week > 6) + return false; + + } else if (*p == 'J' || *p == 'j') { + // J format: Jn (Julian day 1-365, not counting Feb 29) + rule.type = DSTRuleType::JULIAN_NO_LEAP; + p++; + + rule.day = parse_uint(p); + if (rule.day < 1 || rule.day > 365) + return false; + + } else if (std::isdigit(static_cast(*p))) { + // Plain number format: n (day 0-365, counting Feb 29) + rule.type = DSTRuleType::DAY_OF_YEAR; + + rule.day = parse_uint(p); + if (rule.day > 365) + return false; + + } else { + return false; + } + + // Parse optional /time suffix + parse_transition_time(p, rule); + + return true; +} + +// Calculate days from Jan 1 of given year to given month/day +static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) { + int days = day - 1; + for (int m = 1; m < month; m++) { + days += days_in_month(year, m); + } + return days; +} + +// Calculate days from epoch to Jan 1 of given year (for DST transition calculations) +// Only supports years >= 1970. Timezone is either compiled in from YAML or set by +// Home Assistant, so pre-1970 dates are not a concern. +static int64_t __attribute__((noinline)) days_to_year_start(int year) { + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += days_in_year(y); + } + return days; +} + +time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { + int month, day; + + switch (rule.type) { + case DSTRuleType::MONTH_WEEK_DAY: { + // Find the nth occurrence of day_of_week in the given month + int first_dow = day_of_week(year, rule.month, 1); + + // Days until first occurrence of target day + int days_until_first = (rule.day_of_week - first_dow + 7) % 7; + int first_occurrence = 1 + days_until_first; + + if (rule.week == 5) { + // "Last" occurrence - find the last one in the month + int dim = days_in_month(year, rule.month); + day = first_occurrence; + while (day + 7 <= dim) { + day += 7; + } + } else { + // nth occurrence + day = first_occurrence + (rule.week - 1) * 7; + } + month = rule.month; + break; + } + + case DSTRuleType::JULIAN_NO_LEAP: + // J format: day 1-365, Feb 29 not counted + julian_to_month_day(rule.day, month, day); + break; + + case DSTRuleType::DAY_OF_YEAR: + // Plain format: day 0-365, Feb 29 counted + day_of_year_to_month_day(rule.day, year, month, day); + break; + + case DSTRuleType::NONE: + // Should never be called with NONE, but handle it gracefully + month = 1; + day = 1; + break; + } + + // Calculate days from epoch to this date + int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day); + + // Convert to epoch and add transition time and base offset + return days * 86400 + rule.time_seconds + base_offset_seconds; +} + +} // namespace internal + +bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { + if (!tz.has_dst()) { + return false; + } + + int year = internal::epoch_to_year(utc_epoch); + + // Calculate DST start and end for this year + // DST start transition happens in standard time + time_t dst_start = internal::calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds); + // DST end transition happens in daylight time + time_t dst_end = internal::calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds); + + if (dst_start < dst_end) { + // Northern hemisphere: DST is between start and end + return (utc_epoch >= dst_start && utc_epoch < dst_end); + } else { + // Southern hemisphere: DST is outside the range (wraps around year) + return (utc_epoch >= dst_start || utc_epoch < dst_end); + } +} + +// Remove before 2026.9.0: This parser is bridge code for backward compatibility with +// older Home Assistant clients that send the timezone as a POSIX TZ string instead of +// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct +// directly, this function and the parsing helpers above (skip_tz_name, parse_offset, +// parse_dst_rule, parse_transition_time) can be removed. +// See https://github.com/esphome/backlog/issues/91 +bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { + if (!tz_string || !*tz_string) { + return false; + } + + const char *p = tz_string; + + // Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false) + result.std_offset_seconds = 0; + result.dst_offset_seconds = 0; + result.dst_start = {}; + result.dst_end = {}; + + // Skip standard timezone name + if (!internal::skip_tz_name(p)) { + return false; + } + + // Parse standard offset (required) + if (!*p || (!std::isdigit(static_cast(*p)) && *p != '+' && *p != '-')) { + return false; + } + result.std_offset_seconds = internal::parse_offset(p); + + // Check for DST name + if (!*p) { + return true; // No DST + } + + // If next char is comma, there's no DST name but there are rules (invalid) + if (*p == ',') { + return false; + } + + // Check if there's something that looks like a DST name start + // (letter or angle bracket). If not, treat as trailing garbage and return success. + if (!std::isalpha(static_cast(*p)) && *p != '<') { + return true; // No DST, trailing characters ignored + } + + if (!internal::skip_tz_name(p)) { + return false; // Invalid DST name (started but malformed) + } + + // Optional DST offset (default is std - 1 hour) + if (*p && *p != ',' && (std::isdigit(static_cast(*p)) || *p == '+' || *p == '-')) { + result.dst_offset_seconds = internal::parse_offset(p); + } else { + result.dst_offset_seconds = result.std_offset_seconds - 3600; + } + + // Parse DST rules (required when DST name is present) + if (*p != ',') { + // DST name without rules - treat as no DST since we can't determine transitions + return true; + } + + p++; + if (!internal::parse_dst_rule(p, result.dst_start)) { + return false; + } + + // Second rule is required per POSIX + if (*p != ',') { + return false; + } + p++; + // has_dst() now returns true since dst_start.type was set by parse_dst_rule + return internal::parse_dst_rule(p, result.dst_end); +} + +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { + if (!out_tm) { + return false; + } + + // Determine DST status once (avoids duplicate is_in_dst calculation) + bool in_dst = is_in_dst(utc_epoch, tz); + int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds; + + // Apply offset (POSIX offset is positive west, so subtract to get local) + time_t local_epoch = utc_epoch - offset; + + internal::epoch_to_tm_utc(local_epoch, out_tm); + out_tm->tm_isdst = in_dst ? 1 : 0; + + return true; +} + +} // namespace esphome::time + +#ifndef USE_HOST +// Override libc's localtime functions to use our timezone on embedded platforms. +// This allows user lambdas calling ::localtime() to get correct local time +// without needing the TZ environment variable (which pulls in scanf bloat). +// On host, we use the normal TZ mechanism since there's no memory constraint. + +// Thread-safe version +extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) { + if (timer == nullptr || result == nullptr) { + return nullptr; + } + esphome::time::epoch_to_local_tm(*timer, esphome::time::get_global_tz(), result); + return result; +} + +// Non-thread-safe version (uses static buffer, standard libc behavior) +extern "C" struct tm *localtime(const time_t *timer) { + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static struct tm localtime_buf; + return localtime_r(timer, &localtime_buf); +} +#endif // !USE_HOST + +#endif // USE_TIME_TIMEZONE diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h new file mode 100644 index 0000000000..c71ba15cd1 --- /dev/null +++ b/esphome/components/time/posix_tz.h @@ -0,0 +1,144 @@ +#pragma once + +#ifdef USE_TIME_TIMEZONE + +#include +#include + +namespace esphome::time { + +/// Type of DST transition rule +enum class DSTRuleType : uint8_t { + NONE = 0, ///< No DST rule (used to indicate no DST) + MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March) + JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted) + DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years) +}; + +/// Rule for DST transition (packed for 32-bit: 12 bytes) +struct DSTRule { + int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM) + uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) + DSTRuleType type; ///< Type of rule + uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY) + uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY) + uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) +}; + +/// Parsed POSIX timezone information (packed for 32-bit: 32 bytes) +struct ParsedTimezone { + int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west) + int32_t dst_offset_seconds; ///< DST offset from UTC in seconds + DSTRule dst_start; ///< When DST starts + DSTRule dst_end; ///< When DST ends + + /// Check if this timezone has DST rules + bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } +}; + +/// Parse a POSIX TZ string into a ParsedTimezone struct. +/// +/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). +/// This parser only exists so that older Home Assistant clients that send the timezone +/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still +/// set the timezone on the device. Once all clients are updated to send the struct +/// directly, this function and all internal parsing helpers will be removed. +/// See https://github.com/esphome/backlog/issues/91 +/// +/// Supports formats like: +/// - "EST5" (simple offset, no DST) +/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules) +/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times) +/// - "<+07>-7" (angle-bracket notation for special names) +/// - "IST-5:30" (half-hour offsets) +/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day) +/// - "EST5EDT,60,300" (plain day number: day of year with leap day) +/// @param tz_string The POSIX TZ string to parse +/// @param result Output: the parsed timezone data +/// @return true if parsing succeeded, false on error +bool parse_posix_tz(const char *tz_string, ParsedTimezone &result); + +/// Convert a UTC epoch to local time using the parsed timezone. +/// This replaces libc's localtime() to avoid scanf dependency. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @param[out] out_tm Output tm struct with local time +/// @return true on success +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm); + +/// Set the global timezone used by epoch_to_local_tm() when called without a timezone. +/// This is called by RealTimeClock::apply_timezone_() to enable ESPTime::from_epoch_local() +/// to work without libc's localtime(). +void set_global_tz(const ParsedTimezone &tz); + +/// Get the global timezone. +const ParsedTimezone &get_global_tz(); + +/// Check if a given UTC epoch falls within DST for the parsed timezone. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @return true if DST is in effect at the given time +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); + +// Internal helper functions exposed for testing. +// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only +// used by parse_posix_tz() which is bridge code for backward compatibility. +// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.) +// are used by the conversion functions and will stay. + +namespace internal { + +/// Skip a timezone name (letters or <...> quoted format) +/// @param p Pointer to current position, updated on return +/// @return true if a valid name was found +bool skip_tz_name(const char *&p); + +/// Parse an offset in format [-]hh[:mm[:ss]] +/// @param p Pointer to current position, updated on return +/// @return Offset in seconds +int32_t parse_offset(const char *&p); + +/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time] +/// @param p Pointer to current position, updated on return +/// @param rule Output: the parsed rule +/// @return true if parsing succeeded +bool parse_dst_rule(const char *&p, DSTRule &rule); + +/// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day +/// @param julian_day Day number 1-365 +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void julian_to_month_day(int julian_day, int &month, int &day); + +/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day +/// @param day_of_year Day number 0-365 +/// @param year The year (for leap year calculation) +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day); + +/// Calculate day of week for any date (0 = Sunday) +/// Uses a simplified algorithm that works for years 1970-2099 +int day_of_week(int year, int month, int day); + +/// Get the number of days in a month +int days_in_month(int year, int month); + +/// Check if a year is a leap year +bool is_leap_year(int year); + +/// Convert epoch to year/month/day/hour/min/sec (UTC) +void epoch_to_tm_utc(time_t epoch, struct tm *out_tm); + +/// Calculate the epoch timestamp for a DST transition in a given year. +/// @param year The year (e.g., 2026) +/// @param rule The DST rule (month, week, day_of_week, time) +/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context) +/// @return Unix epoch timestamp of the transition +time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds); + +} // namespace internal + +} // namespace esphome::time + +#endif // USE_TIME_TIMEZONE diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 8a78186178..2e758ad8e7 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -14,8 +14,8 @@ #include #endif #include - #include +#include namespace esphome::time { @@ -23,9 +23,33 @@ static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; +ESPTime __attribute__((noinline)) RealTimeClock::now() { +#ifdef USE_TIME_TIMEZONE + time_t epoch = this->timestamp_now(); + struct tm local_tm; + if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) { + return ESPTime::from_c_tm(&local_tm, epoch); + } + // Fallback to UTC if parsing failed + return ESPTime::from_epoch_utc(epoch); +#else + return ESPTime::from_epoch_local(this->timestamp_now()); +#endif +} + void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE - ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str()); + const auto &tz = get_global_tz(); + // POSIX offset is positive west, negate for conventional UTC+X display + int std_h = -tz.std_offset_seconds / 3600; + int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60; + if (tz.has_dst()) { + int dst_h = -tz.dst_offset_seconds / 3600; + int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60; + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m); + } else { + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_h, std_m); + } #endif auto time = this->now(); ESP_LOGCONFIG(TAG, "Current time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour, @@ -72,11 +96,6 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { ret = settimeofday(&timev, nullptr); } -#ifdef USE_TIME_TIMEZONE - // Move timezone back to local timezone. - this->apply_timezone_(); -#endif - if (ret != 0) { ESP_LOGW(TAG, "setimeofday() failed with code %d", ret); } @@ -89,9 +108,33 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { } #ifdef USE_TIME_TIMEZONE -void RealTimeClock::apply_timezone_() { - setenv("TZ", this->timezone_.c_str(), 1); +void RealTimeClock::apply_timezone_(const char *tz) { + ParsedTimezone parsed{}; + + // Handle null or empty input - use UTC + if (tz == nullptr || *tz == '\0') { + // Skip if already UTC + if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) { + return; + } + set_global_tz(parsed); + return; + } + +#ifdef USE_HOST + // On host platform, also set TZ environment variable for libc compatibility + setenv("TZ", tz, 1); tzset(); +#endif + + // Parse the POSIX TZ string using our custom parser + if (!parse_posix_tz(tz, parsed)) { + ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); + return; + } + + // Set global timezone for all time conversions + set_global_tz(parsed); } #endif diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 19aa1a4f4a..f9de5f5614 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -6,6 +6,9 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/time.h" +#ifdef USE_TIME_TIMEZONE +#include "posix_tz.h" +#endif namespace esphome::time { @@ -20,26 +23,31 @@ class RealTimeClock : public PollingComponent { explicit RealTimeClock(); #ifdef USE_TIME_TIMEZONE - /// Set the time zone. - void set_timezone(const std::string &tz) { - this->timezone_ = tz; - this->apply_timezone_(); - } + /// Set the time zone from a POSIX TZ string. + void set_timezone(const char *tz) { this->apply_timezone_(tz); } - /// Set the time zone from raw buffer, only if it differs from the current one. + /// Set the time zone from a character buffer with known length. + /// The buffer does not need to be null-terminated. void set_timezone(const char *tz, size_t len) { - if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) { - this->timezone_.assign(tz, len); - this->apply_timezone_(); + if (tz == nullptr) { + this->apply_timezone_(nullptr); + return; } + // Stack buffer - TZ strings from tzdata are typically short (< 50 chars) + char buf[128]; + if (len >= sizeof(buf)) + len = sizeof(buf) - 1; + memcpy(buf, tz, len); + buf[len] = '\0'; + this->apply_timezone_(buf); } - /// Get the time zone currently in use. - std::string get_timezone() { return this->timezone_; } + /// Set the time zone from a std::string. + void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); } #endif /// Get the time in the currently defined timezone. - ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); } + ESPTime now(); /// Get the time without any time zone or DST corrections. ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } @@ -58,8 +66,7 @@ class RealTimeClock : public PollingComponent { void synchronize_epoch_(uint32_t epoch); #ifdef USE_TIME_TIMEZONE - std::string timezone_{}; - void apply_timezone_(); + void apply_timezone_(const char *tz); #endif LazyCallbackManager time_sync_callback_; diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 1aea18ac8d..73ba0a9be7 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -2,7 +2,6 @@ #include "helpers.h" #include -#include namespace esphome { @@ -67,58 +66,123 @@ std::string ESPTime::strftime(const char *format) { std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } -bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { - uint16_t year; - uint8_t month; - uint8_t day; - uint8_t hour; - uint8_t minute; - uint8_t second; - int num; - const int ilen = static_cast(len); - - if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, // NOLINT - &second, &num) == 6 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = second; - } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, &num) == 5 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = 0; - } else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT - num == ilen) { - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = second; - } else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT - num == ilen) { - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = 0; - } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - } else { - return false; +// Helper to parse exactly N digits, returns false if not enough digits +static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) { + value = 0; + for (int i = 0; i < count; i++) { + if (p >= end || *p < '0' || *p > '9') + return false; + value = value * 10 + (*p - '0'); + p++; } return true; } +// Helper to check for expected character +static bool expect_char(const char *&p, const char *end, char expected) { + if (p >= end || *p != expected) + return false; + p++; + return true; +} + +bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { + // Supported formats: + // YYYY-MM-DD HH:MM:SS (19 chars) + // YYYY-MM-DD HH:MM (16 chars) + // YYYY-MM-DD (10 chars) + // HH:MM:SS (8 chars) + // HH:MM (5 chars) + + if (time_to_parse == nullptr || len == 0) + return false; + + const char *p = time_to_parse; + const char *end = time_to_parse + len; + uint16_t v1, v2, v3, v4, v5, v6; + + // Try date formats first (start with 4-digit year) + if (len >= 10 && time_to_parse[4] == '-') { + // YYYY-MM-DD... + if (!parse_digits(p, end, 4, v1)) + return false; + if (!expect_char(p, end, '-')) + return false; + if (!parse_digits(p, end, 2, v2)) + return false; + if (!expect_char(p, end, '-')) + return false; + if (!parse_digits(p, end, 2, v3)) + return false; + + esp_time.year = v1; + esp_time.month = v2; + esp_time.day_of_month = v3; + + if (p == end) { + // YYYY-MM-DD (date only) + return true; + } + + if (!expect_char(p, end, ' ')) + return false; + + // Continue with time part: HH:MM[:SS] + if (!parse_digits(p, end, 2, v4)) + return false; + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v5)) + return false; + + esp_time.hour = v4; + esp_time.minute = v5; + + if (p == end) { + // YYYY-MM-DD HH:MM + esp_time.second = 0; + return true; + } + + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v6)) + return false; + + esp_time.second = v6; + return p == end; // YYYY-MM-DD HH:MM:SS + } + + // Try time-only formats (HH:MM[:SS]) + if (len >= 5 && time_to_parse[2] == ':') { + if (!parse_digits(p, end, 2, v1)) + return false; + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v2)) + return false; + + esp_time.hour = v1; + esp_time.minute = v2; + + if (p == end) { + // HH:MM + esp_time.second = 0; + return true; + } + + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v3)) + return false; + + esp_time.second = v3; + return p == end; // HH:MM:SS + } + + return false; +} + void ESPTime::increment_second() { this->timestamp++; if (!increment_time_value(this->second, 0, 60)) @@ -193,27 +257,67 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) { } void ESPTime::recalc_timestamp_local() { - struct tm tm; +#ifdef USE_TIME_TIMEZONE + // Calculate timestamp as if fields were UTC + this->recalc_timestamp_utc(false); + if (this->timestamp == -1) { + return; // Invalid time + } - tm.tm_year = this->year - 1900; - tm.tm_mon = this->month - 1; - tm.tm_mday = this->day_of_month; - tm.tm_hour = this->hour; - tm.tm_min = this->minute; - tm.tm_sec = this->second; - tm.tm_isdst = -1; + // Now convert from local to UTC by adding the offset + // POSIX: local = utc - offset, so utc = local + offset + const auto &tz = time::get_global_tz(); - this->timestamp = mktime(&tm); + if (!tz.has_dst()) { + // No DST - just apply standard offset + this->timestamp += tz.std_offset_seconds; + return; + } + + // Try both interpretations to match libc mktime() with tm_isdst=-1 + // For ambiguous times (fall-back repeated hour), prefer standard time + // For invalid times (spring-forward skipped hour), libc normalizes forward + time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds; + time_t utc_if_std = this->timestamp + tz.std_offset_seconds; + + bool dst_valid = time::is_in_dst(utc_if_dst, tz); + bool std_valid = !time::is_in_dst(utc_if_std, tz); + + if (dst_valid && std_valid) { + // Ambiguous time (repeated hour during fall-back) - prefer standard time + this->timestamp = utc_if_std; + } else if (dst_valid) { + // Only DST interpretation is valid + this->timestamp = utc_if_dst; + } else if (std_valid) { + // Only standard interpretation is valid + this->timestamp = utc_if_std; + } else { + // Invalid time (skipped hour during spring-forward) + // libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT + // Using std offset achieves this since the UTC result falls during DST + this->timestamp = utc_if_std; + } +#else + // No timezone support - treat as UTC + this->recalc_timestamp_utc(false); +#endif } int32_t ESPTime::timezone_offset() { +#ifdef USE_TIME_TIMEZONE time_t now = ::time(nullptr); - struct tm local_tm = *::localtime(&now); - local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset. - time_t local_time = mktime(&local_tm); - struct tm utc_tm = *::gmtime(&now); - time_t utc_time = mktime(&utc_tm); - return static_cast(local_time - utc_time); + const auto &tz = time::get_global_tz(); + // POSIX offset is positive west, but we return offset to add to UTC to get local + // So we negate the POSIX offset + if (time::is_in_dst(now, tz)) { + return -tz.dst_offset_seconds; + } + return -tz.std_offset_seconds; +#else + // No timezone support - no offset + return 0; +#endif } bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; } diff --git a/esphome/core/time.h b/esphome/core/time.h index d9ce86131c..874f0db4b4 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -7,6 +7,10 @@ #include #include +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif + namespace esphome { template bool increment_time_value(T ¤t, uint16_t begin, uint16_t end); @@ -105,11 +109,17 @@ struct ESPTime { * @return The generated ESPTime */ static ESPTime from_epoch_local(time_t epoch) { - struct tm *c_tm = ::localtime(&epoch); - if (c_tm == nullptr) { - return ESPTime{}; // Return an invalid ESPTime +#ifdef USE_TIME_TIMEZONE + struct tm local_tm; + if (time::epoch_to_local_tm(epoch, time::get_global_tz(), &local_tm)) { + return ESPTime::from_c_tm(&local_tm, epoch); } - return ESPTime::from_c_tm(c_tm, epoch); + // Fallback to UTC if conversion failed + return ESPTime::from_epoch_utc(epoch); +#else + // No timezone support - return UTC (no TZ configured, localtime would return UTC anyway) + return ESPTime::from_epoch_utc(epoch); +#endif } /** Convert an UTC epoch timestamp to a UTC time ESPTime instance. * diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index e97b5bd7b0..78b65092ae 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -66,6 +66,7 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: ], "build_flags": [ "-Og", # optimize for debug + "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing ], "debug_build_flags": [ # only for debug builds "-g3", # max debug info diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp new file mode 100644 index 0000000000..d1747ef5b1 --- /dev/null +++ b/tests/components/time/posix_tz_parser.cpp @@ -0,0 +1,1275 @@ +// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime. +// +// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is +// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0. +// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely +// used to handle timezone strings from Home Assistant clients older than 2026.3.0 +// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct. +// See https://github.com/esphome/backlog/issues/91 +// +// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion +// functions that will remain permanently. + +// Enable USE_TIME_TIMEZONE for tests +#define USE_TIME_TIMEZONE + +#include +#include +#include +#include "esphome/components/time/posix_tz.h" +#include "esphome/core/time.h" + +namespace esphome::time::testing { + +// Helper to create UTC epoch from date/time components (for test readability) +static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0, int sec = 0) { + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365; + } + static const int DAYS_BEFORE[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + days += DAYS_BEFORE[month - 1]; + if (month > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) + days++; // Leap year adjustment + days += day - 1; + return days * 86400 + hour * 3600 + min * 60 + sec; +} + +// ============================================================================ +// Basic TZ string parsing tests +// ============================================================================ + +TEST(PosixTzParser, ParseSimpleOffsetEST5) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); // +5 hours (west of UTC) + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseNegativeOffsetCET) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); // -1 hour (east of UTC) + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseExplicitPositiveOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("TEST+5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseZeroOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 0); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseUSEasternWithDST) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 2); + EXPECT_EQ(tz.dst_start.day_of_week, 0); // Sunday + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // Default 2:00 AM + EXPECT_EQ(tz.dst_end.month, 11); + EXPECT_EQ(tz.dst_end.week, 1); + EXPECT_EQ(tz.dst_end.day_of_week, 0); +} + +TEST(PosixTzParser, ParseUSCentralWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); + EXPECT_EQ(tz.std_offset_seconds, 6 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // 2:00 AM + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParseEuropeBerlin) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -2 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 5); // Last week + EXPECT_EQ(tz.dst_end.month, 10); + EXPECT_EQ(tz.dst_end.week, 5); // Last week + EXPECT_EQ(tz.dst_end.time_seconds, 3 * 3600); // 3:00 AM +} + +TEST(PosixTzParser, ParseNewZealand) { + ParsedTimezone tz; + // Southern hemisphere - DST starts in Sept, ends in April + ASSERT_TRUE(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -12 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -13 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.month, 9); // September + EXPECT_EQ(tz.dst_end.month, 4); // April +} + +TEST(PosixTzParser, ParseExplicitDstOffset) { + ParsedTimezone tz; + // Some places have non-standard DST offsets + ASSERT_TRUE(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); + EXPECT_TRUE(tz.has_dst()); +} + +// ============================================================================ +// Angle-bracket notation tests (espressif/newlib-esp32#8) +// ============================================================================ + +TEST(PosixTzParser, ParseAngleBracketPositive) { + // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+07>-7", tz)); + EXPECT_EQ(tz.std_offset_seconds, -7 * 3600); // -7 = 7 hours east of UTC + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseAngleBracketNegative) { + // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<-03>3", tz)); + EXPECT_EQ(tz.std_offset_seconds, 3 * 3600); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseAngleBracketWithDST) { + // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -11 * 3600); + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.month, 10); + EXPECT_EQ(tz.dst_end.month, 4); +} + +TEST(PosixTzParser, ParseAngleBracketNamed) { + // -10 (Australian Eastern Standard Time) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("-10", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseAngleBracketWithMinutes) { + // <+0545>-5:45 (Nepal) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+0545>-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst()); +} + +// ============================================================================ +// Half-hour and unusual offset tests +// ============================================================================ + +TEST(PosixTzParser, ParseOffsetWithMinutesIndia) { + ParsedTimezone tz; + // India: UTC+5:30 + ASSERT_TRUE(parse_posix_tz("IST-5:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 30 * 60)); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseOffsetWithMinutesNepal) { + ParsedTimezone tz; + // Nepal: UTC+5:45 + ASSERT_TRUE(parse_posix_tz("NPT-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, ParseOffsetWithSeconds) { + ParsedTimezone tz; + // Unusual but valid: offset with seconds + ASSERT_TRUE(parse_posix_tz("TEST-1:30:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(1 * 3600 + 30 * 60 + 30)); +} + +TEST(PosixTzParser, ParseChathamIslands) { + // Chatham Islands: UTC+12:45 with DST + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(12 * 3600 + 45 * 60)); + EXPECT_EQ(tz.dst_offset_seconds, -(13 * 3600 + 45 * 60)); + EXPECT_TRUE(tz.has_dst()); +} + +// ============================================================================ +// Invalid input tests +// ============================================================================ + +TEST(PosixTzParser, ParseEmptyStringFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("", tz)); +} + +TEST(PosixTzParser, ParseNullFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz(nullptr, tz)); +} + +TEST(PosixTzParser, ParseShortNameFails) { + ParsedTimezone tz; + // TZ name must be at least 3 characters + EXPECT_FALSE(parse_posix_tz("AB5", tz)); +} + +TEST(PosixTzParser, ParseMissingOffsetFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("EST", tz)); +} + +TEST(PosixTzParser, ParseUnterminatedBracketFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("<+07-7", tz)); // Missing closing > +} + +// ============================================================================ +// J-format and plain day number tests +// ============================================================================ + +TEST(PosixTzParser, ParseJFormatBasic) { + ParsedTimezone tz; + // J format: Julian day 1-365, not counting Feb 29 + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60,J305", tz)); + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_start.day, 60); // March 1 + EXPECT_EQ(tz.dst_end.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_end.day, 305); // November 1 +} + +TEST(PosixTzParser, ParseJFormatWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); + EXPECT_EQ(tz.dst_start.day, 60); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); + EXPECT_EQ(tz.dst_end.day, 305); + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParsePlainDayNumber) { + ParsedTimezone tz; + // Plain format: day 0-365, counting Feb 29 in leap years + ASSERT_TRUE(parse_posix_tz("EST5EDT,59,304", tz)); + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_end.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_end.day, 304); +} + +TEST(PosixTzParser, JFormatInvalidDayZero) { + ParsedTimezone tz; + // J format day must be 1-365, not 0 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J0,J305", tz)); +} + +TEST(PosixTzParser, JFormatInvalidDay366) { + ParsedTimezone tz; + // J format day must be 1-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J366,J305", tz)); +} + +TEST(PosixTzParser, ParsePlainDayNumberWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_start.time_seconds, 3 * 3600); + EXPECT_EQ(tz.dst_end.day, 304); + EXPECT_EQ(tz.dst_end.time_seconds, 1 * 3600 + 30 * 60); +} + +TEST(PosixTzParser, PlainDayInvalidDay366) { + ParsedTimezone tz; + // Plain format day must be 0-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,366,304", tz)); +} + +// ============================================================================ +// Transition time edge cases (POSIX V3 allows -167 to +167 hours) +// ============================================================================ + +TEST(PosixTzParser, NegativeTransitionTime) { + ParsedTimezone tz; + // Negative transition time: /-1 means 11 PM (23:00) the previous day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1,M11.1.0/2", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, -1 * 3600); // -1 hour = 11 PM previous day + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, NegativeTransitionTimeWithMinutes) { + ParsedTimezone tz; + // /-1:30 means 10:30 PM the previous day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1:30,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, -(1 * 3600 + 30 * 60)); +} + +TEST(PosixTzParser, LargeTransitionTime) { + ParsedTimezone tz; + // POSIX V3 allows transition times from -167 to +167 hours + // /25 means 1:00 AM the next day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/25,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 25 * 3600); +} + +TEST(PosixTzParser, MaxTransitionTime167Hours) { + ParsedTimezone tz; + // Maximum allowed transition time per POSIX V3 + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/167,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 167 * 3600); +} + +TEST(PosixTzParser, TransitionTimeWithHoursMinutesSeconds) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2:30:45,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600 + 30 * 60 + 45); +} + +// ============================================================================ +// Invalid M format tests +// ============================================================================ + +TEST(PosixTzParser, MFormatInvalidMonth13) { + ParsedTimezone tz; + // Month must be 1-12 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M13.1.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidMonth0) { + ParsedTimezone tz; + // Month must be 1-12 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M0.1.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidWeek6) { + ParsedTimezone tz; + // Week must be 1-5 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.6.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidWeek0) { + ParsedTimezone tz; + // Week must be 1-5 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.0.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidDayOfWeek7) { + ParsedTimezone tz; + // Day of week must be 0-6 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.7,M11.1.0", tz)); +} + +TEST(PosixTzParser, MissingEndRule) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.0", tz)); +} + +TEST(PosixTzParser, MissingEndRuleJFormat) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,J60", tz)); +} + +TEST(PosixTzParser, MissingEndRulePlainDay) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,60", tz)); +} + +TEST(PosixTzParser, LowercaseMFormat) { + ParsedTimezone tz; + // Lowercase 'm' should be accepted + ASSERT_TRUE(parse_posix_tz("EST5EDT,m3.2.0,m11.1.0", tz)); + EXPECT_TRUE(tz.has_dst()); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_end.month, 11); +} + +TEST(PosixTzParser, LowercaseJFormat) { + ParsedTimezone tz; + // Lowercase 'j' should be accepted + ASSERT_TRUE(parse_posix_tz("EST5EDT,j60,j305", tz)); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_start.day, 60); +} + +TEST(PosixTzParser, DstNameWithoutRules) { + ParsedTimezone tz; + // DST name present but no rules - treat as no DST since we can't determine transitions + ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); + EXPECT_FALSE(tz.has_dst()); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); +} + +TEST(PosixTzParser, TrailingCharactersIgnored) { + ParsedTimezone tz; + // Trailing characters after valid TZ should be ignored (parser stops at end of valid input) + // This matches libc behavior + ASSERT_TRUE(parse_posix_tz("EST5 extra garbage here", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst()); +} + +TEST(PosixTzParser, PlainDay365LeapYear) { + // Day 365 in leap year is Dec 31 + int month, day; + internal::day_of_year_to_month_day(365, 2024, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, PlainDay364NonLeapYear) { + // Day 364 (0-indexed) is Dec 31 in non-leap year (last valid day) + int month, day; + internal::day_of_year_to_month_day(364, 2025, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +// ============================================================================ +// Large offset tests +// ============================================================================ + +TEST(PosixTzParser, MaxOffset14Hours) { + ParsedTimezone tz; + // Line Islands (Kiribati) is UTC+14, the maximum offset + ASSERT_TRUE(parse_posix_tz("<+14>-14", tz)); + EXPECT_EQ(tz.std_offset_seconds, -14 * 3600); +} + +TEST(PosixTzParser, MaxNegativeOffset12Hours) { + ParsedTimezone tz; + // Baker Island is UTC-12 + ASSERT_TRUE(parse_posix_tz("<-12>12", tz)); + EXPECT_EQ(tz.std_offset_seconds, 12 * 3600); +} + +// ============================================================================ +// Helper function tests +// ============================================================================ + +TEST(PosixTzParser, JulianDay60IsMarch1) { + // J60 is always March 1 (J format ignores leap years by design) + int month, day; + internal::julian_to_month_day(60, month, day); + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DayOfYear59DiffersByLeap) { + int month, day; + // Day 59 in leap year is Feb 29 + internal::day_of_year_to_month_day(59, 2024, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 29); + // Day 59 in non-leap year is March 1 + internal::day_of_year_to_month_day(59, 2025, month, day); + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DayOfWeekKnownDates) { + // January 1, 1970 was Thursday (4) + EXPECT_EQ(internal::day_of_week(1970, 1, 1), 4); + // January 1, 2000 was Saturday (6) + EXPECT_EQ(internal::day_of_week(2000, 1, 1), 6); + // March 8, 2026 is Sunday (0) - US DST start + EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0); +} + +TEST(PosixTzParser, LeapYearDetection) { + EXPECT_FALSE(internal::is_leap_year(1900)); // Divisible by 100 but not 400 + EXPECT_TRUE(internal::is_leap_year(2000)); // Divisible by 400 + EXPECT_TRUE(internal::is_leap_year(2024)); // Divisible by 4 + EXPECT_FALSE(internal::is_leap_year(2025)); // Not divisible by 4 +} + +TEST(PosixTzParser, JulianDay1IsJan1) { + int month, day; + internal::julian_to_month_day(1, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, JulianDay31IsJan31) { + int month, day; + internal::julian_to_month_day(31, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, JulianDay32IsFeb1) { + int month, day; + internal::julian_to_month_day(32, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, JulianDay59IsFeb28) { + int month, day; + internal::julian_to_month_day(59, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 28); +} + +TEST(PosixTzParser, JulianDay365IsDec31) { + int month, day; + internal::julian_to_month_day(365, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, DayOfYear0IsJan1) { + int month, day; + internal::day_of_year_to_month_day(0, 2025, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DaysInMonthRegular) { + // Test all 12 months to ensure switch coverage + EXPECT_EQ(internal::days_in_month(2025, 1), 31); // Jan - default case + EXPECT_EQ(internal::days_in_month(2025, 2), 28); // Feb - case 2 + EXPECT_EQ(internal::days_in_month(2025, 3), 31); // Mar - default case + EXPECT_EQ(internal::days_in_month(2025, 4), 30); // Apr - case 4 + EXPECT_EQ(internal::days_in_month(2025, 5), 31); // May - default case + EXPECT_EQ(internal::days_in_month(2025, 6), 30); // Jun - case 6 + EXPECT_EQ(internal::days_in_month(2025, 7), 31); // Jul - default case + EXPECT_EQ(internal::days_in_month(2025, 8), 31); // Aug - default case + EXPECT_EQ(internal::days_in_month(2025, 9), 30); // Sep - case 9 + EXPECT_EQ(internal::days_in_month(2025, 10), 31); // Oct - default case + EXPECT_EQ(internal::days_in_month(2025, 11), 30); // Nov - case 11 + EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case +} + +TEST(PosixTzParser, DaysInMonthLeapYear) { + EXPECT_EQ(internal::days_in_month(2024, 2), 29); + EXPECT_EQ(internal::days_in_month(2025, 2), 28); +} + +// ============================================================================ +// DST transition calculation tests +// ============================================================================ + +TEST(PosixTzParser, DstStartUSEastern2026) { + // March 8, 2026 is 2nd Sunday of March + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds); + struct tm tm; + internal::epoch_to_tm_utc(dst_start, &tm); + + // At 2:00 AM EST (UTC-5), so 7:00 AM UTC + EXPECT_EQ(tm.tm_year + 1900, 2026); + EXPECT_EQ(tm.tm_mon + 1, 3); // March + EXPECT_EQ(tm.tm_mday, 8); // 8th + EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST +} + +TEST(PosixTzParser, DstEndUSEastern2026) { + // November 1, 2026 is 1st Sunday of November + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds); + struct tm tm; + internal::epoch_to_tm_utc(dst_end, &tm); + + // At 2:00 AM EDT (UTC-4), so 6:00 AM UTC + EXPECT_EQ(tm.tm_year + 1900, 2026); + EXPECT_EQ(tm.tm_mon + 1, 11); // November + EXPECT_EQ(tm.tm_mday, 1); // 1st + EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT +} + +TEST(PosixTzParser, LastSundayOfMarch2026) { + // Europe: M3.5.0 = last Sunday of March = March 29, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600; + time_t transition = internal::calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 29); + EXPECT_EQ(tm.tm_wday, 0); // Sunday +} + +TEST(PosixTzParser, LastSundayOfOctober2026) { + // Europe: M10.5.0 = last Sunday of October = October 25, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 10; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 3 * 3600; + time_t transition = internal::calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 25); + EXPECT_EQ(tm.tm_wday, 0); // Sunday +} + +TEST(PosixTzParser, FirstSundayOfApril2026) { + // April 5, 2026 is 1st Sunday + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 4; + rule.week = 1; + rule.day_of_week = 0; + rule.time_seconds = 0; + time_t transition = internal::calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 5); + EXPECT_EQ(tm.tm_wday, 0); +} + +// ============================================================================ +// DST detection tests +// ============================================================================ + +TEST(PosixTzParser, IsInDstUSEasternSummer) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 12:00 UTC - definitely in DST + time_t summer = make_utc(2026, 7, 4, 12); + EXPECT_TRUE(is_in_dst(summer, tz)); +} + +TEST(PosixTzParser, IsInDstUSEasternWinter) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // January 15, 2026 12:00 UTC - definitely not in DST + time_t winter = make_utc(2026, 1, 15, 12); + EXPECT_FALSE(is_in_dst(winter, tz)); +} + +TEST(PosixTzParser, IsInDstNoDstTimezone) { + ParsedTimezone tz; + parse_posix_tz("IST-5:30", tz); + + // July 15, 2026 12:00 UTC + time_t epoch = make_utc(2026, 7, 15, 12); + EXPECT_FALSE(is_in_dst(epoch, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstSummer) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // December 15, 2025 12:00 UTC - summer in NZ, should be in DST + time_t nz_summer = make_utc(2025, 12, 15, 12); + EXPECT_TRUE(is_in_dst(nz_summer, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstWinter) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST + time_t nz_winter = make_utc(2026, 7, 15, 12); + EXPECT_FALSE(is_in_dst(nz_winter, tz)); +} + +// ============================================================================ +// epoch_to_local_tm tests +// ============================================================================ + +TEST(PosixTzParser, EpochToLocalBasic) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 70); + EXPECT_EQ(local.tm_mon, 0); + EXPECT_EQ(local.tm_mday, 1); + EXPECT_EQ(local.tm_hour, 0); +} + +TEST(PosixTzParser, EpochToLocalNegativeEpoch) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + // Dec 31, 1969 23:59:59 UTC (1 second before epoch) + time_t epoch = -1; + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 69); // 1969 + EXPECT_EQ(local.tm_mon, 11); // December + EXPECT_EQ(local.tm_mday, 31); + EXPECT_EQ(local.tm_hour, 23); + EXPECT_EQ(local.tm_min, 59); + EXPECT_EQ(local.tm_sec, 59); +} + +TEST(PosixTzParser, EpochToLocalNullTmFails) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr)); +} + +TEST(PosixTzParser, EpochToLocalWithOffset) { + ParsedTimezone tz; + parse_posix_tz("EST5", tz); // UTC-5 + + // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST + time_t utc_epoch = make_utc(2026, 1, 1, 5); + + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 0); // Midnight EST + EXPECT_EQ(local.tm_mday, 1); + EXPECT_EQ(local.tm_isdst, 0); +} + +TEST(PosixTzParser, EpochToLocalDstTransition) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 16:00 UTC = 12:00 EDT (noon) + time_t utc_epoch = make_utc(2026, 7, 4, 16); + + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 12); // Noon EDT + EXPECT_EQ(local.tm_isdst, 1); +} + +// ============================================================================ +// Verification against libc +// ============================================================================ + +class LibcVerificationTest : public ::testing::TestWithParam> { + protected: + // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name + void SetUp() override { + // Save current TZ + const char *current_tz = getenv("TZ"); + saved_tz_ = current_tz ? current_tz : ""; + had_tz_ = current_tz != nullptr; + } + + // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name + void TearDown() override { + // Restore TZ + if (had_tz_) { + setenv("TZ", saved_tz_.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); + } + + private: + std::string saved_tz_; + bool had_tz_{false}; +}; + +TEST_P(LibcVerificationTest, MatchesLibc) { + auto [tz_str, epoch] = GetParam(); + + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + + // Our implementation + struct tm our_tm {}; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &our_tm)); + + // libc implementation + setenv("TZ", tz_str, 1); + tzset(); + struct tm *libc_tm = localtime(&epoch); + ASSERT_NE(libc_tm, nullptr); + + EXPECT_EQ(our_tm.tm_year, libc_tm->tm_year); + EXPECT_EQ(our_tm.tm_mon, libc_tm->tm_mon); + EXPECT_EQ(our_tm.tm_mday, libc_tm->tm_mday); + EXPECT_EQ(our_tm.tm_hour, libc_tm->tm_hour); + EXPECT_EQ(our_tm.tm_min, libc_tm->tm_min); + EXPECT_EQ(our_tm.tm_sec, libc_tm->tm_sec); + EXPECT_EQ(our_tm.tm_isdst, libc_tm->tm_isdst); +} + +INSTANTIATE_TEST_SUITE_P(USEastern, LibcVerificationTest, + ::testing::Values(std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AngleBracket, LibcVerificationTest, + ::testing::Values(std::make_tuple("<+07>-7", 1704067200), + std::make_tuple("<+07>-7", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(India, LibcVerificationTest, + ::testing::Values(std::make_tuple("IST-5:30", 1704067200), + std::make_tuple("IST-5:30", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(NewZealand, LibcVerificationTest, + ::testing::Values(std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1704067200), + std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(USCentral, LibcVerificationTest, + ::testing::Values(std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(EuropeBerlin, LibcVerificationTest, + ::testing::Values(std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1704067200), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1720000000), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest, + ::testing::Values(std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1704067200), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); + +// ============================================================================ +// DST boundary edge cases +// ============================================================================ + +TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { + // Test 1 second before DST starts + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) + time_t before_epoch = make_utc(2026, 3, 8, 6, 59, 59); + EXPECT_FALSE(is_in_dst(before_epoch, tz)); + + // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) + time_t after_epoch = make_utc(2026, 3, 8, 7); + EXPECT_TRUE(is_in_dst(after_epoch, tz)); +} + +TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { + // Test 1 second before DST ends + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) + time_t before_epoch = make_utc(2026, 11, 1, 5, 59, 59); + EXPECT_TRUE(is_in_dst(before_epoch, tz)); + + // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) + time_t after_epoch = make_utc(2026, 11, 1, 6); + EXPECT_FALSE(is_in_dst(after_epoch, tz)); +} + +} // namespace esphome::time::testing + +// ============================================================================ +// ESPTime::strptime tests (replaces sscanf-based parsing) +// ============================================================================ + +namespace esphome::testing { + +TEST(ESPTimeStrptime, FullDateTime) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30:45", 19, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 45); +} + +TEST(ESPTimeStrptime, DateTimeNoSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30", 16, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, DateOnly) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15", 10, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); +} + +TEST(ESPTimeStrptime, TimeWithSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("14:30:45", 8, t)); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 45); +} + +TEST(ESPTimeStrptime, TimeNoSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("14:30", 5, t)); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, Midnight) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("00:00:00", 8, t)); + EXPECT_EQ(t.hour, 0); + EXPECT_EQ(t.minute, 0); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, EndOfDay) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("23:59:59", 8, t)); + EXPECT_EQ(t.hour, 23); + EXPECT_EQ(t.minute, 59); + EXPECT_EQ(t.second, 59); +} + +TEST(ESPTimeStrptime, LeapYearDate) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2024-02-29", 10, t)); + EXPECT_EQ(t.year, 2024); + EXPECT_EQ(t.month, 2); + EXPECT_EQ(t.day_of_month, 29); +} + +TEST(ESPTimeStrptime, NewYearsEve) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-12-31 23:59:59", 19, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 12); + EXPECT_EQ(t.day_of_month, 31); + EXPECT_EQ(t.hour, 23); + EXPECT_EQ(t.minute, 59); + EXPECT_EQ(t.second, 59); +} + +TEST(ESPTimeStrptime, EmptyStringFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("", 0, t)); +} + +TEST(ESPTimeStrptime, NullInputFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime(nullptr, 0, t)); +} + +TEST(ESPTimeStrptime, InvalidFormatFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("not-a-date", 10, t)); +} + +TEST(ESPTimeStrptime, PartialDateFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("2026-03", 7, t)); +} + +TEST(ESPTimeStrptime, PartialTimeFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("14:", 3, t)); +} + +TEST(ESPTimeStrptime, ExtraCharactersFails) { + ESPTime t{}; + // Full datetime with extra characters should fail + EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t)); +} + +TEST(ESPTimeStrptime, WrongSeparatorFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("2026/03/15", 10, t)); +} + +TEST(ESPTimeStrptime, LeadingZeroTime) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("01:05:09", 8, t)); + EXPECT_EQ(t.hour, 1); + EXPECT_EQ(t.minute, 5); + EXPECT_EQ(t.second, 9); +} + +// ============================================================================ +// recalc_timestamp_local() tests - verify behavior matches libc mktime() +// ============================================================================ + +// Helper to call libc mktime with same fields +static time_t libc_mktime(int year, int month, int day, int hour, int min, int sec) { + struct tm tm {}; + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + tm.tm_isdst = -1; // Let libc determine DST + return mktime(&tm); +} + +// Helper to create ESPTime and call recalc_timestamp_local +static time_t esptime_recalc_local(int year, int month, int day, int hour, int min, int sec) { + ESPTime t{}; + t.year = year; + t.month = month; + t.day_of_month = day; + t.hour = hour; + t.minute = min; + t.second = sec; + t.day_of_week = 1; // Placeholder for fields_in_range() + t.day_of_year = 1; + t.recalc_timestamp_local(); + return t.timestamp; +} + +TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) { + // Set timezone to US Central (CST6CDT) + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test a normal time in winter (no DST) + // January 15, 2026 at 10:30:00 CST + time_t libc_result = libc_mktime(2026, 1, 15, 10, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test a normal time in summer (DST active) + // July 15, 2026 at 10:30:00 CDT + libc_result = libc_mktime(2026, 7, 15, 10, 30, 0); + esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, SpringForwardSkippedHour) { + // Set timezone to US Central (CST6CDT) + // DST starts March 8, 2026 at 2:00 AM -> clocks jump to 3:00 AM + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test time before the transition (1:30 AM CST exists) + time_t libc_result = libc_mktime(2026, 3, 8, 1, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test time after the transition (3:30 AM CDT exists) + libc_result = libc_mktime(2026, 3, 8, 3, 30, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 3, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test the skipped hour (2:30 AM doesn't exist - gets normalized) + // Both implementations should produce the same result + libc_result = libc_mktime(2026, 3, 8, 2, 30, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, FallBackRepeatedHour) { + // Set timezone to US Central (CST6CDT) + // DST ends November 1, 2026 at 2:00 AM -> clocks fall back to 1:00 AM + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test time before the transition (midnight CDT) + time_t libc_result = libc_mktime(2026, 11, 1, 0, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 11, 1, 0, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test time well after the transition (3:00 AM CST) + libc_result = libc_mktime(2026, 11, 1, 3, 0, 0); + esp_result = esptime_recalc_local(2026, 11, 1, 3, 0, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test the repeated hour (1:30 AM occurs twice) + // libc behavior varies by platform for this edge case, so we verify our + // consistent behavior: prefer standard time (later UTC timestamp) + esp_result = esptime_recalc_local(2026, 11, 1, 1, 30, 0); + time_t std_interpretation = esptime_recalc_local(2026, 11, 1, 2, 30, 0) - 3600; // 2:30 CST - 1 hour + EXPECT_EQ(esp_result, std_interpretation); +} + +TEST(RecalcTimestampLocal, SouthernHemisphereDST) { + // Set timezone to Australia/Sydney (AEST-10AEDT,M10.1.0,M4.1.0) + // DST starts first Sunday of October, ends first Sunday of April + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test winter time (July - no DST in southern hemisphere) + time_t libc_result = libc_mktime(2026, 7, 15, 10, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test summer time (January - DST active in southern hemisphere) + libc_result = libc_mktime(2026, 1, 15, 10, 30, 0); + esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, ExactTransitionBoundary) { + // Test exact boundary of spring forward transition + // Mar 8, 2026 at 2:00 AM CST -> 3:00 AM CDT (clocks skip forward) + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // 1:59:59 AM CST - last second before transition (still standard time) + time_t libc_result = libc_mktime(2026, 3, 8, 1, 59, 59); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 59, 59); + EXPECT_EQ(esp_result, libc_result); + + // 3:00:00 AM CDT - first second after transition (now DST) + libc_result = libc_mktime(2026, 3, 8, 3, 0, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 3, 0, 0); + EXPECT_EQ(esp_result, libc_result); + + // Verify the gap: 3:00 AM CDT should be exactly 1 second after 1:59:59 AM CST + time_t before_transition = esptime_recalc_local(2026, 3, 8, 1, 59, 59); + time_t after_transition = esptime_recalc_local(2026, 3, 8, 3, 0, 0); + EXPECT_EQ(after_transition - before_transition, 1); +} + +TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { + // Test DST transition at 3:00 AM instead of default 2:00 AM + // Using custom transition time: CST6CDT,M3.2.0/3,M11.1.0/3 + const char *tz_str = "CST6CDT,M3.2.0/3,M11.1.0/3"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // 2:30 AM should still be standard time (transition at 3:00 AM) + time_t libc_result = libc_mktime(2026, 3, 8, 2, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // 4:00 AM should be DST (after 3:00 AM transition) + libc_result = libc_mktime(2026, 3, 8, 4, 0, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 4, 0, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, YearBoundaryDST) { + // Test southern hemisphere DST across year boundary + // Australia/Sydney: DST active from October to April (spans Jan 1) + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Dec 31, 2025 at 23:30 - DST should be active + time_t libc_result = libc_mktime(2025, 12, 31, 23, 30, 0); + time_t esp_result = esptime_recalc_local(2025, 12, 31, 23, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Jan 1, 2026 at 00:30 - DST should still be active + libc_result = libc_mktime(2026, 1, 1, 0, 30, 0); + esp_result = esptime_recalc_local(2026, 1, 1, 0, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Verify both are in DST (11 hour offset from UTC, not 10) + // The timestamps should be 1 hour apart + time_t dec31 = esptime_recalc_local(2025, 12, 31, 23, 30, 0); + time_t jan1 = esptime_recalc_local(2026, 1, 1, 0, 30, 0); + EXPECT_EQ(jan1 - dec31, 3600); // 1 hour difference +} + +// ============================================================================ +// ESPTime::timezone_offset() tests +// ============================================================================ + +TEST(TimezoneOffset, NoTimezone) { + // When no timezone is set, offset should be 0 + time::ParsedTimezone tz{}; + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + EXPECT_EQ(offset, 0); +} + +TEST(TimezoneOffset, FixedOffsetPositive) { + // India: UTC+5:30 (no DST) + const char *tz_str = "IST-5:30"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + // Offset should be +5:30 = 19800 seconds (to add to UTC to get local) + EXPECT_EQ(offset, 5 * 3600 + 30 * 60); +} + +TEST(TimezoneOffset, FixedOffsetNegative) { + // US Eastern Standard Time: UTC-5 (testing without DST rules) + const char *tz_str = "EST5"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + // Offset should be -5 hours = -18000 seconds + EXPECT_EQ(offset, -5 * 3600); +} + +TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { + // US Eastern with DST + const char *tz_str = "EST5EDT,M3.2.0,M11.1.0"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Get current time and check offset matches expected based on DST status + time_t now = ::time(nullptr); + int32_t offset = ESPTime::timezone_offset(); + + // Verify offset matches what is_in_dst says + if (time::is_in_dst(now, tz)) { + // During DST, offset should be -4 hours (EDT) + EXPECT_EQ(offset, -4 * 3600); + } else { + // During standard time, offset should be -5 hours (EST) + EXPECT_EQ(offset, -5 * 3600); + } +} + +} // namespace esphome::testing From 50e7571f4c603f460a2dffa2b78eacc0a6dd5014 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 18:17:25 -0700 Subject: [PATCH 41/42] [web_server_idf] Prefer make_unique_for_overwrite for noninit recv buffer (#14279) --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 4034a22586..f18570965b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -921,7 +921,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c }); // Use heap buffer - 1460 bytes is too large for the httpd task stack - auto buffer = std::make_unique(MULTIPART_CHUNK_SIZE); + auto buffer = std::make_unique_for_overwrite(MULTIPART_CHUNK_SIZE); size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { From 15846137a6c64bbbc44345f29ff17d40ac92f1bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Feb 2026 18:17:52 -0700 Subject: [PATCH 42/42] [rp2040] Update arduino-pico framework from 3.9.4 to 5.5.0 (#14328) --- .clang-tidy.hash | 2 +- esphome/components/rp2040/__init__.py | 8 +-- esphome/components/rp2040/gpio.cpp | 2 +- .../components/uart/uart_component_rp2040.cpp | 4 +- .../components/wifi/wifi_component_pico_w.cpp | 53 +++++++++++++------ platformio.ini | 4 +- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 777c846371..767da3f33e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -5eb1e5852765114ad06533220d3160b6c23f5ccefc4de41828699de5dfff5ad6 +b97e16a84153b2a4cfc51137cd6121db3c32374504b2bea55144413b3e573052 diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 3a1ea16fa3..23f12e651f 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -91,18 +91,18 @@ def _parse_platform_version(value): # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 9, 4) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.2.0-gcc12" +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(3, 9, 4), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(3, 9, 4), None), + "dev": (cv.Version(5, 5, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 5, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2040/gpio.cpp index 2b1699f888..4b3c98104c 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2040/gpio.cpp @@ -106,7 +106,7 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { sio_hw->gpio_oe_set = arg->mask; } else if (flags & gpio::FLAG_INPUT) { sio_hw->gpio_oe_clr = arg->mask; - hw_write_masked(&padsbank0_hw->io[arg->pin], + hw_write_masked(&pads_bank0_hw->io[arg->pin], (bool_to_bit(flags & gpio::FLAG_PULLUP) << PADS_BANK0_GPIO0_PUE_LSB) | (bool_to_bit(flags & gpio::FLAG_PULLDOWN) << PADS_BANK0_GPIO0_PDE_LSB), PADS_BANK0_GPIO0_PUE_BITS | PADS_BANK0_GPIO0_PDE_BITS); diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index 0c6834055c..faf8f4d90f 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -115,8 +115,8 @@ void RP2040UartComponent::setup() { if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) { ESP_LOGV(TAG, "Using SerialPIO"); - pin_size_t tx = this->tx_pin_ == nullptr ? SerialPIO::NOPIN : this->tx_pin_->get_pin(); - pin_size_t rx = this->rx_pin_ == nullptr ? SerialPIO::NOPIN : this->rx_pin_->get_pin(); + pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin(); + pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin(); auto *serial = new SerialPIO(tx, rx, this->rx_buffer_size_); // NOLINT(cppcoreguidelines-owning-memory) serial->begin(this->baud_rate_, config); if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1baf21e2b2..9b2c077dc5 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -78,8 +78,13 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return false; #endif - auto ret = WiFi.begin(ap.ssid_.c_str(), ap.password_.c_str()); - if (ret != WL_CONNECTED) + // Use beginNoBlock to avoid WiFi.begin()'s additional 2x timeout wait loop on top of + // CYW43::begin()'s internal blocking join. CYW43::begin() blocks for up to 10 seconds + // (default timeout) to complete the join - this is required because the LwipIntfDev netif + // setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving + // up to 20 additional seconds of blocking per attempt. + auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str()); + if (ret == WL_IDLE_STATUS) return false; return true; @@ -116,13 +121,19 @@ const char *get_disconnect_reason_str(uint8_t reason) { } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { - int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); + // Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino + // framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif + // (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's + // flags and would only fall through to cyw43_wifi_link_status when the flags aren't set. + // Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state. + int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA); switch (status) { case CYW43_LINK_JOIN: - case CYW43_LINK_NOIP: + // WiFi joined, check if we have an IP address via the Arduino framework's WiFi class + if (WiFi.status() == WL_CONNECTED) { + return WiFiSTAConnectStatus::CONNECTED; + } return WiFiSTAConnectStatus::CONNECTING; - case CYW43_LINK_UP: - return WiFiSTAConnectStatus::CONNECTED; case CYW43_LINK_FAIL: case CYW43_LINK_BADAUTH: return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; @@ -139,18 +150,24 @@ int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *r void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { s_scan_result_count++; - const char *ssid_cstr = reinterpret_cast(result->ssid); + + // CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated. + // Use ssid_len to create a properly terminated copy for string operations. + uint8_t len = std::min(result->ssid_len, static_cast(sizeof(result->ssid))); + char ssid_buf[33]; // 32 max + null terminator + memcpy(ssid_buf, result->ssid, len); + ssid_buf[len] = '\0'; // Skip networks that don't match any configured network (unless full results needed) - if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_cstr, result->bssid)) { - this->log_discarded_scan_result_(ssid_cstr, result->bssid, result->rssi, result->channel); + if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_buf, result->bssid)) { + this->log_discarded_scan_result_(ssid_buf, result->bssid, result->rssi, result->channel); return; } bssid_t bssid; std::copy(result->bssid, result->bssid + 6, bssid.begin()); - WiFiScanResult res(bssid, ssid_cstr, strlen(ssid_cstr), result->channel, result->rssi, - result->auth_mode != CYW43_AUTH_OPEN, ssid_cstr[0] == '\0'); + WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN, + len == 0); if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) { this->scan_result_.push_back(res); } @@ -167,7 +184,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { ESP_LOGV(TAG, "cyw43_wifi_scan failed"); } return err == 0; - return true; } #ifdef USE_WIFI_AP @@ -212,8 +228,10 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t * #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { - int err = cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA); - return err == 0; + // Use Arduino WiFi.disconnect() instead of raw cyw43_wifi_leave() to properly + // clean up the lwIP netif, DHCP client, and internal Arduino state. + WiFi.disconnect(); + return true; } bssid_t WiFiComponent::wifi_bssid() { @@ -269,9 +287,10 @@ void WiFiComponent::wifi_loop_() { // Poll for connection state changes // The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32, - // so we need to poll the link status to detect state changes - auto status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); - bool is_connected = (status == CYW43_LINK_UP); + // so we need to poll the link status to detect state changes. + // Use WiFi.connected() which checks both the WiFi link and IP address via the + // Arduino framework's own netif (not the SDK's uninitialized one). + bool is_connected = WiFi.connected(); // Detect connection state change if (is_connected && !s_sta_was_connected) { diff --git a/platformio.ini b/platformio.ini index e35dce2228..16a1b18211 100644 --- a/platformio.ini +++ b/platformio.ini @@ -193,10 +193,10 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.2.0-gcc12 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/3.9.4/rp2040-3.9.4.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.0/rp2040-5.5.0.zip framework = arduino lib_deps =