From 019d415bbd80ee99bcaa72293a7a2148545b51b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 09:37:11 -1000 Subject: [PATCH 01/18] [codegen] Fix templatable int type to use cg.int_ (#15571) --- esphome/components/at581x/__init__.py | 4 ++-- esphome/components/ezo_pmp/__init__.py | 4 ++-- esphome/components/fan/__init__.py | 2 +- esphome/components/pmwcs3/sensor.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index acd2bcc608..94b68db4b3 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -179,7 +179,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_frequency(template_)) if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: - template_ = await cg.templatable(sens_dist, args, int) + template_ = await cg.templatable(sens_dist, args, cg.int_) cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): @@ -199,7 +199,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: - template_ = await cg.templatable(stage_gain, args, int) + template_ = await cg.templatable(stage_gain, args, cg.int_) cg.add(var.set_stage_gain(template_)) if power := config.get(CONF_POWER_CONSUMPTION): diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 3de796dd25..0793495e1a 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -202,7 +202,7 @@ async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) cg.add(var.set_volume(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, int) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) cg.add(var.set_duration(template_)) return var @@ -236,7 +236,7 @@ async def ezo_pmp_dose_with_constant_flow_rate_to_code( template_ = await cg.templatable(config[CONF_VOLUME_PER_MINUTE], args, cg.double) cg.add(var.set_volume(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, int) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) cg.add(var.set_duration(template_)) return var diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index df71c6ab3f..f906d2c945 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -348,7 +348,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(oscillating, args, bool) cg.add(var.set_oscillating(template_)) if (speed := config.get(CONF_SPEED)) is not None: - template_ = await cg.templatable(speed, args, int) + template_ = await cg.templatable(speed, args, cg.int_) cg.add(var.set_speed(template_)) if (direction := config.get(CONF_DIRECTION)) is not None: template_ = await cg.templatable(direction, args, FanDirection) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index bb40f3e499..c0bc54c5ba 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -137,6 +137,6 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) - address = await cg.templatable(config[CONF_ADDRESS], args, int) + address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) cg.add(var.set_new_address(address)) return var From 62d84db5a401fe5218df2b252993dbada32688fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:38:17 -1000 Subject: [PATCH 02/18] Bump CodSpeedHQ/action from 4.13.0 to 4.13.1 (#15577) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06e8189f54..dddf21f57e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v4 + uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 5b840c1662014c8cb30f6630923428791ced3dbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 09:39:12 -1000 Subject: [PATCH 03/18] [codegen] Fix templatable bool type to use cg.bool_ (#15569) --- esphome/components/cover/__init__.py | 2 +- esphome/components/fan/__init__.py | 4 ++-- esphome/components/htu21d/sensor.py | 2 +- esphome/components/mqtt/__init__.py | 4 ++-- esphome/components/nextion/binary_sensor/__init__.py | 6 +++--- esphome/components/nextion/sensor/__init__.py | 4 ++-- esphome/components/nextion/switch/__init__.py | 6 +++--- esphome/components/number/__init__.py | 2 +- esphome/components/online_image/__init__.py | 2 +- esphome/components/remote_base/__init__.py | 10 +++++----- esphome/components/remote_transmitter/__init__.py | 2 +- esphome/components/select/__init__.py | 2 +- esphome/components/switch/__init__.py | 2 +- esphome/components/template/binary_sensor/__init__.py | 2 +- esphome/components/template/switch/__init__.py | 2 +- esphome/components/template/water_heater/__init__.py | 4 ++-- esphome/components/valve/__init__.py | 2 +- 17 files changed, 29 insertions(+), 29 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c330241f4d..cd82de456d 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -300,7 +300,7 @@ async def cover_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if (stop := config.get(CONF_STOP)) is not None: - template_ = await cg.templatable(stop, args, bool) + template_ = await cg.templatable(stop, args, cg.bool_) cg.add(var.set_stop(template_)) if (state := config.get(CONF_STATE)) is not None: template_ = await cg.templatable(state, args, float) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index f906d2c945..ce1e55d36b 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -345,7 +345,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if (oscillating := config.get(CONF_OSCILLATING)) is not None: - template_ = await cg.templatable(oscillating, args, bool) + template_ = await cg.templatable(oscillating, args, cg.bool_) cg.add(var.set_oscillating(template_)) if (speed := config.get(CONF_SPEED)) is not None: template_ = await cg.templatable(speed, args, cg.int_) @@ -370,7 +370,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): async def fan_cycle_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_OFF_SPEED_CYCLE], args, bool) + template_ = await cg.templatable(config[CONF_OFF_SPEED_CYCLE], args, cg.bool_) cg.add(var.set_no_off_cycle(template_)) return var diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 942a28475a..8808dc70f5 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -118,6 +118,6 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): async def set_heater_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - status_ = await cg.templatable(config[CONF_STATUS], args, bool) + status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) cg.add(var.set_status(status_)) return var diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 33a88c49cc..cb6b9d144f 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -504,7 +504,7 @@ async def mqtt_publish_action_to_code(config, action_id, template_arg, args): cg.add(var.set_payload(template_)) template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, bool) + template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) cg.add(var.set_retain(template_)) return var @@ -537,7 +537,7 @@ async def mqtt_publish_json_action_to_code(config, action_id, template_arg, args cg.add(var.set_payload(lambda_)) template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, bool) + template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) cg.add(var.set_retain(template_)) return var diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 5b5922887c..29f5bdaea7 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -76,13 +76,13 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 7351d8f1d5..ba551056c6 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -119,10 +119,10 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_STATE], args, float) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 81e6721d0f..29749ecab0 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -58,13 +58,13 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index c844100258..65c131aeab 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -445,7 +445,7 @@ async def number_to_to_code(config, action_id, template_arg, args): to_ = await cg.templatable(operation, args, NumberOperation) cg.add(var.set_operation(to_)) if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, bool) + template_ = await cg.templatable(cycle, args, cg.bool_) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: template_ = await cg.templatable( diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 518d787d8a..ee4d5abb1c 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -100,7 +100,7 @@ async def online_image_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, bool) + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) cg.add(var.set_update(template_)) return var diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 042ac9d46a..cbf82e6f44 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -868,7 +868,7 @@ async def keeloq_action(var, config, args): cg.add(var.set_encrypted(template_)) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_LEVEL], args, bool) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) cg.add(var.set_vlow(template_)) @@ -1580,7 +1580,7 @@ async def rc_switch_type_a_action(var, config, args): cg.add( var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.std_string)) ) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1605,7 +1605,7 @@ async def rc_switch_type_b_action(var, config, args): cg.add(var.set_protocol(proto)) cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1638,7 +1638,7 @@ async def rc_switch_type_c_action(var, config, args): ) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1663,7 +1663,7 @@ async def rc_switch_type_d_action(var, config, args): cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_trigger("rc_switch", RCSwitchTrigger, RCSwitchData) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 89019e296e..1163fc86eb 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -128,7 +128,7 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( async def digital_write_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, bool) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 8c7c8f00fa..ba5214e550 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -279,7 +279,7 @@ async def select_operation_to_code(config, action_id, template_arg, args): op_ = await cg.templatable(operation, args, SelectOperation) cg.add(var.set_operation(op_)) if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, bool) + template_ = await cg.templatable(cycle, args, cg.bool_) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: template_ = await cg.templatable( diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 5a63cbfb9f..9fa4a013ff 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -196,7 +196,7 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( async def switch_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index e537e1f97c..8f57df91c5 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -64,6 +64,6 @@ async def to_code(config): async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index eb6f0f46de..ca986365ed 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -85,6 +85,6 @@ async def to_code(config): async def switch_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 814aa40193..2dbc952bf1 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -158,11 +158,11 @@ async def water_heater_template_publish_to_code( cg.add(var.set_mode(template_)) if CONF_AWAY in config: - template_ = await cg.templatable(config[CONF_AWAY], args, bool) + template_ = await cg.templatable(config[CONF_AWAY], args, cg.bool_) cg.add(var.set_away(template_)) if CONF_IS_ON in config: - template_ = await cg.templatable(config[CONF_IS_ON], args, bool) + template_ = await cg.templatable(config[CONF_IS_ON], args, cg.bool_) cg.add(var.set_is_on(template_)) return var diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 0319ff50e7..d3b9c14a1f 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -229,7 +229,7 @@ async def valve_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if stop_config := config.get(CONF_STOP): - template_ = await cg.templatable(stop_config, args, bool) + template_ = await cg.templatable(stop_config, args, cg.bool_) cg.add(var.set_stop(template_)) if state_config := config.get(CONF_STATE): template_ = await cg.templatable(state_config, args, float) From 4a764ae1e388ab713f25c5b982653a004d292a65 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:42:47 -0400 Subject: [PATCH 04/18] [spi] Fix IndexError on invalid RP2040 CLK pin (#15562) --- esphome/components/spi/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 931882be8d..33ccfbb5ee 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -246,11 +246,15 @@ def validate_hw_pins(spi, index=-1): return clk_pin_no >= 0 if target_platform == PLATFORM_RP2040: - pin_set = ( - list(filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS))[0] - if index == -1 - else RP_SPI_PINSETS[index] - ) + if index == -1: + matches = list( + filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) + ) + if not matches: + return False + pin_set = matches[0] + else: + pin_set = RP_SPI_PINSETS[index] if pin_set is None: return False if sdo_pin_no not in pin_set[CONF_MOSI_PIN]: From 4b8f99ed102cbe6ef5b53098b455c6c71e2c06e8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:44:19 -0400 Subject: [PATCH 05/18] [modbus_controller] Fix output missing address validation and text_sensor division (#15561) --- esphome/components/modbus_controller/output/__init__.py | 9 +++++++++ .../components/modbus_controller/text_sensor/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 1ec4afd997..27d212d58d 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -11,6 +11,7 @@ from .. import ( modbus_controller_ns, ) from ..const import ( + CONF_CUSTOM_COMMAND, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_TYPE, CONF_USE_WRITE_MULTIPLE, @@ -35,6 +36,10 @@ CONFIG_SCHEMA = cv.typed_schema( "coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( { cv.GenerateID(): cv.declare_id(ModbusBinaryOutput), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid( + "custom_command is not supported for outputs" + ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, } @@ -42,6 +47,10 @@ CONFIG_SCHEMA = cv.typed_schema( "holding": output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( { cv.GenerateID(): cv.declare_id(ModbusFloatOutput), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid( + "custom_command is not supported for outputs" + ), cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( SENSOR_VALUE_TYPE ), diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 995357143e..93ecd31168 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -61,7 +61,7 @@ async def to_code(config): response_size = config[CONF_RESPONSE_SIZE] reg_count = config[CONF_REGISTER_COUNT] if reg_count == 0: - reg_count = response_size / 2 + reg_count = response_size // 2 var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], From fb0033947c18ac02cea29976d8155c4e3618d7d6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:45:43 -0400 Subject: [PATCH 06/18] [qspi_dbi] Connect _validate to CONFIG_SCHEMA (#15563) --- esphome/components/qspi_dbi/display.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 48d1f6d12e..595067e94c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -154,6 +154,7 @@ CONFIG_SCHEMA = cv.All( upper=True, key=CONF_MODEL, ), + _validate, cv.only_on_esp32, ) From 312dea7ddba55312f356f6907d2c9b5093e0b123 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 09:46:16 -1000 Subject: [PATCH 07/18] [json] Fix heap buffer overflow in SerializationBuffer truncation path (#15566) --- esphome/components/json/json_util.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 6c60a04d20..edcd23f922 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -140,8 +140,11 @@ SerializationBuffer<> JsonBuilder::serialize() { heap_size *= 2; } // Payload exceeds 5120 bytes - return truncated result - ESP_LOGW(TAG, "JSON payload too large, truncated to %zu bytes", size); - result.set_size_(size); + // heap_size was doubled after the last iteration, so the actual allocated + // buffer capacity is heap_size/2. Clamp to avoid writing past the buffer. + size_t max_content = heap_size / 2 - 1; + ESP_LOGW(TAG, "JSON payload too large, truncated to %zu bytes", max_content); + result.set_size_(max_content); return result; } From 19c8f0ac7a42f96dddeb08a9213763867e82b879 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:46:36 -0400 Subject: [PATCH 08/18] [zephyr] Fix user overlay only emitting first property (#15560) --- esphome/components/zephyr/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 348e7a3cf2..d3cc6b2cf4 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -199,11 +199,14 @@ def zephyr_add_user(key, value): def copy_files(): user = zephyr_data()[KEY_USER] if user: + entries = " ".join( + f"{key} = {', '.join(value)};" for key, value in user.items() + ) zephyr_add_overlay( f""" / {{ zephyr,user {{ - {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} + {entries} }}; }}; """ From 94f1e48d959029d737c1ce27b098b73f32d9fb17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 10:09:43 -1000 Subject: [PATCH 09/18] [esp32] Preserve crash data across OTA rollback reboots (#15578) --- esphome/components/api/api_connection.h | 1 + esphome/components/esp32/crash_handler.cpp | 11 +++++++++-- esphome/components/esp32/crash_handler.h | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 2f685b0b8a..4be5a73e81 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -276,6 +276,7 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); + esp32::crash_handler_clear(); #endif #ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ecf30d7878..55cf92a703 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -101,12 +101,19 @@ void crash_handler_read_and_clear() { if (s_raw_crash_data.pseudo_excause > 1) s_raw_crash_data.pseudo_excause = 0; } - // Clear magic regardless so we don't re-report on next normal reboot - s_raw_crash_data.magic = 0; + // Don't clear magic here — crash data must survive OTA rollback reboots. + // Magic is cleared by crash_handler_clear() after an API client receives the data. } bool crash_handler_has_data() { return s_crash_data_valid; } +void crash_handler_clear() { + // Only clear the magic so data doesn't survive the next reboot. + // Keep s_crash_data_valid so crash_handler_log() still works for + // additional API clients connecting during this boot session. + s_raw_crash_data.magic = 0; +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 97a4d4e116..c5e7d145ec 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,12 +4,18 @@ namespace esphome::esp32 { -/// Read crash data from NOINIT memory and clear the magic marker. +/// Read and validate crash data from NOINIT memory. +/// Does not clear the magic marker — call crash_handler_clear() after +/// the data has been delivered to an API client so it survives OTA rollback reboots. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Clear the magic marker and mark crash data as consumed. +/// Call after the data has been delivered to an API client. +void crash_handler_clear(); + /// Returns true if crash data was found this boot. bool crash_handler_has_data(); From 2cd92a311b323df121fdb49c1a755c1c464c4960 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:14:18 -0400 Subject: [PATCH 10/18] [esp32] Capture both cores' backtraces in crash handler (#15559) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 219 ++++++++++++++------- 1 file changed, 149 insertions(+), 70 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 55cf92a703..ed61b61936 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -59,6 +59,59 @@ static inline bool is_return_addr(uint32_t addr) { } #endif +// --- Architecture-specific backtrace helpers --- +// These run from IRAM during panic (no flash access). + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// Walk Xtensa backtrace from an exception frame, writing PCs to out[]. +// Returns number of entries written. +static uint8_t IRAM_ATTR walk_xtensa_backtrace(XtExcFrame *frame, uint32_t *out, uint8_t max) { + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) frame->pc, + .sp = (uint32_t) frame->a1, + .next_pc = (uint32_t) frame->a0, + .exc_frame = frame, + }; + uint8_t count = 0; + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + out[count++] = first_pc; + } + while (count < max && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) + break; + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + out[count++] = pc; + } + } + return count; +} +#endif + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Capture RISC-V backtrace: MEPC + RA from registers, then stack scan. +// Returns total count; *reg_count receives number of register-sourced entries. +static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *out, uint8_t max, uint8_t *reg_count) { + uint8_t count = 0; + if (is_code_addr(frame->mepc)) { + out[count++] = frame->mepc; + } + if (is_code_addr(frame->ra) && frame->ra != frame->mepc) { + out[count++] = frame->ra; + } + *reg_count = count; + auto *scan_start = (uint32_t *) frame->sp; + for (uint32_t i = 0; i < 64 && count < max; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != frame->mepc && val != frame->ra) { + out[count++] = val; + } + } + return count; +} +#endif + // Raw crash data written by the panic handler wrapper. // Lives in .noinit so it survives software reset but contains garbage after power cycle. // Validated by magic marker. Static linkage since it's only used within this file. @@ -66,7 +119,7 @@ static inline bool is_return_addr(uint32_t addr) { // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_DATA_VERSION = 2; struct RawCrashData { uint32_t version; uint32_t magic; @@ -77,6 +130,13 @@ struct RawCrashData { uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) uint32_t backtrace[MAX_BACKTRACE]; uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint8_t crashed_core; +#if SOC_CPU_CORES_NUM > 1 + static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); + uint8_t other_backtrace_count; + uint8_t other_reg_frame_count; + uint32_t other_backtrace[MAX_BACKTRACE]; +#endif }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -100,6 +160,14 @@ void crash_handler_read_and_clear() { s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT if (s_raw_crash_data.pseudo_excause > 1) s_raw_crash_data.pseudo_excause = 0; + if (s_raw_crash_data.crashed_core >= SOC_CPU_CORES_NUM) + s_raw_crash_data.crashed_core = 0; +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.other_backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.other_reg_frame_count > s_raw_crash_data.other_backtrace_count) + s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; +#endif } // Don't clear magic here — crash data must survive OTA rollback reboots. // Magic is cleared by crash_handler_clear() after an API client receives the data. @@ -219,6 +287,36 @@ static const char *get_exception_type() { return "Unknown"; } +// Log backtrace entries, filtering stack-scanned addresses on RISC-V. +static void log_backtrace(const uint32_t *addrs, uint8_t count, uint8_t reg_frame_count) { + uint8_t bt_num = 0; + for (uint8_t i = 0; i < count; i++) { + uint32_t addr = addrs[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= reg_frame_count && !is_return_addr(addr)) + continue; + const char *source = (i < reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } +} + +// Append backtrace addresses to the addr2line hint buffer. +static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *addrs, uint8_t count, + uint8_t reg_frame_count) { + for (uint8_t i = 0; i < count && pos < size - 12; i++) { + uint32_t addr = addrs[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(buf + pos, size - pos, " 0x%08" PRIX32, addr); + } + return pos; +} + // Intentionally uses separate ESP_LOGE calls per line instead of combining into // one multi-line log message. This ensures each address appears as its own line // on the serial console, making it possible to see partial output if the device @@ -235,33 +333,28 @@ void crash_handler_log() { } else { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } + ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); - uint8_t bt_num = 0; - for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { - uint32_t addr = s_raw_crash_data.backtrace[i]; -#if CONFIG_IDF_TARGET_ARCH_RISCV - // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. - if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) - continue; -#endif -#if CONFIG_IDF_TARGET_ARCH_RISCV - const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; -#else - const char *source = "backtrace"; -#endif - ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); + +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + int other_core = 1 - s_raw_crash_data.crashed_core; + ESP_LOGE(TAG, " Other core (%d) backtrace:", other_core); + log_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count); } +#endif + // Build addr2line hint with all captured addresses for easy copy-paste char hint[256]; int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { - uint32_t addr = s_raw_crash_data.backtrace[i]; -#if CONFIG_IDF_TARGET_ARCH_RISCV - if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) - continue; + pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); +#if SOC_CPU_CORES_NUM > 1 + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); #endif - pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); - } ESP_LOGE(TAG, "%s", hint); } @@ -283,68 +376,54 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.reg_frame_count = 0; s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + s_raw_crash_data.crashed_core = (uint8_t) info->core; +#if SOC_CPU_CORES_NUM > 1 + s_raw_crash_data.other_backtrace_count = 0; + s_raw_crash_data.other_reg_frame_count = 0; +#endif #if CONFIG_IDF_TARGET_ARCH_XTENSA // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; s_raw_crash_data.cause = xt_frame->exccause; - esp_backtrace_frame_t bt_frame = { - .pc = (uint32_t) xt_frame->pc, - .sp = (uint32_t) xt_frame->a1, - .next_pc = (uint32_t) xt_frame->a0, - .exc_frame = xt_frame, - }; - - uint8_t count = 0; - // First frame PC - uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); - if (is_code_addr(first_pc)) { - s_raw_crash_data.backtrace[count++] = first_pc; - } - // Walk remaining frames - while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { - if (!esp_backtrace_get_next_frame(&bt_frame)) { - break; - } - uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); - if (is_code_addr(pc)) { - s_raw_crash_data.backtrace[count++] = pc; - } - } - s_raw_crash_data.backtrace_count = count; + s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } +#if SOC_CPU_CORES_NUM > 1 + // Capture the other core's backtrace from the global frame array. + // Both cores save their frames to g_exc_frames[] before esp_panic_handler + // is called, so the other core's frame is available here. + if (info->core >= 0 && info->core < SOC_CPU_CORES_NUM) { + int other_core = 1 - info->core; + auto *other_frame = (XtExcFrame *) g_exc_frames[other_core]; + if (other_frame != nullptr) { + s_raw_crash_data.other_backtrace_count = + walk_xtensa_backtrace(other_frame, s_raw_crash_data.other_backtrace, MAX_BACKTRACE); + } + } +#endif + #elif CONFIG_IDF_TARGET_ARCH_RISCV // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; s_raw_crash_data.cause = rv_frame->mcause; - uint8_t count = 0; - - // Save MEPC (fault PC) and RA (return address) - if (is_code_addr(rv_frame->mepc)) { - s_raw_crash_data.backtrace[count++] = rv_frame->mepc; - } - if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { - s_raw_crash_data.backtrace[count++] = rv_frame->ra; - } - - // Track how many entries came from registers (MEPC/RA) so we can - // skip return-address validation for them at log time. - s_raw_crash_data.reg_frame_count = count; - - // Scan stack for code addresses — captures broadly during panic, - // filtered by is_return_addr() at log time when flash is accessible. - auto *scan_start = (uint32_t *) rv_frame->sp; - for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { - uint32_t val = scan_start[i]; - if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { - s_raw_crash_data.backtrace[count++] = val; - } - } - s_raw_crash_data.backtrace_count = count; + s_raw_crash_data.backtrace_count = + capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } + +#if SOC_CPU_CORES_NUM > 1 + // Capture the other core's backtrace from the global frame array. + if (info->core >= 0 && info->core < SOC_CPU_CORES_NUM) { + int other_core = 1 - info->core; + auto *other_frame = (RvExcFrame *) g_exc_frames[other_core]; + if (other_frame != nullptr) { + s_raw_crash_data.other_backtrace_count = capture_riscv_backtrace( + other_frame, s_raw_crash_data.other_backtrace, MAX_BACKTRACE, &s_raw_crash_data.other_reg_frame_count); + } + } +#endif #endif // Write version and magic last — ensures all data is written before we mark it valid From 4a18ef87d7b8e28862b1a0c73be07ca123bf06f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 10:23:36 -1000 Subject: [PATCH 11/18] [codegen] Fix templatable float type to use cg.float_ (#15568) --- .../analog_threshold/binary_sensor.py | 6 ++--- esphome/components/audio_adc/__init__.py | 2 +- esphome/components/audio_dac/__init__.py | 2 +- esphome/components/climate/__init__.py | 8 +++---- esphome/components/cover/__init__.py | 6 ++--- .../components/dfrobot_sen0395/__init__.py | 16 +++++++------- esphome/components/esp8266_pwm/output.py | 2 +- esphome/components/integration/sensor.py | 2 +- esphome/components/ledc/output.py | 2 +- esphome/components/libretiny_pwm/output.py | 2 +- esphome/components/light/automation.py | 22 +++++++++---------- esphome/components/media_player/__init__.py | 2 +- esphome/components/nextion/display.py | 2 +- esphome/components/nextion/sensor/__init__.py | 2 +- esphome/components/number/__init__.py | 10 ++++++--- esphome/components/output/__init__.py | 6 ++--- esphome/components/pid/climate.py | 6 ++--- .../components/pipsolar/output/__init__.py | 2 +- esphome/components/rp2040_pwm/output.py | 2 +- esphome/components/sensor/__init__.py | 14 ++++++------ esphome/components/servo/__init__.py | 2 +- esphome/components/speaker/__init__.py | 2 +- esphome/components/template/cover/__init__.py | 6 ++--- .../components/template/sensor/__init__.py | 2 +- esphome/components/template/valve/__init__.py | 4 ++-- .../template/water_heater/__init__.py | 4 ++-- esphome/components/ufire_ec/sensor.py | 4 ++-- esphome/components/ufire_ise/sensor.py | 4 ++-- esphome/components/valve/__init__.py | 4 ++-- 29 files changed, 76 insertions(+), 72 deletions(-) diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index b5f87b9b5c..8c13727755 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -40,10 +40,10 @@ async def to_code(config): cg.add(var.set_sensor(sens)) if isinstance(config[CONF_THRESHOLD], dict): - lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], float) - upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], float) + lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_) + upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], cg.float_) else: - lower = await cg.templatable(config[CONF_THRESHOLD], [], float) + lower = await cg.templatable(config[CONF_THRESHOLD], [], cg.float_) upper = lower cg.add(var.set_upper_threshold(upper)) cg.add(var.set_lower_threshold(lower)) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c9b32e610..3c3a4988b5 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -32,7 +32,7 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, float) + template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, cg.float_) cg.add(var.set_mic_gain(template_)) return var diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index a950c1967b..46c277ce51 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -52,7 +52,7 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config.get(CONF_VOLUME), args, float) + template_ = await cg.templatable(config.get(CONF_VOLUME), args, cg.float_) cg.add(var.set_volume(template_)) return var diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 13dd7aa007..df77fa5c1c 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -488,16 +488,16 @@ async def climate_control_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(mode, args, ClimateMode) cg.add(var.set_mode(template_)) if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, float) + template_ = await cg.templatable(target_temp, args, cg.float_) cg.add(var.set_target_temperature(template_)) if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, float) + template_ = await cg.templatable(target_temp_low, args, cg.float_) cg.add(var.set_target_temperature_low(template_)) if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, float) + template_ = await cg.templatable(target_temp_high, args, cg.float_) cg.add(var.set_target_temperature_high(template_)) if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, float) + template_ = await cg.templatable(target_humidity, args, cg.float_) cg.add(var.set_target_humidity(template_)) if (fan_mode := config.get(CONF_FAN_MODE)) is not None: template_ = await cg.templatable(fan_mode, args, ClimateFanMode) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index cd82de456d..fdfca55f0f 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -303,13 +303,13 @@ async def cover_control_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(stop, args, cg.bool_) cg.add(var.set_stop(template_)) if (state := config.get(CONF_STATE)) is not None: - template_ = await cg.templatable(state, args, float) + template_ = await cg.templatable(state, args, cg.float_) cg.add(var.set_position(template_)) if (position := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position, args, float) + template_ = await cg.templatable(position, args, cg.float_) cg.add(var.set_position(template_)) if (tilt := config.get(CONF_TILT)) is not None: - template_ = await cg.templatable(tilt, args, float) + template_ = await cg.templatable(tilt, args, cg.float_) cg.add(var.set_tilt(template_)) return var diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index feb79eeacf..943c510279 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -166,24 +166,24 @@ async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args segments = config[CONF_DETECTION_SEGMENTS] if len(segments) >= 2: - template_ = await cg.templatable(segments[0], args, float) + template_ = await cg.templatable(segments[0], args, cg.float_) cg.add(var.set_det_min1(template_)) - template_ = await cg.templatable(segments[1], args, float) + template_ = await cg.templatable(segments[1], args, cg.float_) cg.add(var.set_det_max1(template_)) if len(segments) >= 4: - template_ = await cg.templatable(segments[2], args, float) + template_ = await cg.templatable(segments[2], args, cg.float_) cg.add(var.set_det_min2(template_)) - template_ = await cg.templatable(segments[3], args, float) + template_ = await cg.templatable(segments[3], args, cg.float_) cg.add(var.set_det_max2(template_)) if len(segments) >= 6: - template_ = await cg.templatable(segments[4], args, float) + template_ = await cg.templatable(segments[4], args, cg.float_) cg.add(var.set_det_min3(template_)) - template_ = await cg.templatable(segments[5], args, float) + template_ = await cg.templatable(segments[5], args, cg.float_) cg.add(var.set_det_max3(template_)) if len(segments) >= 8: - template_ = await cg.templatable(segments[6], args, float) + template_ = await cg.templatable(segments[6], args, cg.float_) cg.add(var.set_det_min4(template_)) - template_ = await cg.templatable(segments[7], args, float) + template_ = await cg.templatable(segments[7], args, cg.float_) cg.add(var.set_det_max4(template_)) if CONF_OUTPUT_LATENCY in config: template_ = await cg.templatable( diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index b9b6dcc95a..f119a6ba9f 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -62,6 +62,6 @@ async def to_code(config) -> None: async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index d0aae4201e..8d784df672 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -133,6 +133,6 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args async def sensor_integration_set_value_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_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 62ff5ad30a..95df1fba23 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -82,6 +82,6 @@ async def to_code(config): async def ledc_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index e812b6a8f2..6f71530aaf 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -43,6 +43,6 @@ async def to_code(config): async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 2400822b31..46d37239e5 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -183,18 +183,18 @@ async def light_control_to_code(config, action_id, template_arg, args): # (config_key, setter_name, c++ type) FIELDS = ( (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", bool), + (CONF_STATE, "set_state", cg.bool_), (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", float), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", float), - (CONF_RED, "set_red", float), - (CONF_GREEN, "set_green", float), - (CONF_BLUE, "set_blue", float), - (CONF_WHITE, "set_white", float), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", float), - (CONF_COLD_WHITE, "set_cold_white", float), - (CONF_WARM_WHITE, "set_warm_white", float), + (CONF_BRIGHTNESS, "set_brightness", cg.float_), + (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + (CONF_RED, "set_red", cg.float_), + (CONF_GREEN, "set_green", cg.float_), + (CONF_BLUE, "set_blue", cg.float_), + (CONF_WHITE, "set_white", cg.float_), + (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + (CONF_COLD_WHITE, "set_cold_white", cg.float_), + (CONF_WARM_WHITE, "set_warm_white", cg.float_), ) for conf_key, setter, type_ in FIELDS: if conf_key in config: @@ -262,7 +262,7 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( async def light_dim_relative_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, float) + templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_) cg.add(var.set_relative_brightness(templ)) if CONF_TRANSITION_LENGTH in config: templ = await cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 3c2e9029d6..1c2c474645 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -305,7 +305,7 @@ _register_state_conditions() async def media_player_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, float) + volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) cg.add(var.set_volume(volume)) return var diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 4d42898a10..dc3d5c6d09 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -148,7 +148,7 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, float) + template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) cg.add(var.set_brightness(template_)) return var diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index ba551056c6..61cb42e62c 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -116,7 +116,7 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_state(template_)) template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 65c131aeab..f13ccc4c36 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -257,10 +257,14 @@ async def _build_number_automations(var, config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) if CONF_ABOVE in conf: - template_ = await cg.templatable(conf[CONF_ABOVE], [(float, "x")], float) + template_ = await cg.templatable( + conf[CONF_ABOVE], [(float, "x")], cg.float_ + ) cg.add(trigger.set_min(template_)) if CONF_BELOW in conf: - template_ = await cg.templatable(conf[CONF_BELOW], [(float, "x")], float) + template_ = await cg.templatable( + conf[CONF_BELOW], [(float, "x")], cg.float_ + ) cg.add(trigger.set_max(template_)) await automation.build_automation(trigger, [(float, "x")], conf) @@ -362,7 +366,7 @@ OPERATION_BASE_SCHEMA = cv.Schema( async def number_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index a4ce2b2d1a..36798f2d7f 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -104,7 +104,7 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): async def output_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, float) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) cg.add(var.set_level(template_)) return var @@ -123,7 +123,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): async def output_set_min_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MIN_POWER], args, float) + template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) cg.add(var.set_min_power(template_)) return var @@ -142,7 +142,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): async def output_set_max_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MAX_POWER], args, float) + template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) cg.add(var.set_max_power(template_)) return var diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 18e33b8039..3e4ff754c9 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -189,13 +189,13 @@ async def set_control_parameters(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - kp_template_ = await cg.templatable(config[CONF_KP], args, float) + kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_) cg.add(var.set_kp(kp_template_)) - ki_template_ = await cg.templatable(config[CONF_KI], args, float) + ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_) cg.add(var.set_ki(ki_template_)) - kd_template_ = await cg.templatable(config[CONF_KD], args, float) + kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_) cg.add(var.set_kd(kd_template_)) return var diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 4ae8d9d487..d1ea981589 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -103,6 +103,6 @@ async def to_code(config): async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_level(template_)) return var diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 4ea488a6cd..ad37926954 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -47,6 +47,6 @@ async def to_code(config): async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index ecf51d5488..b658ff7056 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -371,13 +371,13 @@ def sensor_schema( @FILTER_REGISTRY.register("offset", OffsetFilter, cv.templatable(cv.float_)) async def offset_filter_to_code(config, filter_id): - template_ = await cg.templatable(config, [], float) + template_ = await cg.templatable(config, [], cg.float_) return cg.new_Pvariable(filter_id, template_) @FILTER_REGISTRY.register("multiply", MultiplyFilter, cv.templatable(cv.float_)) async def multiply_filter_to_code(config, filter_id): - template_ = await cg.templatable(config, [], float) + template_ = await cg.templatable(config, [], cg.float_) return cg.new_Pvariable(filter_id, template_) @@ -389,7 +389,7 @@ async def multiply_filter_to_code(config, filter_id): async def filter_out_filter_to_code(config, filter_id): if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], float) for x in config] + template_ = [await cg.templatable(x, [], cg.float_) for x in config] return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) @@ -658,7 +658,7 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( async def throttle_with_priority_filter_to_code(config, filter_id): if not isinstance(config[CONF_VALUE], list): config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] + template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) @@ -713,7 +713,7 @@ async def timeout_filter_to_code(config, filter_id): else: # Use TimeoutFilterConfigured for configured value mode filter_id.type = TimeoutFilterConfigured - template_ = await cg.templatable(config[CONF_VALUE], [], float) + template_ = await cg.templatable(config[CONF_VALUE], [], cg.float_) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) return var @@ -909,10 +909,10 @@ async def _build_sensor_automations(var, config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) if (above := conf.get(CONF_ABOVE)) is not None: - template_ = await cg.templatable(above, [(float, "x")], float) + template_ = await cg.templatable(above, [(float, "x")], cg.float_) cg.add(trigger.set_min(template_)) if (below := conf.get(CONF_BELOW)) is not None: - template_ = await cg.templatable(below, [(float, "x")], float) + template_ = await cg.templatable(below, [(float, "x")], cg.float_) cg.add(trigger.set_max(template_)) await automation.build_automation(trigger, [(float, "x")], conf) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index a23bb53536..c2eaefe455 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -67,7 +67,7 @@ async def to_code(config): async def servo_write_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, float) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 8480eebcdb..98b5abe58c 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -127,7 +127,7 @@ automation.register_condition( async def speaker_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, float) + volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) cg.add(var.set_volume(volume)) return var diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index ea4da4e73c..a30c0af313 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -130,13 +130,13 @@ async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_position(template_)) if CONF_POSITION in config: - template_ = await cg.templatable(config[CONF_POSITION], args, float) + template_ = await cg.templatable(config[CONF_POSITION], args, cg.float_) cg.add(var.set_position(template_)) if CONF_TILT in config: - template_ = await cg.templatable(config[CONF_TILT], args, float) + template_ = await cg.templatable(config[CONF_TILT], args, cg.float_) cg.add(var.set_tilt(template_)) if CONF_CURRENT_OPERATION in config: template_ = await cg.templatable( diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index b0f48ade46..0c875bba0f 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -49,6 +49,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 3e8fd81603..a2d0c19880 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -118,10 +118,10 @@ async def valve_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, float) + template_ = await cg.templatable(state_config, args, cg.float_) cg.add(var.set_position(template_)) if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, float) + template_ = await cg.templatable(position_config, args, cg.float_) cg.add(var.set_position(template_)) if current_operation_config := config.get(CONF_CURRENT_OPERATION): template_ = await cg.templatable( diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 2dbc952bf1..7f8a82c916 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -146,11 +146,11 @@ async def water_heater_template_publish_to_code( await cg.register_parented(var, config[CONF_ID]) if current_temp := config.get(CONF_CURRENT_TEMPERATURE): - template_ = await cg.templatable(current_temp, args, float) + template_ = await cg.templatable(current_temp, args, cg.float_) cg.add(var.set_current_temperature(template_)) if target_temp := config.get(CONF_TARGET_TEMPERATURE): - template_ = await cg.templatable(target_temp, args, float) + template_ = await cg.templatable(target_temp, args, cg.float_) cg.add(var.set_target_temperature(template_)) if mode := config.get(CONF_MODE): diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 10b4ece614..1d8775ccf0 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -102,8 +102,8 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - solution_ = await cg.templatable(config[CONF_SOLUTION], args, float) - temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, float) + solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) + temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) cg.add(var.set_solution(solution_)) cg.add(var.set_temperature(temperature_)) return var diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index a116012d05..23254b2f47 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -96,7 +96,7 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, float) + template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) cg.add(var.set_solution(template_)) return var @@ -110,7 +110,7 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, float) + template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) cg.add(var.set_solution(template_)) return var diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d3b9c14a1f..1930a7ad0c 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -232,10 +232,10 @@ async def valve_control_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(stop_config, args, cg.bool_) cg.add(var.set_stop(template_)) if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, float) + template_ = await cg.templatable(state_config, args, cg.float_) cg.add(var.set_position(template_)) if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, float) + template_ = await cg.templatable(position_config, args, cg.float_) cg.add(var.set_position(template_)) return var From 576d89a82a79a9c8acd688716fef81ee3119172b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 11:05:53 -1000 Subject: [PATCH 12/18] [api] Peel first write iteration, inline socket writes, zero-gap batch encoding (#15063) --- esphome/components/api/api_connection.cpp | 64 ++----- esphome/components/api/api_connection.h | 64 ++++++- esphome/components/api/api_frame_helper.cpp | 78 +++++---- esphome/components/api/api_frame_helper.h | 85 ++++++--- .../components/api/api_frame_helper_noise.cpp | 127 +++++++------- .../components/api/api_frame_helper_noise.h | 17 +- .../api/api_frame_helper_plaintext.cpp | 162 ++++++++++-------- .../api/api_frame_helper_plaintext.h | 15 +- esphome/components/api/proto.h | 11 ++ .../components/api/bench_plaintext_frame.cpp | 4 +- 10 files changed, 374 insertions(+), 253 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index bfb3ec291c..17605345fd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -406,7 +406,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif - return encode_to_buffer(size_fn(&msg), encode_fn, &msg, conn, remaining_size); + return encode_to_buffer_slow(size_fn(&msg), encode_fn, &msg, conn, remaining_size); } uint16_t APIConnection::fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, @@ -2005,48 +2005,12 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type); } -// Encodes a message to the buffer and returns the total number of bytes used, -// including header and footer overhead. Returns 0 if the message doesn't fit. -uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, - APIConnection *conn, uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - // Cache frame sizes to avoid repeated virtual calls - const uint8_t header_padding = conn->helper_->frame_header_padding(); - const uint8_t footer_size = conn->helper_->frame_footer_size(); +// encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE) - // Calculate total size with padding for buffer allocation - size_t total_calculated_size = calculated_size + header_padding + footer_size; - - // Check if it fits - if (total_calculated_size > remaining_size) - return 0; // Doesn't fit - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - - size_t to_add; - if (conn->flags_.batch_first_message) { - // First message - buffer already prepared by caller, just clear flag - conn->flags_.batch_first_message = false; - to_add = calculated_size; - } else { - // Batch message second or later - // Reserve for full message, resize to include footer gap + header padding + payload - to_add = total_calculated_size; - } - - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - // Return total size (header + payload + footer) - return static_cast(total_calculated_size); +// Noinline version for cold paths — single shared copy +uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { + return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size); } bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE); @@ -2173,17 +2137,15 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items "MessageInfo must remain trivially destructible with this placement-new approach"); const size_t messages_to_process = std::min(num_items, MAX_MESSAGES_PER_BATCH); - const uint8_t frame_overhead = header_padding + footer_size; // Stack-allocated array for message info alignas(MessageInfo) char message_info_storage[MAX_MESSAGES_PER_BATCH * sizeof(MessageInfo)]; MessageInfo *message_info = reinterpret_cast(message_info_storage); size_t items_processed = 0; uint16_t remaining_size = std::numeric_limits::max(); - // Track where each message's header padding begins in the buffer - // For plaintext: this is where the 6-byte header padding starts - // For noise: this is where the 7-byte header padding starts - // The actual message data follows after the header padding + // Track where each message's header begins in the buffer + // First message: offset 0 (max padding, may have unused leading bytes) + // Subsequent messages: offset points to exact header start (no gaps) uint32_t current_offset = 0; // Process items and encode directly to buffer (up to our limit) @@ -2199,13 +2161,14 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items } // Message was encoded successfully - // payload_size is header_padding + actual payload size + footer_size - uint16_t proto_payload_size = payload_size - frame_overhead; + // payload_size = header_size + proto_payload_size + footer_size + uint16_t proto_payload_size = payload_size - this->batch_header_size_ - footer_size; // Use placement new to construct MessageInfo in pre-allocated stack array // This avoids default-constructing all MAX_MESSAGES_PER_BATCH elements // Explicit destruction is not needed because MessageInfo is trivially destructible, // as ensured by the static_assert in its definition. - new (&message_info[items_processed++]) MessageInfo(item.message_type, current_offset, proto_payload_size); + new (&message_info[items_processed++]) + MessageInfo(item.message_type, current_offset, proto_payload_size, this->batch_header_size_); // After first message, set remaining size to MAX_BATCH_PACKET_SIZE to avoid fragmentation if (items_processed == 1) { remaining_size = MAX_BATCH_PACKET_SIZE; @@ -2255,6 +2218,7 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item, uint32_t remaining_size, bool batch_first) { this->flags_.batch_first_message = batch_first; + this->batch_message_type_ = item.message_type; #ifdef USE_EVENT // Events need aux_data_index to look up event type from entity if (item.message_type == EventResponse::MESSAGE_TYPE) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4be5a73e81..f227dbe2de 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -411,16 +411,59 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Non-template buffer management for batch encoding - static uint16_t encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, - APIConnection *conn, uint32_t remaining_size); + // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. + // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. + static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, + uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); - // Thin template wrapper — computes size, delegates buffer work to non-template helper + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; + } + + // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). + // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. + static uint16_t encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size); + + // Thin template wrapper — uses noinline encode_to_buffer_slow since + // encode_message_to_buffer callers are cold paths (zero-payload control messages). + // Hot paths (state/info) go through fill_and_encode_entity_state/info instead. + // batch_message_type_ is already set by dispatch_message_ before reaching here. template static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) { if constexpr (T::ESTIMATED_SIZE == 0) { - return encode_to_buffer(0, &encode_msg_noop, &msg, conn, remaining_size); + return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size); } else { - return encode_to_buffer(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); + return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); } } @@ -735,9 +778,14 @@ class APIConnection final : public APIServerConnectionBase { // 2-byte types immediately after flags_ (no padding between them) uint16_t client_api_version_major_{0}; uint16_t client_api_version_minor_{0}; - // 1-byte type to fill padding + // 1-byte types to fill remaining space before next 4-byte boundary ActiveIterator active_iterator_{ActiveIterator::NONE}; - // Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary + uint8_t batch_message_type_{0}; // Current message type during batch encoding + // Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary + + // Actual header size used by encode_to_buffer for the current message. + // Read by process_batch_multi_ to pass into MessageInfo. + uint8_t batch_header_size_{0}; uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } // Message will use 8 more bytes than the minimum size, and typical diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 6d3bd51b58..90353b6402 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,10 +100,17 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } +#ifdef HELPER_LOG_PACKETS +void APIFrameHelper::log_packet_sending_(const void *data, uint16_t len) { + LOG_PACKET_SENDING(reinterpret_cast(data), len); +} +#endif + APIError APIFrameHelper::drain_overflow_and_handle_errors_() { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } @@ -111,45 +118,58 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. -// Returns OK if all data was sent or successfully queued. -// Returns SOCKET_WRITE_FAILED on hard error (sets state to FAILED). -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { +// Single-buffer write path: wraps in iovec and delegates. +APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t sent) { + struct iovec iov = {const_cast(data), len}; + APIError err = this->write_raw_iov_(&iov, 1, len, sent); #ifdef HELPER_LOG_PACKETS - for (int i = 0; i < iovcnt; i++) { - LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); - } + // Log after write/enqueue so re-entrant log sends can't corrupt data before it's sent + if (err == APIError::OK) + LOG_PACKET_SENDING(reinterpret_cast(data), len); #endif + return err; +} - uint16_t skip = 0; - - // Drain any existing backlog first - if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; - } - - // If backlog is clear, try direct send - if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = - (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); - - if (sent == -1) [[unlikely]] { +// Handles partial writes, errors, and overflow buffering. +// Called when the inline fast path couldn't complete the write, +// or directly from cold paths (handshake, error handling). +APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { + if (sent <= 0) { + if (sent == WRITE_NOT_ATTEMPTED) { + // Cold path: no write attempted yet, drain overflow and try + if (!this->overflow_buf_.empty()) { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; + } + if (this->overflow_buf_.empty()) { + sent = this->write_iov_to_socket_(iov, iovcnt); + if (sent == static_cast(total_write_len)) + return APIError::OK; + // Partial write or -1: fall through to error check / enqueue below + } else { + // Overflow backlog remains after drain; skip socket write, enqueue everything + sent = 0; + } + } + // WRITE_FAILED (-1): fast path or retry write returned -1, check errno + if (sent == WRITE_FAILED) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } - } else if (static_cast(sent) >= total_write_len) [[likely]] { - return APIError::OK; - } else { - skip = static_cast(sent); + sent = 0; // Treat WOULD_BLOCK as zero bytes sent } } + // Full write completed (possible when called directly, not via write_raw_fast_buf_) + if (sent == static_cast(total_write_len)) + return APIError::OK; + // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { HELPER_LOG("Overflow buffer full, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 72ccf8aa56..d1215388d2 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -49,12 +49,17 @@ struct ReadPacketBuffer { }; // Packed message info structure to minimize memory usage +// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits. +// The noise wire format encodes types as 16-bit, but the high byte is always 0. +// If message types ever exceed 255, this and encrypt_noise_message_ must be updated. struct MessageInfo { uint16_t offset; // Offset in buffer where message starts uint16_t payload_size; // Size of the message payload uint8_t message_type; // Message type (0-255) + uint8_t header_size; // Actual header size used (avoids recomputation in write path) - MessageInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} + MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr) + : offset(off), payload_size(size), message_type(type), header_size(hdr) {} }; enum class APIError : uint16_t { @@ -161,20 +166,33 @@ class APIFrameHelper { this->nodelay_counter_ = 0; } } - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - // Resize buffer to include footer space if needed (e.g. Noise MAC) - if (frame_footer_size_) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - MessageInfo msg{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); - } - // Write multiple protobuf messages in a single operation - // messages contains (message_type, offset, length) for each message in the buffer - // The buffer contains all messages with appropriate padding before each + // Write a single protobuf message - the hot path (87-100% of all writes). + // Caller must ensure state is DATA before calling. + virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + // Write multiple protobuf messages in a single batched operation. + // Caller must ensure state is DATA and messages is not empty. + // messages contains (message_type, offset, length) for each message in the buffer. + // The buffer contains all messages with appropriate padding before each. virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) = 0; - // Get the frame header padding required by this protocol + // Get the maximum frame header padding required by this protocol (worst case) uint8_t frame_header_padding() const { return frame_header_padding_; } + // Get the actual frame header size for a specific message. + // For noise: always returns frame_header_padding_ (fixed 7-byte header). + // For plaintext: computes actual size from varint lengths (3-6 bytes). + // Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC + // footer, plaintext has footer=0). If a protocol with a plaintext footer is ever + // added, this should become a virtual method. + uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + return this->frame_footer_size_ + ? this->frame_header_padding_ + : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); +#elif defined(USE_API_NOISE) + return this->frame_header_padding_; +#else // USE_API_PLAINTEXT only + return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); +#endif + } // Get the frame footer size required by this protocol uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read @@ -196,18 +214,41 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Common implementation for writing raw data to socket - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); + // Sentinel values for the sent parameter in write_raw_ methods + static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1 + static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet - // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). - // Returns WOULD_BLOCK for transient errors, SOCKET_WRITE_FAILED for hard errors. - APIError check_socket_write_err_(int err) { - if (err == EWOULDBLOCK || err == EAGAIN) - return APIError::WOULD_BLOCK; - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; + // Dispatch to write() or writev() based on iovec count + inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) { + return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); } + // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) + // These inline the fast path (overflow empty + full write) and tail-call the out-of-line + // slow path only on failure/partial write. + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) { + if (this->overflow_buf_.empty()) [[likely]] { + ssize_t sent = this->socket_->write(data, len); + if (sent == static_cast(len)) [[likely]] { +#ifdef HELPER_LOG_PACKETS + this->log_packet_sending_(data, len); +#endif + return APIError::OK; + } + // sent is -1 (WRITE_FAILED) or partial write count + return this->write_raw_buf_(data, len, sent); + } + return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED); + } + // Out-of-line write paths: handle partial writes, errors, overflow buffering + // sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write) + APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED); + APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, + ssize_t sent = WRITE_NOT_ATTEMPTED); +#ifdef HELPER_LOG_PACKETS + void log_packet_sending_(const void *data, uint16_t len); +#endif + // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 162f4ef605..6dba64a7f8 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -47,15 +47,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; format_hex_pretty_to(hex_buf_, (buffer).data(), \ (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ } while (0) -#define LOG_PACKET_SENDING(data, len) \ - do { \ - char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ - ESP_LOGVV(TAG, "Sending raw: %s", \ - format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ - } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) -#define LOG_PACKET_SENDING(data, len) ((void) 0) #endif /// Convert a noise error code to a readable error @@ -464,65 +457,83 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); +// Encrypt a single noise message in place and return the encrypted frame length. +// Returns APIError::OK on success. +APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + uint16_t &encrypted_len_out) { + // Write noise header + buf_start[0] = 0x01; // indicator + // buf_start[1], buf_start[2] to be set after encryption + + // Write message header (to be encrypted) + constexpr uint8_t msg_offset = 3; + buf_start[msg_offset] = static_cast(message_type >> 8); // type high byte + buf_start[msg_offset + 1] = static_cast(message_type); // type low byte + buf_start[msg_offset + 2] = static_cast(payload_size >> 8); // data_len high byte + buf_start[msg_offset + 3] = static_cast(payload_size); // data_len low byte + // payload data is already in the buffer starting at offset + 7 + + // Encrypt the message in place + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + payload_size, 4 + payload_size + this->frame_footer_size_); + + int err = noise_cipherstate_encrypt(this->send_cipher_, &mbuf); + APIError aerr = + this->handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); if (aerr != APIError::OK) return aerr; - if (messages.empty()) { - return APIError::OK; - } + // Fill in the encrypted size + buf_start[1] = static_cast(mbuf.size >> 8); + buf_start[2] = static_cast(mbuf.size); + encrypted_len_out = static_cast(3 + mbuf.size); // indicator + size + encrypted data + return APIError::OK; +} + +APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif + + // Resize buffer to include footer space for Noise MAC + if (this->frame_footer_size_) + buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_); + + uint16_t payload_size = + static_cast(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_); + uint8_t *buf_start = buffer.get_buffer()->data(); + uint16_t encrypted_len; + APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len); + if (aerr != APIError::OK) + return aerr; + return this->write_raw_fast_buf_(buf_start, encrypted_len); +} + +APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); + assert(!messages.empty()); +#endif + + // Noise messages are already contiguous in the buffer: + // HEADER_PADDING (7) exactly matches the fixed header size, and + // footer space (16) is consumed by the encryption MAC. uint8_t *buffer_data = buffer.get_buffer()->data(); - - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; + uint8_t *write_start = buffer_data + messages[0].offset; uint16_t total_write_len = 0; - // We need to encrypt each message in place for (const auto &msg : messages) { - // The buffer already has padding at offset uint8_t *buf_start = buffer_data + msg.offset; - - // Write noise header - buf_start[0] = 0x01; // indicator - // buf_start[1], buf_start[2] to be set after encryption - - // Write message header (to be encrypted) - constexpr uint8_t msg_offset = 3; - buf_start[msg_offset] = static_cast(msg.message_type >> 8); // type high byte - buf_start[msg_offset + 1] = static_cast(msg.message_type); // type low byte - buf_start[msg_offset + 2] = static_cast(msg.payload_size >> 8); // data_len high byte - buf_start[msg_offset + 3] = static_cast(msg.payload_size); // data_len low byte - // payload data is already in the buffer starting at offset + 7 - - // Make sure we have space for MAC - // The buffer should already have been sized appropriately - - // Encrypt the message in place - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size, - 4 + msg.payload_size + frame_footer_size_); - - int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); - APIError aerr = - handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); + uint16_t encrypted_len; + APIError aerr = this->encrypt_noise_message_(buf_start, msg.payload_size, msg.message_type, encrypted_len); if (aerr != APIError::OK) return aerr; - - // Fill in the encrypted size - buf_start[1] = static_cast(mbuf.size >> 8); - buf_start[2] = static_cast(mbuf.size); - - // Add iovec for this encrypted message - size_t msg_len = static_cast(3 + mbuf.size); // indicator + size + encrypted data - iovs.push_back({buf_start, msg_len}); - total_write_len += msg_len; + total_write_len += encrypted_len; } - // Send all encrypted messages in one writev call - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_buf_(write_start, total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { @@ -531,16 +542,16 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; + if (len == 0) { + return this->write_raw_buf_(header, 3); + } struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; - if (len == 0) { - return this->write_raw_(iov, 1, 3); // Just header - } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len); // Header + data + return this->write_raw_iov_(iov, 2, 3 + len); } /** Initiate the data structures for the handshake. @@ -606,7 +617,7 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { if (aerr != APIError::OK) return aerr; - frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); + this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); HELPER_LOG("Handshake complete!"); noise_handshakestate_free(handshake_); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index f44bde0755..0676eab78d 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -9,19 +9,22 @@ namespace esphome::api { class APINoiseFrameHelper final : public APIFrameHelper { public: + // Noise header structure: + // Pos 0: indicator (0x01) + // Pos 1-2: encrypted payload size (16-bit big-endian) + // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) + // Pos 7+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len + APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx) : APIFrameHelper(std::move(socket)), ctx_(ctx) { - // Noise header structure: - // Pos 0: indicator (0x01) - // Pos 1-2: encrypted payload size (16-bit big-endian) - // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) - // Pos 7+: actual payload data - frame_header_padding_ = 7; + frame_header_padding_ = HEADER_PADDING; } ~APINoiseFrameHelper() override; APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: @@ -33,6 +36,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_handshake_write_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); + APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + uint16_t &encrypted_len_out); APIError init_handshake_(); APIError check_handshake_finished_(); void send_explicit_handshake_reject_(const LogString *reason); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9e669b31ee..fa611a6e33 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -39,15 +39,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; format_hex_pretty_to(hex_buf_, (buffer).data(), \ (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ } while (0) -#define LOG_PACKET_SENDING(data, len) \ - do { \ - char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ - ESP_LOGVV(TAG, "Sending raw: %s", \ - format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ - } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) -#define LOG_PACKET_SENDING(data, len) ((void) 0) #endif /// Initialize the frame helper, returns OK if successful. @@ -205,7 +198,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Make sure to tell the remote that we don't // understand the indicator byte so it knows // we do not support it. - struct iovec iov[1]; // The \x00 first byte is the marker for plaintext. // // The remote will know how to handle the indicator byte, @@ -220,14 +212,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - iov[0].iov_base = (void *) msg; + this->write_raw_buf_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - iov[0].iov_base = (void *) MSG; + this->write_raw_buf_(MSG, INDICATOR_MSG_SIZE); #endif - iov[0].iov_len = INDICATOR_MSG_SIZE; - this->write_raw_(iov, 1, INDICATOR_MSG_SIZE); } return aerr; } @@ -237,73 +227,101 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = this->rx_header_parsed_type_; return APIError::OK; } + +// Encode a 16-bit varint (1-3 bytes) using pre-computed length. +ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_t varint_len, uint8_t *p) { + if (varint_len >= 2) { + *p++ = static_cast(value | 0x80); + value >>= 7; + if (varint_len == 3) { + *p++ = static_cast(value | 0x80); + value >>= 7; + } + } + *p = static_cast(value); +} + +// Encode an 8-bit varint (1-2 bytes) using pre-computed length. +ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) { + if (varint_len == 2) { + *p++ = static_cast(value | 0x80); + *p = static_cast(value >> 7); + } else { + *p = value; + } +} + +// Write plaintext header into pre-allocated padding before payload. +// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg, +// actual header size for contiguous batch messages). +// Returns the total header length (indicator + varints). +ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size, + uint8_t message_type, uint8_t padding_size) { + uint8_t size_varint_len = ProtoSize::varint16(payload_size); + uint8_t type_varint_len = ProtoSize::varint8(message_type); + uint8_t total_header_len = 1 + size_varint_len + type_varint_len; + + // The header is right-justified within the padding so it sits immediately before payload. + // + // Single/first message (padding_size = HEADER_PADDING = 6): + // Example (small, header=3): [0-2] unused | [3] 0x00 | [4] size | [5] type | [6...] payload + // Example (medium, header=4): [0-1] unused | [2] 0x00 | [3-4] size | [5] type | [6...] payload + // Example (large, header=6): [0] 0x00 | [1-3] size | [4-5] type | [6...] payload + // + // Batch messages 2+ (padding_size = actual header size, no unused bytes): + // Example (small, header=3): [0] 0x00 | [1] size | [2] type | [3...] payload + // Example (medium, header=4): [0] 0x00 | [1-2] size | [3] type | [4...] payload +#ifdef ESPHOME_DEBUG_API + assert(padding_size >= total_header_len); +#endif + uint32_t header_offset = padding_size - total_header_len; + + // Write the plaintext header + buf_start[header_offset] = 0x00; // indicator + + // Encode varints directly into buffer using pre-computed lengths + encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1); + encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); + + return total_header_len; +} + +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif + + uint16_t payload_size = static_cast(buffer.get_buffer()->size() - HEADER_PADDING); + uint8_t *buffer_data = buffer.get_buffer()->data(); + uint8_t header_len = write_plaintext_header(buffer_data, payload_size, type, HEADER_PADDING); + return this->write_raw_fast_buf_(buffer_data + HEADER_PADDING - header_len, + static_cast(header_len + payload_size)); +} + APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; - - if (messages.empty()) { - return APIError::OK; - } - +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); + assert(!messages.empty()); +#endif uint8_t *buffer_data = buffer.get_buffer()->data(); - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; - uint16_t total_write_len = 0; + // First message has max padding (header_size = HEADER_PADDING), may have unused leading bytes. + // Subsequent messages were encoded with exact header sizes (header_size = actual header len). + // write_plaintext_header right-justifies the header within header_size bytes of padding. + const auto &first = messages[0]; + uint8_t *first_start = buffer_data + first.offset; + uint8_t header_len = write_plaintext_header(first_start, first.payload_size, first.message_type, HEADER_PADDING); + uint8_t *write_start = first_start + HEADER_PADDING - header_len; + uint16_t total_len = header_len + first.payload_size; - for (const auto &msg : messages) { - // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead - uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE - ? 1 - : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); - uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; - uint8_t total_header_len = 1 + size_varint_len + type_varint_len; - - // Calculate where to start writing the header - // The header starts at the latest possible position to minimize unused padding - // - // Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3 - // [0-2] - Unused padding - // [3] - 0x00 indicator byte - // [4] - Payload size varint (1 byte, for sizes 0-127) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2 - // [0-1] - Unused padding - // [2] - 0x00 indicator byte - // [3-4] - Payload size varint (2 bytes, for sizes 128-16383) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 - // [0] - 0x00 indicator byte - // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) - // [4-5] - Message type varint (2 bytes, for types 128-16383) - // [6...] - Actual payload data - // - // The message starts at offset + frame_header_padding_ - // So we write the header starting at offset + frame_header_padding_ - total_header_len - uint8_t *buf_start = buffer_data + msg.offset; - uint32_t header_offset = frame_header_padding_ - total_header_len; - - // Write the plaintext header - buf_start[header_offset] = 0x00; // indicator - - // Encode varints directly into buffer - encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); - encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); - - // Add iovec for this message (header + payload) - size_t msg_len = static_cast(total_header_len + msg.payload_size); - iovs.push_back({buf_start + header_offset, msg_len}); - total_write_len += msg_len; + for (size_t i = 1; i < messages.size(); i++) { + const auto &msg = messages[i]; + header_len = write_plaintext_header(buffer_data + msg.offset, msg.payload_size, msg.message_type, msg.header_size); + total_len += header_len + msg.payload_size; } - // Send all messages in one writev call - return write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_buf_(write_start, total_len); } } // namespace esphome::api diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index f8161c039d..8314754715 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -7,18 +7,21 @@ namespace esphome::api { class APIPlaintextFrameHelper final : public APIFrameHelper { public: + // Plaintext header structure (worst case): + // Pos 0: indicator (0x00) + // Pos 1-3: payload size varint (up to 3 bytes) + // Pos 4-5: message type varint (up to 2 bytes) + // Pos 6+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint + explicit APIPlaintextFrameHelper(std::unique_ptr socket) : APIFrameHelper(std::move(socket)) { - // Plaintext header structure (worst case): - // Pos 0: indicator (0x00) - // Pos 1-3: payload size varint (up to 3 bytes) - // Pos 4-5: message type varint (up to 2 bytes) - // Pos 6+: actual payload data - frame_header_padding_ = 6; + frame_header_padding_ = HEADER_PADDING; } ~APIPlaintextFrameHelper() override = default; APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index ff7c5232b6..e0a4e03189 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -645,6 +645,17 @@ class ProtoSize { static constexpr uint32_t VARINT_THRESHOLD_3_BYTE = 1 << 21; // 2097152 static constexpr uint32_t VARINT_THRESHOLD_4_BYTE = 1 << 28; // 268435456 + // Varint encoded length for a 16-bit value (1, 2, or 3 bytes). + // Fully inline — no slow path call for values >= 128. + static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint16(uint16_t value) { + return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3); + } + + // Varint encoded length for an 8-bit value (1 or 2 bytes). + static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) { + return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2; + } + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 0caa50c748..74c640a093 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -75,7 +75,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { buffer.clear(); - MessageInfo messages[5] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}}; + MessageInfo messages[5] = {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}; for (int j = 0; j < 5; j++) { uint16_t offset = buffer.size(); @@ -89,7 +89,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { ProtoWriteBuffer writer(&buffer, offset + padding); msg.encode(writer); - messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size); + messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size, padding); } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); From d4cce142c5352e94772777dfeaf668fbc1533cb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 11:11:31 -1000 Subject: [PATCH 13/18] [api] Fix batch messages stuck in Nagle buffer (#15581) --- esphome/components/api/api_connection.cpp | 33 ++++++++++++++++------- esphome/components/api/api_connection.h | 1 + 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 17605345fd..7db423141c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -315,6 +315,8 @@ void APIConnection::process_active_iterator_() { this->destroy_active_iterator_(); if (this->flags_.state_subscription) { this->begin_iterator_(ActiveIterator::INITIAL_STATE); + } else { + this->finalize_iterator_sync_(); } } else { this->process_iterator_batch_(this->iterator_storage_.list_entities); @@ -322,21 +324,27 @@ void APIConnection::process_active_iterator_() { } else { // INITIAL_STATE if (this->iterator_storage_.initial_state.completed()) { this->destroy_active_iterator_(); - // Process any remaining batched messages immediately - if (!this->deferred_batch_.empty()) { - this->process_batch_(); - } - // Now that everything is sent, enable immediate sending for future state changes - this->flags_.should_try_send_immediately = true; - // Release excess memory from buffers that grew during initial sync - this->deferred_batch_.release_buffer(); - this->helper_->release_buffers(); + this->finalize_iterator_sync_(); } else { this->process_iterator_batch_(this->iterator_storage_.initial_state); } } } +void APIConnection::finalize_iterator_sync_() { + // Flush any remaining batched messages immediately so clients + // receive completion responses (e.g. ListEntitiesDoneResponse) + // without waiting for the batch timer. + if (!this->deferred_batch_.empty()) { + this->process_batch_(); + } + // Enable immediate sending for future state changes + this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); +} + void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); size_t max_batch = this->get_max_batch_size_(); @@ -2185,6 +2193,13 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items shared_buf.resize(shared_buf.size() + footer_size); } + // Ensure TCP_NODELAY is on before writing batch data. + // Log messages enable Nagle (NODELAY off) to coalesce small packets. + // Without this, batch data written to the socket sits in LWIP's Nagle + // buffer — the remote won't ACK until it sends its own data (e.g. a + // ping), which can take 20+ seconds. + this->helper_->set_nodelay_for_message(false); + // Send all collected messages APIError err = this->helper_->write_protobuf_messages(ProtoWriteBuffer{&shared_buf}, std::span(message_info, items_processed)); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f227dbe2de..284c4475de 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -662,6 +662,7 @@ class APIConnection final : public APIServerConnectionBase { // Helper methods for iterator lifecycle management void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); + void finalize_iterator_sync_(); #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif From faa05031a71508cc2690d971bf5bbda3224522ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 12:25:29 -1000 Subject: [PATCH 14/18] [climate] Store custom mode vectors on Climate entity to eliminate heap allocation (#15206) --- .../bedjet/climate/bedjet_climate.cpp | 9 ++ .../bedjet/climate/bedjet_climate.h | 11 +-- esphome/components/climate/climate.cpp | 21 +++- esphome/components/climate/climate.h | 47 ++++++++- esphome/components/climate/climate_traits.cpp | 27 ++++++ esphome/components/climate/climate_traits.h | 90 ++++++++++++----- esphome/components/demo/demo_climate.h | 18 +++- esphome/components/midea/ac_adapter.cpp | 4 +- esphome/components/midea/air_conditioner.cpp | 27 +++++- esphome/components/midea/air_conditioner.h | 7 +- .../thermostat/thermostat_climate.cpp | 17 ++-- .../legacy_climate_component/__init__.py | 4 + .../climate/__init__.py | 16 ++++ .../climate/legacy_climate.h | 55 +++++++++++ .../fixtures/legacy_climate_compat.yaml | 18 ++++ .../integration/test_legacy_climate_compat.py | 96 +++++++++++++++++++ 16 files changed, 404 insertions(+), 63 deletions(-) create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h create mode 100644 tests/integration/fixtures/legacy_climate_compat.yaml create mode 100644 tests/integration/test_legacy_climate_compat.py diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index a17407f08f..88ed902a11 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -61,6 +61,15 @@ void BedJetClimate::dump_config() { } void BedJetClimate::setup() { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); + this->set_supported_custom_presets({ + this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", + "M1", + "M2", + "M3", + }); + // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 05f4a849e0..d12c2a8255 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -42,21 +42,14 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli climate::CLIMATE_MODE_DRY, }); - // It would be better if we had a slider for the fan modes. - traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); traits.set_supported_presets({ // If we support NONE, then have to decide what happens if the user switches to it (turn off?) // climate::CLIMATE_PRESET_NONE, // Climate doesn't have a "TURBO" mode, but we can use the BOOST preset instead. climate::CLIMATE_PRESET_BOOST, }); - // String literals are stored in rodata and valid for program lifetime - traits.set_supported_custom_presets({ - this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", - "M1", - "M2", - "M3", - }); + // Custom fan modes and presets are set once in setup(), stored on Climate base class, + // and wired automatically via get_traits() traits.set_visual_min_temperature(19.0); traits.set_visual_max_temperature(43.0); traits.set_visual_temperature_step(1.0); diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 32cac0961c..e132497140 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -484,6 +484,11 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); + // Wire custom mode pointers from Climate-owned storage + if (this->supported_custom_fan_modes_) + traits.set_supported_custom_fan_modes_(this->supported_custom_fan_modes_); + if (this->supported_custom_presets_) + traits.set_supported_custom_presets_(this->supported_custom_presets_); #ifdef USE_CLIMATE_VISUAL_OVERRIDES if (!std::isnan(this->visual_min_temperature_override_)) { traits.set_visual_min_temperature(this->visual_min_temperature_override_); @@ -681,9 +686,8 @@ bool Climate::set_fan_mode_(ClimateFanMode mode) { } bool Climate::set_custom_fan_mode_(const char *mode, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_fan_mode_, this->fan_mode, - traits.find_custom_fan_mode_(mode, len), this->has_custom_fan_mode()); + return set_custom_mode(this->custom_fan_mode_, this->fan_mode, this->find_custom_fan_mode_(mode, len), + this->has_custom_fan_mode()); } void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } @@ -691,8 +695,7 @@ void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } bool Climate::set_preset_(ClimatePreset preset) { return set_primary_mode(this->preset, this->custom_preset_, preset); } bool Climate::set_custom_preset_(const char *preset, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_preset_, this->preset, traits.find_custom_preset_(preset, len), + return set_custom_mode(this->custom_preset_, this->preset, this->find_custom_preset_(preset, len), this->has_custom_preset()); } @@ -703,6 +706,10 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } @@ -711,6 +718,10 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly return this->get_traits().find_custom_preset_(custom_preset, len); } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 0251365dd8..04f653a2b0 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" @@ -234,6 +235,28 @@ class Climate : public EntityBase { void set_visual_max_humidity_override(float visual_max_humidity_override); #endif + /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end()); + } + void set_supported_custom_fan_modes(const std::vector &modes) { + this->ensure_custom_fan_modes_() = modes; + } + template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->ensure_custom_fan_modes_().assign(modes, modes + N); + } + + /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_presets(std::initializer_list presets) { + this->ensure_custom_presets_().assign(presets.begin(), presets.end()); + } + void set_supported_custom_presets(const std::vector &presets) { + this->ensure_custom_presets_() = presets; + } + template void set_supported_custom_presets(const char *const (&presets)[N]) { + this->ensure_custom_presets_().assign(presets, presets + N); + } + /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -336,13 +359,14 @@ class Climate : public EntityBase { * called from publish_state() */ void save_state_(const ClimateTraits &traits); - void save_state_() { this->save_state_(this->traits()); } + void save_state_() { this->save_state_(this->get_traits()); } void dump_traits_(const char *tag); LazyCallbackManager state_callback_{}; LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; + #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; float visual_max_temperature_override_{NAN}; @@ -353,16 +377,33 @@ class Climate : public EntityBase { #endif private: + /// Lazy-allocate custom mode vectors (never freed — entity lives forever). + std::vector &ensure_custom_fan_modes_() { + if (!this->supported_custom_fan_modes_) { + this->supported_custom_fan_modes_ = new std::vector(); // NOLINT + } + return *this->supported_custom_fan_modes_; + } + std::vector &ensure_custom_presets_() { + if (!this->supported_custom_presets_) { + this->supported_custom_presets_ = new std::vector(); // NOLINT + } + return *this->supported_custom_presets_; + } + + std::vector *supported_custom_fan_modes_{nullptr}; + std::vector *supported_custom_presets_{nullptr}; + /** The active custom fan mode (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_fan_modes_ or nullptr. + * Points to an entry in supported_custom_fan_modes_ or nullptr. * Use get_custom_fan_mode() to read, set_custom_fan_mode_() to modify. */ const char *custom_fan_mode_{nullptr}; /** The active custom preset (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_presets_ or nullptr. + * Points to an entry in supported_custom_presets_ or nullptr. * Use get_custom_preset() to read, set_custom_preset_() to modify. */ const char *custom_preset_{nullptr}; diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 9bf2d9acd3..398e25f69e 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -2,6 +2,33 @@ namespace esphome::climate { +// Compat: shared empty vector for getters when no custom modes are set. +// Remove in 2026.11.0 when deprecated ClimateTraits setters are removed +// and getters can return const vector * instead of const vector &. +static const std::vector EMPTY_CUSTOM_MODES; // NOLINT + +const std::vector &ClimateTraits::get_supported_custom_fan_modes() const { + if (this->supported_custom_fan_modes_) { + return *this->supported_custom_fan_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_fan_modes_.empty()) { + return this->compat_custom_fan_modes_; + } + return EMPTY_CUSTOM_MODES; +} + +const std::vector &ClimateTraits::get_supported_custom_presets() const { + if (this->supported_custom_presets_) { + return *this->supported_custom_presets_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_presets_.empty()) { + return this->compat_custom_presets_; + } + return EMPTY_CUSTOM_MODES; +} + int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_target_temperature_step_); } diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 80ef0854d5..082b2127a9 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -147,27 +147,45 @@ class ClimateTraits { void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { - return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); + if (!this->supported_fan_modes_.empty()) { + return true; + } + // Same precedence as get_supported_custom_fan_modes() getter + if (this->supported_custom_fan_modes_) { + return !this->supported_custom_fan_modes_->empty(); + } + return !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0 } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { - this->supported_custom_fan_modes_ = modes; + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). + this->compat_custom_fan_modes_ = modes; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { - this->supported_custom_fan_modes_ = modes; + this->compat_custom_fan_modes_ = modes; } - template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - this->supported_custom_fan_modes_.assign(modes, modes + N); + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->compat_custom_fan_modes_.assign(modes, modes + N); } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_fan_modes(const std::vector &modes) = delete; void set_supported_custom_fan_modes(std::initializer_list modes) = delete; - const std::vector &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_fan_modes() const; bool supports_custom_fan_mode(const char *custom_fan_mode) const { - return vector_contains(this->supported_custom_fan_modes_, custom_fan_mode); + return (this->supported_custom_fan_modes_ && + vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode)) || + vector_contains(this->compat_custom_fan_modes_, custom_fan_mode); // Compat: remove in 2026.11.0 } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supports_custom_fan_mode(custom_fan_mode.c_str()); @@ -179,23 +197,32 @@ class ClimateTraits { bool get_supports_presets() const { return !this->supported_presets_.empty(); } const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { - this->supported_custom_presets_ = presets; + this->compat_custom_presets_ = presets; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { - this->supported_custom_presets_ = presets; + this->compat_custom_presets_ = presets; } - template void set_supported_custom_presets(const char *const (&presets)[N]) { - this->supported_custom_presets_.assign(presets, presets + N); + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_presets(const char *const (&presets)[N]) { + this->compat_custom_presets_.assign(presets, presets + N); } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_presets(const std::vector &presets) = delete; void set_supported_custom_presets(std::initializer_list presets) = delete; - const std::vector &get_supported_custom_presets() const { return this->supported_custom_presets_; } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_presets() const; bool supports_custom_preset(const char *custom_preset) const { - return vector_contains(this->supported_custom_presets_, custom_preset); + return (this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset)) || + vector_contains(this->compat_custom_presets_, custom_preset); // Compat: remove in 2026.11.0 } bool supports_custom_preset(const std::string &custom_preset) const { return this->supports_custom_preset(custom_preset.c_str()); @@ -258,13 +285,25 @@ class ClimateTraits { } } + /// Set custom mode pointers (only Climate::get_traits() should call these). + void set_supported_custom_fan_modes_(const std::vector *modes) { + this->supported_custom_fan_modes_ = modes; + } + void set_supported_custom_presets_(const std::vector *presets) { + this->supported_custom_presets_ = presets; + } + /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); } const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found @@ -273,7 +312,11 @@ class ClimateTraits { return this->find_custom_preset_(custom_preset, strlen(custom_preset)); } const char *find_custom_preset_(const char *custom_preset, size_t len) const { - return vector_find(this->supported_custom_presets_, custom_preset, len); + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0}; @@ -289,16 +332,17 @@ class ClimateTraits { climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; - /** Custom mode storage using const char* pointers to eliminate std::string overhead. + /** Custom mode storage - pointers to vectors owned by the Climate base class. * - * Pointers must remain valid for the ClimateTraits lifetime. Safe patterns: - * - String literals: set_supported_custom_fan_modes({"Turbo", "Silent"}) - * - Static const data: static const char* MODE = "Eco"; - * - * Climate class setters validate pointers are from these vectors before storing. + * ClimateTraits does not own this data; Climate stores the vectors and + * get_traits() wires these pointers automatically. */ - std::vector supported_custom_fan_modes_; - std::vector supported_custom_presets_; + const std::vector *supported_custom_fan_modes_{nullptr}; + const std::vector *supported_custom_presets_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). + // Remove in 2026.11.0. + std::vector compat_custom_fan_modes_; + std::vector compat_custom_presets_; }; } // namespace esphome::climate diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index c5f07ac114..c6d328b1bc 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -16,6 +16,19 @@ class DemoClimate : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + switch (type_) { + case DemoClimateType::TYPE_1: + break; + case DemoClimateType::TYPE_2: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + this->set_supported_custom_presets({"My Preset"}); + break; + case DemoClimateType::TYPE_3: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + break; + } + // Set initial state switch (type_) { case DemoClimateType::TYPE_1: this->current_temperature = 20.0; @@ -105,14 +118,13 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_FAN_DIFFUSE, climate::CLIMATE_FAN_QUIET, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes and presets are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, }); - traits.set_supported_custom_presets({"My Preset"}); break; case DemoClimateType::TYPE_3: traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | @@ -123,7 +135,7 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index d903db4a1b..8b20a562c8 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -168,8 +168,8 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_BOOST); if (capabilities.supportEcoPreset()) traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_ECO); - if (capabilities.supportFrostProtectionPreset()) - traits.set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + // Frost protection custom preset is handled by AirConditioner directly + // since custom presets are stored on the Climate base class } } // namespace ac diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 4d59a4fbbc..50521cf238 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -24,6 +24,25 @@ template void update_property(T &property, const T &value, bool &fla } void AirConditioner::on_status_change() { + // Add frost protection custom preset once when autoconf completes + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && + this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { + // Read existing presets (set by codegen), append frost protection, write back + const auto &existing = this->get_traits().get_supported_custom_presets(); + bool found = false; + for (const char *p : existing) { + if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { + found = true; + break; + } + } + if (!found) { + std::vector merged(existing.begin(), existing.end()); + merged.push_back(Constants::FREEZE_PROTECTION); + this->set_supported_custom_presets(merged); + } + this->frost_protection_set_ = true; + } bool need_publish = false; update_property(this->target_temperature, this->base_.getTargetTemp(), need_publish); update_property(this->current_temperature, this->base_.getIndoorTemp(), need_publish); @@ -91,17 +110,15 @@ ClimateTraits AirConditioner::traits() { traits.set_supported_modes(this->supported_modes_); traits.set_supported_swing_modes(this->supported_swing_modes_); traits.set_supported_presets(this->supported_presets_); - if (!this->supported_custom_presets_.empty()) - traits.set_supported_custom_presets(this->supported_custom_presets_); - if (!this->supported_custom_fan_modes_.empty()) - traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + // Custom fan modes and presets are stored on Climate base class and wired via get_traits() /* + MINIMAL SET OF CAPABILITIES */ traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_AUTO); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_LOW); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_MEDIUM); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_HIGH); - if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { Converters::to_climate_traits(traits, this->base_.getCapabilities()); + } if (!traits.get_supported_modes().empty()) traits.add_supported_mode(ClimateMode::CLIMATE_MODE_OFF); if (!traits.get_supported_swing_modes().empty()) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 70833b8bcc..8dbc71b422 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -46,8 +46,8 @@ class AirConditioner : public ApplianceBase, void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } - void set_custom_presets(std::initializer_list presets) { this->supported_custom_presets_ = presets; } - void set_custom_fan_modes(std::initializer_list modes) { this->supported_custom_fan_modes_ = modes; } + void set_custom_presets(std::initializer_list presets) { this->set_supported_custom_presets(presets); } + void set_custom_fan_modes(std::initializer_list modes) { this->set_supported_custom_fan_modes(modes); } protected: void control(const ClimateCall &call) override; @@ -55,8 +55,7 @@ class AirConditioner : public ApplianceBase, ClimateModeMask supported_modes_{}; ClimateSwingModeMask supported_swing_modes_{}; ClimatePresetMask supported_presets_{}; - std::vector supported_custom_presets_{}; - std::vector supported_custom_fan_modes_{}; + bool frost_protection_set_{false}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d979359c1f..d8478d2648 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -333,15 +333,7 @@ climate::ClimateTraits ThermostatClimate::traits() { traits.add_supported_preset(entry.preset); } - // Extract custom preset names from the custom_preset_config_ vector - if (!this->custom_preset_config_.empty()) { - std::vector custom_preset_names; - custom_preset_names.reserve(this->custom_preset_config_.size()); - for (const auto &entry : this->custom_preset_config_) { - custom_preset_names.push_back(entry.name); - } - traits.set_supported_custom_presets(custom_preset_names); - } + // Custom presets are stored on Climate base class and wired via get_traits() return traits; } @@ -1306,6 +1298,13 @@ void ThermostatClimate::set_preset_config(std::initializer_list pre void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; + // Populate Climate base class custom presets vector + std::vector names; + names.reserve(presets.size()); + for (const auto &entry : this->custom_preset_config_) { + names.push_back(entry.name); + } + this->set_supported_custom_presets(names); } ThermostatClimate::ThermostatClimate() = default; diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py new file mode 100644 index 0000000000..ba9eff2d89 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py @@ -0,0 +1,4 @@ +"""Legacy climate component — tests deprecated ClimateTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py new file mode 100644 index 0000000000..0810ae02a1 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py @@ -0,0 +1,16 @@ +"""Legacy climate platform that uses deprecated ClimateTraits setters.""" + +import esphome.codegen as cg +from esphome.components import climate +import esphome.config_validation as cv +from esphome.types import ConfigType + +legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test") +LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component) + +CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h new file mode 100644 index 0000000000..bdf5179fa5 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/core/component.h" + +namespace esphome::legacy_climate_test { + +/// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyClimate : public climate::Climate, public Component { + public: + void setup() override { + this->mode = climate::CLIMATE_MODE_OFF; + this->target_temperature = 22.0f; + this->current_temperature = 20.0f; + this->publish_state(); + } + + protected: + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_COOL}); + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(30.0f); + traits.set_visual_temperature_step(0.5f); + + // DEPRECATED API: setting custom modes directly on ClimateTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_custom_fan_modes({"Turbo", "Silent", "Auto"}); + traits.set_supported_custom_presets({"Eco Mode", "Night Mode"}); +#pragma GCC diagnostic pop + + return traits; + } + + void control(const climate::ClimateCall &call) override { + if (call.get_mode().has_value()) { + this->mode = *call.get_mode(); + } + if (call.get_target_temperature().has_value()) { + this->target_temperature = *call.get_target_temperature(); + } + if (call.has_custom_fan_mode()) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } + if (call.has_custom_preset()) { + this->set_custom_preset_(call.get_custom_preset()); + } + this->publish_state(); + } +}; + +} // namespace esphome::legacy_climate_test diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml new file mode 100644 index 0000000000..112e50a468 --- /dev/null +++ b/tests/integration/fixtures/legacy_climate_compat.yaml @@ -0,0 +1,18 @@ +esphome: + name: legacy-climate-compat + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_climate_component] + +climate: + - platform: legacy_climate_component + name: "Legacy Climate" + id: legacy_climate diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py new file mode 100644 index 0000000000..aad71dd04a --- /dev/null +++ b/tests/integration/test_legacy_climate_compat.py @@ -0,0 +1,96 @@ +"""Integration test for backward compatibility of deprecated ClimateTraits setters. + +Verifies that external components using the old traits.set_supported_custom_fan_modes() +and traits.set_supported_custom_presets() API still work correctly during the +deprecation period. + +Remove this entire test file and the legacy_climate_component external component +in 2026.11.0 when the deprecated ClimateTraits setters are removed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import aioesphomeapi +from aioesphomeapi import ClimateInfo +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_climate_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated ClimateTraits custom mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, ( + f"Expected 1 climate entity, got {len(climate_infos)}" + ) + + test_climate = climate_infos[0] + + # Verify custom fan modes set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_fan_modes) == { + "Turbo", + "Silent", + "Auto", + }, ( + f"Expected custom fan modes {{Turbo, Silent, Auto}}, " + f"got {test_climate.supported_custom_fan_modes}" + ) + + # Verify custom presets set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_presets) == { + "Eco Mode", + "Night Mode", + }, ( + f"Expected custom presets {{Eco Mode, Night Mode}}, " + f"got {test_climate.supported_custom_presets}" + ) + + # Set up state tracking with InitialStateHelper + turbo_future: asyncio.Future[aioesphomeapi.ClimateState] = loop.create_future() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, aioesphomeapi.ClimateState) + and state.custom_fan_mode == "Turbo" + and not turbo_future.done() + ): + turbo_future.set_result(state) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify we can set a custom fan mode via API (tests find_custom_fan_mode_ compat path) + client.climate_command(test_climate.key, custom_fan_mode="Turbo") + + try: + turbo_state = await asyncio.wait_for(turbo_future, timeout=5.0) + except TimeoutError: + pytest.fail("Custom fan mode 'Turbo' not received within 5 seconds") + + assert turbo_state.custom_fan_mode == "Turbo" From 8e02d0a20e0c46ecc578b2d5d405f406cb599068 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 12:25:37 -1000 Subject: [PATCH 15/18] [fan] Store preset mode vector on Fan entity to eliminate heap allocation (#15209) --- esphome/components/copy/fan/copy_fan.cpp | 9 +- esphome/components/fan/fan.cpp | 55 +++++++++++-- esphome/components/fan/fan.h | 24 ++++++ esphome/components/fan/fan_traits.h | 49 ++++++++--- .../components/hbridge/fan/hbridge_fan.cpp | 1 - esphome/components/hbridge/fan/hbridge_fan.h | 8 +- esphome/components/speed/fan/speed_fan.cpp | 1 - esphome/components/speed/fan/speed_fan.h | 8 +- .../components/template/fan/template_fan.cpp | 1 - .../components/template/fan/template_fan.h | 8 +- .../legacy_fan_component/__init__.py | 4 + .../legacy_fan_component/fan/__init__.py | 16 ++++ .../legacy_fan_component/fan/legacy_fan.h | 45 ++++++++++ .../fixtures/legacy_fan_compat.yaml | 18 ++++ tests/integration/test_legacy_fan_compat.py | 82 +++++++++++++++++++ 15 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h create mode 100644 tests/integration/fixtures/legacy_fan_compat.yaml create mode 100644 tests/integration/test_legacy_fan_compat.py diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 14c600d71f..bdaa35c467 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -7,6 +7,12 @@ namespace copy { static const char *const TAG = "copy.fan"; void CopyFan::setup() { + // Copy preset modes once from source fan — stored on Fan base class + auto source_traits = source_->get_traits(); + if (source_traits.supports_preset_modes()) { + this->set_supported_preset_modes(source_traits.supported_preset_modes()); + } + source_->add_on_state_callback([this]() { this->copy_state_from_source_(); this->publish_state(); @@ -39,7 +45,8 @@ fan::FanTraits CopyFan::get_traits() { traits.set_speed(base.supports_speed()); traits.set_supported_speed_count(base.supported_speed_count()); traits.set_direction(base.supports_direction()); - traits.set_supported_preset_modes(base.supported_preset_modes()); + // Preset modes are set once in setup() and wired via wire_preset_modes_() + this->wire_preset_modes_(traits); return traits; } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index dc7a75018c..9301e0cea4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -9,6 +9,22 @@ namespace fan { static const char *const TAG = "fan"; +// Compat: shared empty vector for getter when no preset modes are set. +// Remove in 2026.11.0 when deprecated FanTraits setters are removed +// and getter can return const vector * instead of const vector &. +static const std::vector EMPTY_PRESET_MODES; // NOLINT + +const std::vector &FanTraits::supported_preset_modes() const { + if (this->preset_modes_) { + return *this->preset_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0 (change return to const vector *). + if (!this->compat_preset_modes_.empty()) { + return this->compat_preset_modes_; + } + return EMPTY_PRESET_MODES; +} + // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN"); @@ -148,6 +164,18 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { + if (preset_mode == nullptr || len == 0) { + return nullptr; + } + if (this->supported_preset_modes_) { + for (const char *mode : *this->supported_preset_modes_) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { + return mode; + } + } + return nullptr; + } + // Fallback for deprecated path: external components may set modes on FanTraits directly return this->get_traits().find_preset_mode(preset_mode, len); } @@ -261,8 +289,6 @@ void Fan::save_state_() { return; } - auto traits = this->get_traits(); - FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; @@ -271,12 +297,25 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - const auto &preset_modes = traits.supported_preset_modes(); - // Find index of current preset mode (pointer comparison is safe since preset is from traits) - for (size_t i = 0; i < preset_modes.size(); i++) { - if (preset_modes[i] == this->preset_mode_) { - state.preset_mode = i; - break; + if (this->supported_preset_modes_) { + // New path: search Fan-owned vector directly + for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { + if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } + } + } else { + // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. + // Pointer comparison works because preset_mode_ and the compat vector both + // hold pointers to string literals in .rodata (stable addresses). + auto traits = this->get_traits(); + const auto &preset_modes = traits.supported_preset_modes(); + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } } } } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e7b3681e32..d5763edf2f 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -130,6 +130,14 @@ class Fan : public EntityBase { virtual FanTraits get_traits() = 0; + /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->ensure_preset_modes_().assign(preset_modes.begin(), preset_modes.end()); + } + void set_supported_preset_modes(const std::vector &preset_modes) { + this->ensure_preset_modes_() = preset_modes; + } + /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -167,11 +175,27 @@ class Fan : public EntityBase { const char *find_preset_mode_(const char *preset_mode); const char *find_preset_mode_(const char *preset_mode, size_t len); + /// Wire the Fan-owned preset modes pointer into the given traits object. + void wire_preset_modes_(FanTraits &traits) { + if (this->supported_preset_modes_) { + traits.set_supported_preset_modes_(this->supported_preset_modes_); + } + } + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; private: + /// Lazy-allocate preset modes vector (never freed — entity lives forever). + std::vector &ensure_preset_modes_() { + if (!this->supported_preset_modes_) { + this->supported_preset_modes_ = new std::vector(); // NOLINT + } + return *this->supported_preset_modes_; + } + + std::vector *supported_preset_modes_{nullptr}; const char *preset_mode_{nullptr}; }; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index c0c5f34c50..a2b2633af1 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -3,12 +3,17 @@ #include #include #include +#include "esphome/core/helpers.h" namespace esphome { namespace fan { +class Fan; // Forward declaration + class FanTraits { + friend class Fan; // Allow Fan to access protected pointer setter + public: FanTraits() = default; FanTraits(bool oscillation, bool speed, bool direction, int speed_count) @@ -30,42 +35,64 @@ class FanTraits { bool supports_direction() const { return this->direction_; } /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } - /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { return this->preset_modes_; } - /// Set the preset modes supported by the fan (from initializer list). + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &supported_preset_modes() const; + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - this->preset_modes_ = preset_modes; + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). + this->compat_preset_modes_ = preset_modes; + } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_preset_modes(const std::vector &preset_modes) { + this->compat_preset_modes_ = preset_modes; } - /// Set the preset modes supported by the fan (from vector). - void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_preset_modes(const std::vector &preset_modes) = delete; void set_supported_preset_modes(std::initializer_list preset_modes) = delete; /// Return if preset modes are supported - bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + bool supports_preset_modes() const { + // Same precedence as supported_preset_modes() getter + if (this->preset_modes_) { + return !this->preset_modes_->empty(); + } + return !this->compat_preset_modes_.empty(); + } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); } const char *find_preset_mode(const char *preset_mode, size_t len) const { - if (preset_mode == nullptr || len == 0) + if (preset_mode == nullptr || len == 0) { return nullptr; - for (const char *mode : this->preset_modes_) { + } + // Check pointer-based storage (new path) then compat owned vector (deprecated path) + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->compat_preset_modes_; + for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { - return mode; // Return pointer from traits + return mode; } } return nullptr; } protected: + /// Set the preset modes pointer (only Fan::wire_preset_modes_() should call this). + void set_supported_preset_modes_(const std::vector *preset_modes) { + this->preset_modes_ = preset_modes; + } + bool oscillation_{false}; bool speed_{false}; bool direction_{false}; int speed_count_{}; - std::vector preset_modes_{}; + const std::vector *preset_modes_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). + // Remove in 2026.11.0. + std::vector compat_preset_modes_; }; } // namespace fan diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 89c162eebf..d548128b99 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -30,7 +30,6 @@ fan::FanCall HBridgeFan::brake() { void HBridgeFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index ec1e8ada0e..997f66ae48 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -20,11 +20,14 @@ class HBridgeFan : public Component, public fan::Fan { void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } - void set_preset_modes(std::initializer_list presets) { preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } void setup() override; void dump_config() override; - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } fan::FanCall brake(); @@ -36,7 +39,6 @@ class HBridgeFan : public Component, public fan::Fan { int speed_count_{}; DecayMode decay_mode_{DECAY_MODE_SLOW}; fan::FanTraits traits_; - std::vector preset_modes_{}; void control(const fan::FanCall &call) override; void write_state_(); diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index d45237c467..eaa8a55858 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -9,7 +9,6 @@ static const char *const TAG = "speed.fan"; void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index e9a389e0f3..db96039a13 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -16,8 +16,11 @@ class SpeedFan : public Component, public fan::Fan { void set_output(output::FloatOutput *output) { this->output_ = output; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -28,7 +31,6 @@ class SpeedFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; int speed_count_{}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace speed diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 46a5cba9bb..431be84654 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -9,7 +9,6 @@ void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index b7e1d4ab5a..5ab6ae8c65 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -13,8 +13,11 @@ class TemplateFan final : public Component, public fan::Fan { void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -23,7 +26,6 @@ class TemplateFan final : public Component, public fan::Fan { bool has_direction_{false}; int speed_count_{0}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace esphome::template_ diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py new file mode 100644 index 0000000000..714be181fe --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -0,0 +1,4 @@ +"""Legacy fan component — tests deprecated FanTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py new file mode 100644 index 0000000000..985d97d081 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -0,0 +1,16 @@ +"""Legacy fan platform that uses deprecated FanTraits setters.""" + +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") +LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = fan.fan_schema(LegacyFan).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h new file mode 100644 index 0000000000..ac378b59c5 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "esphome/core/component.h" + +namespace esphome::legacy_fan_test { + +/// Test fan that uses the DEPRECATED FanTraits setters for preset modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyFan : public fan::Fan, public Component { + public: + void setup() override { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } + this->publish_state(); + } + + fan::FanTraits get_traits() override { + auto traits = fan::FanTraits(false, true, false, 3); + + // DEPRECATED API: setting preset modes directly on FanTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_preset_modes({"Turbo", "Silent", "Eco"}); +#pragma GCC diagnostic pop + + return traits; + } + + protected: + void control(const fan::FanCall &call) override { + if (call.get_state().has_value()) { + this->state = *call.get_state(); + } + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + this->apply_preset_mode_(call); + this->publish_state(); + } +}; + +} // namespace esphome::legacy_fan_test diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml new file mode 100644 index 0000000000..256fd4e4c1 --- /dev/null +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -0,0 +1,18 @@ +esphome: + name: legacy-fan-compat + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_fan_component] + +fan: + - platform: legacy_fan_component + name: "Legacy Fan" + id: legacy_fan diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py new file mode 100644 index 0000000000..5ee41772f3 --- /dev/null +++ b/tests/integration/test_legacy_fan_compat.py @@ -0,0 +1,82 @@ +"""Integration test for backward compatibility of deprecated FanTraits setters. + +Verifies that external components using the old traits.set_supported_preset_modes() +API still work correctly during the deprecation period. + +Remove this entire test file and the legacy_fan_component external component +in 2026.11.0 when the deprecated FanTraits setters are removed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_fan_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated FanTraits preset mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + fan_infos = [e for e in entities if isinstance(e, FanInfo)] + assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" + + test_fan = fan_infos[0] + + # Verify preset modes set via deprecated FanTraits setter are exposed + assert set(test_fan.supported_preset_modes) == { + "Turbo", + "Silent", + "Eco", + }, ( + f"Expected preset modes {{Turbo, Silent, Eco}}, " + f"got {test_fan.supported_preset_modes}" + ) + + # Verify speed support + assert test_fan.supports_speed is True + assert test_fan.supported_speed_count == 3 + + # Subscribe and wait for initial states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Wait for initial state + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + # Turn on fan with preset mode (tests find_preset_mode_ compat path) + state_event.clear() + client.fan_command( + key=test_fan.key, + state=True, + preset_mode="Turbo", + ) + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + fan_state = states[test_fan.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Turbo" From 14ec82084b026b752f838247bdfce2310fbb17a2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:35:09 +1000 Subject: [PATCH 16/18] [rpi_dpi_rgb][qspi_dbi] Add deprecation warnings (#15583) --- esphome/components/qspi_dbi/__init__.py | 5 +++++ esphome/components/qspi_dbi/display.py | 6 ++++++ esphome/components/rpi_dpi_rgb/__init__.py | 5 +++++ esphome/components/rpi_dpi_rgb/display.py | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index 290a864335..aebbe2fcc8 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -1,3 +1,8 @@ CODEOWNERS = ["@clydebarrow"] CONF_DRAW_FROM_ORIGIN = "draw_from_origin" + +DEPRECATED_COMPONENT = """ +The 'qspi_dbi' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 595067e94c..48cd72ecdf 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -29,6 +31,7 @@ from . import CONF_DRAW_FROM_ORIGIN from .models import DriverChip DEPENDENCIES = ["spi"] +LOGGER = logging.getLogger(__name__) qspi_dbi_ns = cg.esphome_ns.namespace("qspi_dbi") QSPI_DBI = qspi_dbi_ns.class_( @@ -160,6 +163,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + LOGGER.warning( + "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index c58ce8a01e..8628f0e063 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -1 +1,6 @@ CODEOWNERS = ["@clydebarrow"] + +DEPRECATED_COMPONENT = """ +The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_rgb' component. +""" diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index ee462686e4..314852832c 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display @@ -38,6 +40,7 @@ from esphome.const import ( ) DEPENDENCIES = ["esp32"] +LOGGER = logging.getLogger(__name__) rpi_dpi_rgb_ns = cg.esphome_ns.namespace("rpi_dpi_rgb") RPI_DPI_RGB = rpi_dpi_rgb_ns.class_("RpiDpiRgb", display.Display, cg.Component) @@ -126,6 +129,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + LOGGER.warning( + "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 0a8130858c347b4f0b9ebce9c032dbe9d8139ee4 Mon Sep 17 00:00:00 2001 From: Angel Nunez Mencias Date: Wed, 8 Apr 2026 23:57:53 +0100 Subject: [PATCH 17/18] [ade7953_spi] Fix SPI mode on esp-idf (#14824) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/ade7953_base/ade7953_base.cpp | 10 ++++++- .../components/ade7953_base/ade7953_base.h | 30 +++++++++++-------- .../components/ade7953_spi/ade7953_spi.cpp | 6 ++++ esphome/components/ade7953_spi/ade7953_spi.h | 2 +- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 821e4a3105..2dfab8ff85 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -8,6 +8,9 @@ namespace ade7953_base { static const char *const TAG = "ade7953"; +constexpr uint16_t CONFIG_DEFAULT = 0x8004u; +constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u; + static const float ADE_POWER_FACTOR = 154.0f; static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600; @@ -18,7 +21,12 @@ void ADE7953::setup() { // The chip might take up to 100ms to initialise this->set_timeout(100, [this]() { - // this->ade_write_8(0x0010, 0x04); + // Lock communication interface (SPI or I2C) + uint16_t config_v = CONFIG_DEFAULT; + this->ade_read_16(CONFIG_16, &config_v); + config_v &= static_cast(~CONFIG_LOCK_BIT); // Clear the lock bit + this->ade_write_16(CONFIG_16, config_v); + // Configure optimum settings according to datasheet this->ade_write_8(0x00FE, 0xAD); this->ade_write_16(0x0120, 0x0030); // Set gains diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index bcafddca4e..b58f95b230 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -9,31 +9,35 @@ namespace esphome { namespace ade7953_base { -static const uint8_t PGA_V_8 = +static constexpr uint8_t PGA_V_8 = 0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0]) -static const uint8_t PGA_IA_8 = +static constexpr uint8_t PGA_IA_8 = 0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0]) -static const uint8_t PGA_IB_8 = +static constexpr uint8_t PGA_IB_8 = 0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0]) -static const uint32_t AIGAIN_32 = +static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register + +static constexpr uint16_t AIGAIN_32 = 0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit) -static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t AWGAIN_32 = +static constexpr uint16_t AVGAIN_32 = + 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t AWGAIN_32 = 0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit) -static const uint32_t AVARGAIN_32 = +static constexpr uint16_t AVARGAIN_32 = 0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit) -static const uint32_t AVAGAIN_32 = +static constexpr uint16_t AVAGAIN_32 = 0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit) -static const uint32_t BIGAIN_32 = +static constexpr uint16_t BIGAIN_32 = 0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit) -static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t BWGAIN_32 = +static constexpr uint16_t BVGAIN_32 = + 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t BWGAIN_32 = 0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit) -static const uint32_t BVARGAIN_32 = +static constexpr uint16_t BVARGAIN_32 = 0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit) -static const uint32_t BVAGAIN_32 = +static constexpr uint16_t BVAGAIN_32 = 0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit) class ADE7953 : public PollingComponent, public sensor::Sensor { diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index 6b16d933a2..a69b7d19fb 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -7,6 +7,9 @@ namespace ade7953_spi { static const char *const TAG = "ade7953"; +// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS +constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2; + void AdE7953Spi::setup() { this->spi_setup(); ade7953_base::ADE7953::setup(); @@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) { this->write_byte16(reg); this->transfer_byte(0); this->write_byte16(value); + if (reg == ade7953_base::CONFIG_16) { + delayMicroseconds(CONFIG_LOCK_SETTLE_US); + } this->disable(); return false; } diff --git a/esphome/components/ade7953_spi/ade7953_spi.h b/esphome/components/ade7953_spi/ade7953_spi.h index d96852b9bb..27f6025d98 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.h +++ b/esphome/components/ade7953_spi/ade7953_spi.h @@ -12,7 +12,7 @@ namespace esphome { namespace ade7953_spi { class AdE7953Spi : public ade7953_base::ADE7953, - public spi::SPIDevice { public: void setup() override; From 76490e45bc3c164d4ffe5111a7f61b8af1f66b90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 13:08:29 -1000 Subject: [PATCH 18/18] [ci] Fix status-check-labels workflow flooding CI queue (#15585) --- .github/workflows/status-check-labels.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index cca70815b9..6483bbe789 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -2,30 +2,29 @@ name: Status check labels on: pull_request: - types: [labeled, unlabeled] + types: [opened, reopened, labeled, unlabeled, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: check: - name: Check ${{ matrix.label }} + name: Check blocking labels runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - label: - - needs-docs - - merge-after-release - - chained-pr steps: - - name: Check for ${{ matrix.label }} label + - name: Check for blocking labels uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | + const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); - const hasLabel = labels.find(label => label.name === '${{ matrix.label }}'); - if (hasLabel) { - core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}'); + const labelNames = labels.map(l => l.name); + const found = blockingLabels.filter(bl => labelNames.includes(bl)); + if (found.length > 0) { + core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`); }