From 0d809a748102808a2e03fe69a9206d5162f6326e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 10:09:27 -1000 Subject: [PATCH 01/25] [automation] Add CallbackAutomation dataclass and build_callback_automations helper (#15246) --- esphome/automation.py | 33 +++ .../alarm_control_panel/__init__.py | 92 +++++--- esphome/components/binary_sensor/__init__.py | 47 ++-- esphome/components/button/__init__.py | 10 +- esphome/components/dfplayer/__init__.py | 12 +- esphome/components/event/__init__.py | 12 +- esphome/components/ezo/sensor.py | 45 ++-- esphome/components/factory_reset/__init__.py | 17 +- .../components/fingerprint_grow/__init__.py | 77 +++--- esphome/components/haier/climate.py | 41 ++-- esphome/components/hlk_fm22x/__init__.py | 89 ++++--- esphome/components/ld2450/__init__.py | 10 +- esphome/components/lock/__init__.py | 27 ++- esphome/components/ltr501/sensor.py | 19 +- esphome/components/ltr_als_ps/sensor.py | 19 +- esphome/components/media_player/__init__.py | 59 ++++- .../components/modbus_controller/__init__.py | 41 ++-- esphome/components/nextion/display.py | 54 ++--- esphome/components/number/__init__.py | 12 +- esphome/components/online_image/__init__.py | 18 +- esphome/components/pn532/__init__.py | 12 +- esphome/components/pn7150/__init__.py | 20 +- esphome/components/pn7160/__init__.py | 20 +- esphome/components/rf_bridge/__init__.py | 26 +- esphome/components/rotary_encoder/sensor.py | 17 +- esphome/components/rtttl/__init__.py | 12 +- esphome/components/safe_mode/__init__.py | 14 +- esphome/components/sensor/__init__.py | 19 +- esphome/components/sim800l/__init__.py | 49 ++-- esphome/components/sml/__init__.py | 29 ++- esphome/components/switch/__init__.py | 27 ++- esphome/components/text_sensor/__init__.py | 19 +- tests/unit_tests/test_automation.py | 223 +++++++++++++++++- 33 files changed, 794 insertions(+), 427 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 94d64086ec..b4dcc41995 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import logging import esphome.codegen as cg @@ -715,3 +716,35 @@ async def build_callback_automation( # MockObjs (not user input), and there's no Expression type for positional # aggregate initialization (StructInitializer uses named fields). cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) + + +@dataclass(frozen=True, slots=True) +class CallbackAutomation: + """A single callback automation entry for build_callback_automations.""" + + conf_key: str + callback_method: str + args: TemplateArgsType = field(default_factory=list) + forwarder: MockObj | MockObjClass | None = None + + +async def build_callback_automations( + parent: MockObj, + config: ConfigType, + entries: tuple[CallbackAutomation, ...], +) -> None: + """Build multiple callback automations from a tuple of entries. + + :param parent: The component object (e.g., button, sensor). + :param config: The full component config dict. + :param entries: Tuple of CallbackAutomation entries to process. + """ + for entry in entries: + for conf in config.get(entry.conf_key, []): + await build_callback_automation( + parent, + entry.callback_method, + entry.args, + conf, + forwarder=entry.forwarder, + ) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 4ee073a15b..9fcdf42ecb 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -111,42 +111,66 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder + ), + automation.CallbackAutomation( + CONF_ON_TRIGGERED, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_TRIGGERED + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMING, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING), + ), + automation.CallbackAutomation( + CONF_ON_PENDING, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_PENDING + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_HOME, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_HOME + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_NIGHT, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_NIGHT + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_AWAY, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_AWAY + ), + ), + automation.CallbackAutomation( + CONF_ON_DISARMED, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_DISARMED + ), + ), + automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"), + automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"), + automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"), +) + + @setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): - for conf in config.get(CONF_ON_STATE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=StateAnyForwarder - ) - _STATE_ENTER_MAP = { - CONF_ON_TRIGGERED: AlarmControlPanelState.ACP_STATE_TRIGGERED, - CONF_ON_ARMING: AlarmControlPanelState.ACP_STATE_ARMING, - CONF_ON_PENDING: AlarmControlPanelState.ACP_STATE_PENDING, - CONF_ON_ARMED_HOME: AlarmControlPanelState.ACP_STATE_ARMED_HOME, - CONF_ON_ARMED_NIGHT: AlarmControlPanelState.ACP_STATE_ARMED_NIGHT, - CONF_ON_ARMED_AWAY: AlarmControlPanelState.ACP_STATE_ARMED_AWAY, - CONF_ON_DISARMED: AlarmControlPanelState.ACP_STATE_DISARMED, - } - for conf_key, state_enum in _STATE_ENTER_MAP.items(): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, - "add_on_state_callback", - [], - conf, - forwarder=StateEnterForwarder.template(state_enum), - ) - for conf in config.get(CONF_ON_CLEARED, []): - await automation.build_callback_automation( - var, "add_on_cleared_callback", [], conf - ) - for conf in config.get(CONF_ON_CHIME, []): - await automation.build_callback_automation( - var, "add_on_chime_callback", [], conf - ) - for conf in config.get(CONF_ON_READY, []): - await automation.build_callback_automation( - var, "add_on_ready_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) if mqtt_id := config.get(CONF_MQTT_ID): diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index d8cdaa5d58..0b36c299f6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -531,16 +531,31 @@ def binary_sensor_schema( return _BINARY_SENSOR_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PRESS, + "add_on_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_RELEASE, + "add_on_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(bool, "x")] + ), + automation.CallbackAutomation( + CONF_ON_STATE_CHANGE, + "add_full_state_callback", + [(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")], + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_binary_sensor_automations(var, config): - for conf_key, forwarder in ( - (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), - (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( @@ -572,22 +587,6 @@ async def _build_binary_sensor_automations(var, config): await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_STATE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [(bool, "x")], conf - ) - - for conf in config.get(CONF_ON_STATE_CHANGE, []): - await automation.build_callback_automation( - var, - "add_full_state_callback", - [ - (cg.optional.template(bool), "x_previous"), - (cg.optional.template(bool), "x"), - ], - conf, - ) - @setup_entity("binary_sensor") async def setup_binary_sensor_core_(var, config): diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index f279b6ffe3..2c19ea69b1 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -79,12 +79,14 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"), +) + + @setup_entity("button") async def setup_button_core_(var, config): - for conf in config.get(CONF_ON_PRESS, []): - await automation.build_callback_automation( - var, "add_on_press_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index adc1913791..7796f5d891 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -64,15 +64,19 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - await automation.build_callback_automation( - var, "add_on_finished_playback_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 527bb4ebba..9c9dd025b1 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -82,12 +82,16 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EVENT, "add_on_event_callback", [(cg.StringRef, "event_type")] + ), +) + + @setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): - for conf in config.get(CONF_ON_EVENT, []): - await automation.build_callback_automation( - var, "add_on_event_callback", [(cg.StringRef, "event_type")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) diff --git a/esphome/components/ezo/sensor.py b/esphome/components/ezo/sensor.py index 7c81f9c848..b931885149 100644 --- a/esphome/components/ezo/sensor.py +++ b/esphome/components/ezo/sensor.py @@ -38,33 +38,30 @@ CONFIG_SCHEMA = ( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CUSTOM, "add_custom_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation(CONF_ON_LED, "add_led_state_callback", [(bool, "x")]), + automation.CallbackAutomation( + CONF_ON_DEVICE_INFORMATION, + "add_device_infomation_callback", + [(cg.std_string, "x")], + ), + automation.CallbackAutomation( + CONF_ON_SLOPE, "add_slope_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation( + CONF_ON_CALIBRATION, "add_calibration_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation(CONF_ON_T, "add_t_callback", [(cg.std_string, "x")]), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) await i2c.register_i2c_device(var, config) - for conf in config.get(CONF_ON_CUSTOM, []): - await automation.build_callback_automation( - var, "add_custom_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_LED, []): - await automation.build_callback_automation( - var, "add_led_state_callback", [(bool, "x")], conf - ) - for conf in config.get(CONF_ON_DEVICE_INFORMATION, []): - await automation.build_callback_automation( - var, "add_device_infomation_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_SLOPE, []): - await automation.build_callback_automation( - var, "add_slope_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_CALIBRATION, []): - await automation.build_callback_automation( - var, "add_calibration_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_T, []): - await automation.build_callback_automation( - var, "add_t_callback", [(cg.std_string, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 20b191a2b7..818a53c0ed 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -73,6 +73,15 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_INCREMENT, + "add_increment_callback", + [(cg.uint8, "x"), (cg.uint8, "target")], + ), +) + + async def to_code(config): if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( @@ -81,10 +90,4 @@ async def to_code(config): config[CONF_MAX_DELAY].total_seconds, ) await cg.register_component(var, config) - for conf in config.get(CONF_ON_INCREMENT, []): - await automation.build_callback_automation( - var, - "add_increment_callback", - [(cg.uint8, "x"), (cg.uint8, "target")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0b01ba7cab..8d935a3c9e 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -116,6 +116,44 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_START, "add_on_finger_scan_start_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_MATCHED, + "add_on_finger_scan_matched_callback", + [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_UNMATCHED, + "add_on_finger_scan_unmatched_callback", + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_MISPLACED, + "add_on_finger_scan_misplaced_callback", + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_INVALID, "add_on_finger_scan_invalid_callback" + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_SCAN, + "add_on_enrollment_scan_callback", + [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_DONE, + "add_on_enrollment_done_callback", + [(cg.uint16, "finger_id")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_FAILED, + "add_on_enrollment_failed_callback", + [(cg.uint16, "finger_id")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -140,44 +178,7 @@ async def to_code(config): idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP] cg.add(var.set_idle_period_to_sleep_ms(idle_period_to_sleep_ms)) - for conf in config.get(CONF_ON_FINGER_SCAN_START, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_start_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_MATCHED, []): - await automation.build_callback_automation( - var, - "add_on_finger_scan_matched_callback", - [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], - conf, - ) - for conf in config.get(CONF_ON_FINGER_SCAN_UNMATCHED, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_unmatched_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_MISPLACED, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_misplaced_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_INVALID, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_invalid_callback", [], conf - ) - for conf in config.get(CONF_ON_ENROLLMENT_SCAN, []): - await automation.build_callback_automation( - var, - "add_on_enrollment_scan_callback", - [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], - conf, - ) - for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - await automation.build_callback_automation( - var, "add_on_enrollment_done_callback", [(cg.uint16, "finger_id")], conf - ) - for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - await automation.build_callback_automation( - var, "add_on_enrollment_failed_callback", [(cg.uint16, "finger_id")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 9c2c999f25..d485c1d5d4 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -456,6 +456,25 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_ALARM_START, + "add_alarm_start_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + ), + automation.CallbackAutomation( + CONF_ON_ALARM_END, + "add_alarm_end_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + ), + automation.CallbackAutomation( + CONF_ON_STATUS_MESSAGE, + "add_status_message_callback", + [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], + ), +) + + async def to_code(config): cg.add(haier_ns.init_haier_protocol_logging()) var = await climate.new_climate(config) @@ -497,26 +516,6 @@ async def to_code(config): cg.add( var.set_status_message_header_size(config[CONF_STATUS_MESSAGE_HEADER_SIZE]) ) - for conf in config.get(CONF_ON_ALARM_START, []): - await automation.build_callback_automation( - var, - "add_alarm_start_callback", - [(cg.uint8, "code"), (cg.const_char_ptr, "message")], - conf, - ) - for conf in config.get(CONF_ON_ALARM_END, []): - await automation.build_callback_automation( - var, - "add_alarm_end_callback", - [(cg.uint8, "code"), (cg.const_char_ptr, "message")], - conf, - ) - for conf in config.get(CONF_ON_STATUS_MESSAGE, []): - await automation.build_callback_automation( - var, - "add_status_message_callback", - [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) # https://github.com/paveldn/HaierProtocol cg.add_library("pavlodn/HaierProtocol", "0.9.31") diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index c0349319d1..8f55d5dc08 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -52,58 +52,53 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_MATCHED, + "add_on_face_scan_matched_callback", + [(cg.int16, "face_id"), (cg.std_string, "name")], + ), + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_UNMATCHED, "add_on_face_scan_unmatched_callback" + ), + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_INVALID, + "add_on_face_scan_invalid_callback", + [(cg.uint8, "error")], + ), + automation.CallbackAutomation( + CONF_ON_FACE_INFO, + "add_on_face_info_callback", + [ + (cg.int16, "status"), + (cg.int16, "left"), + (cg.int16, "top"), + (cg.int16, "right"), + (cg.int16, "bottom"), + (cg.int16, "yaw"), + (cg.int16, "pitch"), + (cg.int16, "roll"), + ], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_DONE, + "add_on_enrollment_done_callback", + [(cg.int16, "face_id"), (cg.uint8, "direction")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_FAILED, + "add_on_enrollment_failed_callback", + [(cg.uint8, "error")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []): - await automation.build_callback_automation( - var, - "add_on_face_scan_matched_callback", - [(cg.int16, "face_id"), (cg.std_string, "name")], - conf, - ) - - for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []): - await automation.build_callback_automation( - var, "add_on_face_scan_unmatched_callback", [], conf - ) - - for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []): - await automation.build_callback_automation( - var, "add_on_face_scan_invalid_callback", [(cg.uint8, "error")], conf - ) - - for conf in config.get(CONF_ON_FACE_INFO, []): - await automation.build_callback_automation( - var, - "add_on_face_info_callback", - [ - (cg.int16, "status"), - (cg.int16, "left"), - (cg.int16, "top"), - (cg.int16, "right"), - (cg.int16, "bottom"), - (cg.int16, "yaw"), - (cg.int16, "pitch"), - (cg.int16, "roll"), - ], - conf, - ) - - for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - await automation.build_callback_automation( - var, - "add_on_enrollment_done_callback", - [(cg.int16, "face_id"), (cg.uint8, "direction")], - conf, - ) - - for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - await automation.build_callback_automation( - var, "add_on_enrollment_failed_callback", [(cg.uint8, "error")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 37bf12bafc..585c9f7bf5 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -44,11 +44,13 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_DATA, []): - await automation.build_callback_automation( - var, "add_on_data_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0df4b20cba..1a45896ac1 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -81,20 +81,23 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_LOCK, + "add_on_state_callback", + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + ), + automation.CallbackAutomation( + CONF_ON_UNLOCK, + "add_on_state_callback", + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + ), +) + + @setup_entity("lock") async def _setup_lock_core(var, config): - for conf_key, state_enum in ( - (CONF_ON_LOCK, LockState.LOCK_STATE_LOCKED), - (CONF_ON_UNLOCK, LockState.LOCK_STATE_UNLOCKED), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, - "add_on_state_callback", - [], - conf, - forwarder=LockStateForwarder.template(state_enum), - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index 712810222c..cca9330e76 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -211,6 +211,16 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback" + ), + automation.CallbackAutomation( + CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -240,14 +250,7 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_high_trigger_callback", [], conf - ) - for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_low_trigger_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_ltr_type(config[CONF_TYPE])) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 57503772a1..893415f028 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -201,6 +201,16 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback" + ), + automation.CallbackAutomation( + CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -230,14 +240,7 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_high_trigger_callback", [], conf - ) - for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_low_trigger_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_ltr_type(config[CONF_TYPE])) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 842f620dae..3c2e9029d6 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -69,7 +69,7 @@ StateEnterForwarder = media_player_ns.class_("StateEnterForwarder") MediaPlayerState = media_player_ns.enum("MediaPlayerState") # State triggers: (config_key, state enum or None for any-state) -_STATE_TRIGGERS = [ +_STATE_TRIGGERS = ( (CONF_ON_STATE, None), (CONF_ON_IDLE, MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), (CONF_ON_PLAY, MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), @@ -77,7 +77,7 @@ _STATE_TRIGGERS = [ (CONF_ON_ANNOUNCEMENT, MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), (CONF_ON_TURN_ON, MediaPlayerState.MEDIA_PLAYER_STATE_ON), (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), -] +) # State conditions that all share the same schema and codegen handler _STATE_CONDITIONS = [ @@ -102,17 +102,54 @@ VolumeSetAction = media_player_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder + ), + automation.CallbackAutomation( + CONF_ON_IDLE, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_IDLE + ), + ), + automation.CallbackAutomation( + CONF_ON_PLAY, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING + ), + ), + automation.CallbackAutomation( + CONF_ON_PAUSE, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED + ), + ), + automation.CallbackAutomation( + CONF_ON_ANNOUNCEMENT, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING + ), + ), + automation.CallbackAutomation( + CONF_ON_TURN_ON, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_ON), + ), + automation.CallbackAutomation( + CONF_ON_TURN_OFF, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_OFF), + ), +) + + @setup_entity("media_player") async def setup_media_player_core_(var, config): - for conf_key, state_enum in _STATE_TRIGGERS: - for conf in config.get(conf_key, []): - if state_enum is None: - forwarder = StateAnyForwarder - else: - forwarder = StateEnterForwarder.template(state_enum) - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) async def register_media_player(var, config): diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 9e332425a6..2af58a96be 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -205,6 +205,25 @@ async def add_modbus_base_properties( cg.add(var.set_template(template_)) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_COMMAND_SENT, + "add_on_command_sent_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), + automation.CallbackAutomation( + CONF_ON_ONLINE, + "add_on_online_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), + automation.CallbackAutomation( + CONF_ON_OFFLINE, + "add_on_offline_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) @@ -257,27 +276,7 @@ async def to_code(config): ) cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) - for conf in config.get(CONF_ON_COMMAND_SENT, []): - await automation.build_callback_automation( - var, - "add_on_command_sent_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) - for conf in config.get(CONF_ON_ONLINE, []): - await automation.build_callback_automation( - var, - "add_on_online_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) - for conf in config.get(CONF_ON_OFFLINE, []): - await automation.build_callback_automation( - var, - "add_on_offline_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) async def register_modbus_device(var, config): diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 506eb1202b..e477ab7182 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -144,6 +144,28 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_SETUP, "add_setup_state_callback"), + automation.CallbackAutomation(CONF_ON_SLEEP, "add_sleep_state_callback"), + automation.CallbackAutomation(CONF_ON_WAKE, "add_wake_state_callback"), + automation.CallbackAutomation( + CONF_ON_PAGE, "add_new_page_callback", [(cg.uint8, "x")] + ), + automation.CallbackAutomation( + CONF_ON_TOUCH, + "add_touch_event_callback", + [ + (cg.uint8, "page_id"), + (cg.uint8, "component_id"), + (cg.bool_, "touch_event"), + ], + ), + automation.CallbackAutomation( + CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await uart.register_uart_device(var, config) @@ -232,34 +254,4 @@ async def to_code(config): await display.register_display(var, config) - for conf in config.get(CONF_ON_SETUP, []): - await automation.build_callback_automation( - var, "add_setup_state_callback", [], conf - ) - for conf in config.get(CONF_ON_SLEEP, []): - await automation.build_callback_automation( - var, "add_sleep_state_callback", [], conf - ) - for conf in config.get(CONF_ON_WAKE, []): - await automation.build_callback_automation( - var, "add_wake_state_callback", [], conf - ) - for conf in config.get(CONF_ON_PAGE, []): - await automation.build_callback_automation( - var, "add_new_page_callback", [(cg.uint8, "x")], conf - ) - for conf in config.get(CONF_ON_TOUCH, []): - await automation.build_callback_automation( - var, - "add_touch_event_callback", - [ - (cg.uint8, "page_id"), - (cg.uint8, "component_id"), - (cg.bool_, "touch_event"), - ], - conf, - ) - for conf in config.get(CONF_ON_BUFFER_OVERFLOW, []): - await automation.build_callback_automation( - var, "add_buffer_overflow_event_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 26d2602ba4..a223b346f2 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -243,12 +243,16 @@ def number_schema( return _NUMBER_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_number_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [(float, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 5b8294c70e..518d787d8a 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -105,6 +105,14 @@ async def online_image_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + async def to_code(config): # Use the enhanced helper function to get all runtime image parameters settings = await runtime_image.process_runtime_image_config(config) @@ -139,12 +147,4 @@ async def to_code(config): else: cg.add(var.add_request_header(key, value)) - for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []): - await automation.build_callback_automation( - var, "add_on_finished_callback", [(bool, "cached")], conf - ) - - for conf in config.get(CONF_ON_ERROR, []): - await automation.build_callback_automation( - var, "add_on_error_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 4ccda49a72..f34df21647 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -49,6 +49,13 @@ def CONFIG_SCHEMA(conf): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn532(var, config): await cg.register_component(var, config) @@ -66,10 +73,7 @@ async def setup_pn532(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index c8723dc31c..9dd3e8c5b0 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -164,6 +164,16 @@ async def pn7150_simple_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn7150(var, config): await cg.register_component(var, config) @@ -194,15 +204,7 @@ async def setup_pn7150(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - await automation.build_callback_automation( - var, "add_on_emulated_tag_scan_callback", [], conf - ) - - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index e382594b93..ef14a29099 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -168,6 +168,16 @@ async def pn7160_simple_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn7160(var, config): await cg.register_component(var, config) @@ -206,15 +216,7 @@ async def setup_pn7160(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - await automation.build_callback_automation( - var, "add_on_emulated_tag_scan_callback", [], conf - ) - - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 4ee1e7891f..9ca47fe862 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -67,22 +67,26 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CODE_RECEIVED, + "add_on_code_received_callback", + [(RFBridgeData, "data")], + ), + automation.CallbackAutomation( + CONF_ON_ADVANCED_CODE_RECEIVED, + "add_on_advanced_code_received_callback", + [(RFBridgeAdvancedData, "data")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_CODE_RECEIVED, []): - await automation.build_callback_automation( - var, "add_on_code_received_callback", [(RFBridgeData, "data")], conf - ) - for conf in config.get(CONF_ON_ADVANCED_CODE_RECEIVED, []): - await automation.build_callback_automation( - var, - "add_on_advanced_code_received_callback", - [(RFBridgeAdvancedData, "data")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index d88657e715..20c757f093 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -84,6 +84,14 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_CLOCKWISE, "add_on_clockwise_callback"), + automation.CallbackAutomation( + CONF_ON_ANTICLOCKWISE, "add_on_anticlockwise_callback" + ), +) + + async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -104,14 +112,7 @@ async def to_code(config): if CONF_MAX_VALUE in config: cg.add(var.set_max_value(config[CONF_MAX_VALUE])) - for conf in config.get(CONF_ON_CLOCKWISE, []): - await automation.build_callback_automation( - var, "add_on_clockwise_callback", [], conf - ) - for conf in config.get(CONF_ON_ANTICLOCKWISE, []): - await automation.build_callback_automation( - var, "add_on_anticlockwise_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 638e950ba6..c661aad972 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -71,6 +71,13 @@ FINAL_VALIDATE_SCHEMA = cv.Schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -86,10 +93,7 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - await automation.build_callback_automation( - var, "add_on_finished_playback_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index da36d21eb7..6df0ba78b1 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -65,18 +65,22 @@ async def safe_mode_mark_successful_to_code(config, action_id, template_arg, arg return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_SAFE_MODE, "add_on_safe_mode_callback"), +) + + @coroutine_with_priority(CoroPriority.APPLICATION) async def to_code(config): if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if on_safe_mode_config := config.get(CONF_ON_SAFE_MODE): + if config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") - for conf in on_safe_mode_config: - await automation.build_callback_automation( - var, "add_on_safe_mode_callback", [], conf - ) + await automation.build_callback_automations( + var, config, _CALLBACK_AUTOMATIONS + ) condition = var.should_enter_safe_mode( config[CONF_NUM_ATTEMPTS], diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 275c4542fb..3a54e97f68 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -892,16 +892,19 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] + ), + automation.CallbackAutomation( + CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(float, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_sensor_automations(var, config): - for conf_key, callback in ( - (CONF_ON_VALUE, "add_on_state_callback"), - (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, callback, [(float, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 91771047e1..ae7ee6fa59 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -48,34 +48,37 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_SMS_RECEIVED, + "add_on_sms_received_callback", + [(cg.std_string, "message"), (cg.std_string, "sender")], + ), + automation.CallbackAutomation( + CONF_ON_INCOMING_CALL, + "add_on_incoming_call_callback", + [(cg.std_string, "caller_id")], + ), + automation.CallbackAutomation( + CONF_ON_CALL_CONNECTED, "add_on_call_connected_callback" + ), + automation.CallbackAutomation( + CONF_ON_CALL_DISCONNECTED, "add_on_call_disconnected_callback" + ), + automation.CallbackAutomation( + CONF_ON_USSD_RECEIVED, + "add_on_ussd_received_callback", + [(cg.std_string, "ussd")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_SMS_RECEIVED, []): - await automation.build_callback_automation( - var, - "add_on_sms_received_callback", - [(cg.std_string, "message"), (cg.std_string, "sender")], - conf, - ) - for conf in config.get(CONF_ON_INCOMING_CALL, []): - await automation.build_callback_automation( - var, "add_on_incoming_call_callback", [(cg.std_string, "caller_id")], conf - ) - for conf in config.get(CONF_ON_CALL_CONNECTED, []): - await automation.build_callback_automation( - var, "add_on_call_connected_callback", [], conf - ) - for conf in config.get(CONF_ON_CALL_DISCONNECTED, []): - await automation.build_callback_automation( - var, "add_on_call_disconnected_callback", [], conf - ) - for conf in config.get(CONF_ON_USSD_RECEIVED, []): - await automation.build_callback_automation( - var, "add_on_ussd_received_callback", [(cg.std_string, "ussd")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) SIM800L_SEND_SMS_SCHEMA = cv.Schema( diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 1b7f9da4fb..d25e883fa1 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -31,23 +31,26 @@ CONFIG_SCHEMA = ( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DATA, + "add_on_data_callback", + [ + ( + cg.std_vector.template(cg.uint8).operator("ref").operator("const"), + "bytes", + ), + (cg.bool_, "valid"), + ], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_DATA, []): - await automation.build_callback_automation( - var, - "add_on_data_callback", - [ - ( - cg.std_vector.template(cg.uint8).operator("ref").operator("const"), - "bytes", - ), - (cg.bool_, "valid"), - ], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) def obis_code(value): diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c4dd4856e3..5a63cbfb9f 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -121,17 +121,26 @@ def switch_schema( return _SWITCH_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(bool, "x")] + ), + automation.CallbackAutomation( + CONF_ON_TURN_ON, + "add_on_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_TURN_OFF, + "add_on_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_switch_automations(var, config): - for conf_key, args, forwarder in ( - (CONF_ON_STATE, [(bool, "x")], None), - (CONF_ON_TURN_ON, [], automation.TriggerOnTrueForwarder), - (CONF_ON_TURN_OFF, [], automation.TriggerOnFalseForwarder), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, "add_on_state_callback", args, conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @setup_entity("switch") diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 5b07dd2915..94014e8d20 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -184,16 +184,19 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation( + CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(cg.std_string, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_text_sensor_automations(var, config): - for conf_key, callback in ( - (CONF_ON_VALUE, "add_on_state_callback"), - (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, callback, [(cg.std_string, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @setup_entity("text_sensor") diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 37779f23e6..a377cf185a 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,14 +1,16 @@ """Tests for esphome.automation module.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, call, patch import pytest from esphome.automation import ( + CallbackAutomation, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + build_callback_automations, has_non_synchronous_actions, ) from esphome.cpp_generator import MockObj, RawExpression @@ -254,3 +256,222 @@ def test_trigger_forwarder_custom_type() -> None: custom = MockObj("MyForwarder", "") result = _build_forwarder("auto_1", [], forwarder=custom) assert result == "MyForwarder{auto_1}" + + +@pytest.fixture +def mock_build_callback() -> Generator[AsyncMock]: + """Patch build_callback_automation to capture calls.""" + with patch( + "esphome.automation.build_callback_automation", new_callable=AsyncMock + ) as mock: + yield mock + + +@pytest.mark.asyncio +async def test_build_callback_automations_empty_entries( + mock_build_callback: AsyncMock, +) -> None: + """No entries means no calls.""" + parent = MockObj("var", "->") + await build_callback_automations(parent, {}, ()) + mock_build_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_callback_automations_missing_config_key( + mock_build_callback: AsyncMock, +) -> None: + """Entry present but config key missing -- no calls.""" + parent = MockObj("var", "->") + await build_callback_automations( + parent, + {}, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + mock_build_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_callback_automations_single_entry( + mock_build_callback: AsyncMock, +) -> None: + """Single entry with one config triggers one call.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_state": [conf]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [(bool, "x")], conf, forwarder=None + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_multiple_configs( + mock_build_callback: AsyncMock, +) -> None: + """Single entry with multiple configs triggers multiple calls.""" + parent = MockObj("var", "->") + conf1: dict[str, object] = {"automation_id": "auto_1", "then": []} + conf2: dict[str, object] = {"automation_id": "auto_2", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_state": [conf1, conf2]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + assert mock_build_callback.call_count == 2 + mock_build_callback.assert_any_call( + parent, "add_on_state_callback", [(bool, "x")], conf1, forwarder=None + ) + mock_build_callback.assert_any_call( + parent, "add_on_state_callback", [(bool, "x")], conf2, forwarder=None + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_multiple_entries( + mock_build_callback: AsyncMock, +) -> None: + """Multiple entries each with one config.""" + parent = MockObj("var", "->") + conf_a: dict[str, object] = {"automation_id": "auto_a", "then": []} + conf_b: dict[str, object] = {"automation_id": "auto_b", "then": []} + config: dict[str, list[dict[str, object]]] = { + "on_value": [conf_a], + "on_raw_value": [conf_b], + } + await build_callback_automations( + parent, + config, + ( + CallbackAutomation("on_value", "add_on_value_callback", [(float, "x")]), + CallbackAutomation( + "on_raw_value", "add_on_raw_value_callback", [(float, "x")] + ), + ), + ) + assert mock_build_callback.call_count == 2 + assert mock_build_callback.call_args_list == [ + call(parent, "add_on_value_callback", [(float, "x")], conf_a, forwarder=None), + call( + parent, "add_on_raw_value_callback", [(float, "x")], conf_b, forwarder=None + ), + ] + + +@pytest.mark.asyncio +async def test_build_callback_automations_with_forwarder( + mock_build_callback: AsyncMock, +) -> None: + """Entry with forwarder passes it through.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + ( + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + ), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_mixed_entries( + mock_build_callback: AsyncMock, +) -> None: + """Mix of entries with args, forwarders, and defaults.""" + parent = MockObj("var", "->") + conf_state: dict[str, object] = {"automation_id": "auto_1", "then": []} + conf_press: dict[str, object] = {"automation_id": "auto_2", "then": []} + conf_release: dict[str, object] = {"automation_id": "auto_3", "then": []} + config: dict[str, list[dict[str, object]]] = { + "on_state": [conf_state], + "on_press": [conf_press], + "on_release": [conf_release], + } + await build_callback_automations( + parent, + config, + ( + CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]), + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + CallbackAutomation( + "on_release", "add_on_state_callback", forwarder=TriggerOnFalseForwarder + ), + ), + ) + assert mock_build_callback.call_count == 3 + assert mock_build_callback.call_args_list == [ + call( + parent, "add_on_state_callback", [(bool, "x")], conf_state, forwarder=None + ), + call( + parent, + "add_on_state_callback", + [], + conf_press, + forwarder=TriggerOnTrueForwarder, + ), + call( + parent, + "add_on_state_callback", + [], + conf_release, + forwarder=TriggerOnFalseForwarder, + ), + ] + + +@pytest.mark.asyncio +async def test_build_callback_automations_skips_missing_keys( + mock_build_callback: AsyncMock, +) -> None: + """Entries whose config keys are absent are silently skipped.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + ( + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + CallbackAutomation( + "on_release", "add_on_state_callback", forwarder=TriggerOnFalseForwarder + ), + ), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_defaults( + mock_build_callback: AsyncMock, +) -> None: + """Verify CallbackAutomation with only required fields defaults args=[] and forwarder=None.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_press", "add_on_press_callback"),), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_press_callback", [], conf, forwarder=None + ) From 6460f3a757777f805af3a94d8521db2af837dba1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 10:24:36 -1000 Subject: [PATCH 02/25] [api] Add max_data_length and force to DeviceInfoResponse/HelloResponse proto fields (#15514) --- esphome/components/api/api.proto | 51 +++++++++++------- esphome/components/api/api_connection.cpp | 9 ++++ esphome/components/api/api_pb2.cpp | 60 +++++++++++----------- esphome/components/esp32/__init__.py | 5 +- esphome/components/esp8266/__init__.py | 5 +- esphome/components/libretiny/__init__.py | 5 +- esphome/components/nrf52/__init__.py | 5 +- esphome/components/number/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 5 +- esphome/components/sensor/__init__.py | 2 +- esphome/config_validation.py | 34 +++++++++--- esphome/core/config.py | 20 ++++++-- esphome/core/entity_helpers.py | 15 +++--- tests/unit_tests/test_config_validation.py | 44 +++++++++++++--- 14 files changed, 182 insertions(+), 80 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 07705baff6..33d16f0339 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -129,11 +129,12 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. - // For example "ESPHome v1.10.0 on ESP8266" - string server_info = 3; + // Currently set to ESPHOME_VERSION string literal. + string server_info = 3 [(max_data_length) = 32, (force) = true]; - // The name of the server (App.get_name()) - string name = 4; + // The name of the server (App.get_name() - device hostname) + // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname) + string name = 4 [(max_data_length) = 31, (force) = true]; } // DEPRECATED in ESPHome 2026.1.0 - Password authentication is no longer supported. @@ -196,12 +197,14 @@ message DeviceInfoRequest { message AreaInfo { uint32 area_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; } message DeviceInfo { uint32 device_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; uint32 area_id = 3; } @@ -216,6 +219,16 @@ message SerialProxyInfo { SerialProxyPortType port_type = 2; // Port type (RS232, RS485) } +// DeviceInfoResponse max_data_length values: +// name = 31 (ESPHOME_DEVICE_NAME_MAX_LEN, validated by validate_hostname) +// friendly_name = 120 (core/config.FRIENDLY_NAME_MAX_LEN) +// mac_address/bluetooth_mac_address = 17 (MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1, constexpr) +// esphome_version = 32 (ESPHOME_VERSION string literal) +// compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr) +// manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor") +// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) +// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) +// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -224,28 +237,30 @@ message DeviceInfoResponse { // with older ESPHome versions that still send this field. bool uses_password = 1 [deprecated = true]; - // The name of the node, given by "App.set_name()" - string name = 2; + // The name of the node, given by "App.set_name()" - device hostname + string name = 2 [(max_data_length) = 31, (force) = true]; // The mac address of the device. For example "AC:BC:32:89:0E:A9" - string mac_address = 3; + string mac_address = 3 [(max_data_length) = 17, (force) = true]; // A string describing the ESPHome version. For example "1.10.0" - string esphome_version = 4; + string esphome_version = 4 [(max_data_length) = 32, (force) = true]; // A string describing the date of compilation, this is generated by the compiler // and therefore may not be in the same format all the time. // If the user isn't using ESPHome, this will also not be set. - string compilation_time = 5; + string compilation_time = 5 [(max_data_length) = 25, (force) = true]; // The model of the board. For example NodeMCU - string model = 6; + // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas) + string model = 6 [(max_data_length) = 127, (force) = true]; bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"]; // The esphome project details if set - string project_name = 8 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; - string project_version = 9 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; + // max_data_length matches core/config.PROJECT_MAX_LENGTH + string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; + string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"]; @@ -253,18 +268,18 @@ message DeviceInfoResponse { uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; - string manufacturer = 12; + string manufacturer = 12 [(max_data_length) = 20, (force) = true]; - string friendly_name = 13; + string friendly_name = 13 [(max_data_length) = 120, (force) = true]; // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; - string suggested_area = 16 [(field_ifdef) = "USE_AREAS"]; + string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" - string bluetooth_mac_address = 18 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index feb16e4f4c..bfb3ec291c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -72,6 +72,14 @@ static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000; static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); +// Cross-validate C++ constants against proto max_data_length annotations in api.proto +static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, + "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); +static_assert(Application::BUILD_TIME_STR_SIZE - 1 == 25, "Update max_data_length for compilation_time in api.proto"); +static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esphome_version in api.proto"); +static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto"); +static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1716,6 +1724,7 @@ bool APIConnection::send_device_info_response_() { static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); resp.manufacturer = MANUFACTURER; #endif + static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto"); #undef ESPHOME_MANUFACTURER #ifdef USE_ESP8266 diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f7c68b95a7..d27cfa57cf 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -35,29 +35,29 @@ uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->server_info); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); return pos; } uint32_t HelloResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->api_version_major); size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += ProtoSize::calc_length(1, this->server_info.size()); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->server_info.size(); + size += 2 + this->name.size(); return size; } #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); return pos; } uint32_t AreaInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->area_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); return size; } #endif @@ -65,14 +65,14 @@ uint32_t AreaInfo::calculate_size() const { uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); return pos; } uint32_t DeviceInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->device_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_uint32(1, this->area_id); return size; } @@ -93,19 +93,19 @@ uint32_t SerialProxyInfo::calculate_size() const { #endif uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mac_address); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->esphome_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->compilation_time); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->model); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); #ifdef USE_DEEP_SLEEP ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->project_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->project_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); #endif #ifdef USE_WEBSERVER ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); @@ -113,16 +113,16 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ #ifdef USE_BLUETOOTH_PROXY ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); #endif - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 12, this->manufacturer); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->friendly_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); #ifdef USE_VOICE_ASSISTANT ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); #endif #ifdef USE_API_NOISE ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); @@ -155,19 +155,19 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ } uint32_t DeviceInfoResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_length(1, this->mac_address.size()); - size += ProtoSize::calc_length(1, this->esphome_version.size()); - size += ProtoSize::calc_length(1, this->compilation_time.size()); - size += ProtoSize::calc_length(1, this->model.size()); + size += 2 + this->name.size(); + size += 2 + this->mac_address.size(); + size += 2 + this->esphome_version.size(); + size += 2 + this->compilation_time.size(); + size += 2 + this->model.size(); #ifdef USE_DEEP_SLEEP size += ProtoSize::calc_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_name.size()); + size += 2 + this->project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_version.size()); + size += 2 + this->project_version.size(); #endif #ifdef USE_WEBSERVER size += ProtoSize::calc_uint32(1, this->webserver_port); @@ -175,16 +175,16 @@ uint32_t DeviceInfoResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); #endif - size += ProtoSize::calc_length(1, this->manufacturer.size()); - size += ProtoSize::calc_length(1, this->friendly_name.size()); + size += 2 + this->manufacturer.size(); + size += 2 + this->friendly_name.size(); #ifdef USE_VOICE_ASSISTANT size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += ProtoSize::calc_length(2, this->suggested_area.size()); + size += 3 + this->suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_length(2, this->bluetooth_mac_address.size()); + size += 3 + this->bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, this->api_encryption_supported); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0d8a221524..f27690c97b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -44,6 +44,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt +from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed @@ -1403,7 +1404,9 @@ CONF_PARTITIONS = "partitions" CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_BOARD): cv.string_strict, + cv.Optional(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( *FULL_CPU_FREQUENCIES, upper=True ), diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index fcd3499b15..bef7e36470 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -20,6 +20,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.types import ConfigType @@ -203,7 +204,9 @@ BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_RESTORE_FROM_FLASH, default=False): cv.boolean, cv.Optional(CONF_EARLY_PIN_INIT, default=True): cv.boolean, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8f99124604..656eee6d7b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE +from esphome.core.config import BOARD_MAX_LENGTH from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -266,7 +267,9 @@ CONFIG_SCHEMA = cv.All(_notify_old_style) BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LTComponent), - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, }, diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5054e5e0df..5d92a4fa80 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -46,6 +46,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -145,7 +146,9 @@ CONFIG_SCHEMA = cv.All( set_core_data, cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): cv.Schema( { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index a223b346f2..9fbaff6860 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -190,7 +190,7 @@ validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") validate_unit_of_measurement = cv.All( cv.string_strict, # Keep in sync with max_data_length in api.proto - cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), ) _NUMBER_SCHEMA = ( diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 0bb1811069..e452780d41 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from . import boards @@ -168,7 +169,9 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 3a54e97f68..ecf51d5488 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -294,7 +294,7 @@ RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) validate_unit_of_measurement = cv.All( cv.string_strict, # Keep in sync with max_data_length in api.proto - cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), ) validate_accuracy_decimals = cv.int_ validate_icon = cv.icon diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c6b67e9f35..7805de98db 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -130,6 +130,26 @@ RequiredFieldInvalid = vol.RequiredFieldInvalid # the rest of the error path is relative to the root config path ROOT_CONFIG_PATH = object() + +def ByteLength(*, max: int) -> Callable[[str], str]: + """Validate that the UTF-8 byte length of a string does not exceed max. + + Use instead of Length() when the limit must apply to encoded bytes, + not characters (e.g. for protobuf length-varint constraints). + """ + + def validator(value: str) -> str: + byte_len = len(str(value).encode("utf-8")) + if byte_len > max: + raise Invalid( + f"String is too long ({byte_len} bytes, max {max}). " + f"Multibyte characters count as multiple bytes." + ) + return value + + return validator + + RESERVED_IDS = [ # C++ keywords https://en.cppreference.com/w/cpp/keyword "alarm", @@ -411,9 +431,10 @@ def icon(value): raise Invalid( 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' ) - if len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) + if byte_len > ICON_MAX_LENGTH: raise Invalid( - f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). " + f"Icon string is too long ({byte_len} bytes, max {ICON_MAX_LENGTH}). " "Icons are stored in PROGMEM with a 64-byte buffer limit." ) return value @@ -2067,11 +2088,12 @@ def _validate_entity_name(value): "Name cannot be None when esphome->friendly_name is not set!" )(value) if value is not None: - # Validate length for web server URL compatibility - if len(value) > NAME_MAX_LENGTH: + # Validate byte length for web server URL and proto encoding compatibility + byte_len = len(value.encode("utf-8")) + if byte_len > NAME_MAX_LENGTH: raise Invalid( - f"Name is too long ({len(value)} chars). " - f"Maximum length is {NAME_MAX_LENGTH} characters." + f"Name is too long ({byte_len} bytes). " + f"Maximum length is {NAME_MAX_LENGTH} bytes." ) # Validate no '/' in name for web server URL compatibility value = _validate_no_slash(value) diff --git a/esphome/core/config.py b/esphome/core/config.py index 31cfd00ef7..bf210876df 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -236,11 +236,17 @@ ICON_MAX_LENGTH = 63 # Max unit of measurement string length UNIT_OF_MEASUREMENT_MAX_LENGTH = 63 +# Max project name/version string length (must fit in single-byte varint for proto encoding) +PROJECT_MAX_LENGTH = 127 + +# Max board/model string length (must fit in single-byte varint for proto encoding) +BOARD_MAX_LENGTH = 127 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), } ) @@ -249,7 +255,7 @@ DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA_ID): cv.use_id(Area), } @@ -266,7 +272,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_NAME): cv.valid_name, # Keep max=120 in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), @@ -306,9 +312,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( - cv.string_strict, valid_project_name + cv.string_strict, + valid_project_name, + cv.ByteLength(max=PROJECT_MAX_LENGTH), + ), + cv.Required(CONF_VERSION): cv.All( + cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH) ), - cv.Required(CONF_VERSION): cv.string_strict, cv.Optional(CONF_ON_UPDATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index fc931c2baa..f09dd013fe 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -193,9 +193,10 @@ def _register_string( def register_device_class(value: str) -> int: """Register a device_class string and return its 1-based index.""" - if value and len(value) > DEVICE_CLASS_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > DEVICE_CLASS_MAX_LENGTH: raise ValueError( - f"Device class string too long ({len(value)} chars, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" + f"Device class string too long ({byte_len} bytes, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" ) return _register_string( value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" @@ -204,9 +205,10 @@ def register_device_class(value: str) -> int: def register_unit_of_measurement(value: str) -> int: """Register a unit_of_measurement string and return its 1-based index.""" - if value and len(value) > UNIT_OF_MEASUREMENT_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > UNIT_OF_MEASUREMENT_MAX_LENGTH: raise ValueError( - f"Unit of measurement string too long ({len(value)} chars, " + f"Unit of measurement string too long ({byte_len} bytes, " f"max {UNIT_OF_MEASUREMENT_MAX_LENGTH}): '{value}'" ) return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") @@ -214,9 +216,10 @@ def register_unit_of_measurement(value: str) -> int: def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" - if value and len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > ICON_MAX_LENGTH: raise ValueError( - f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'" + f"Icon string too long ({byte_len} bytes, max {ICON_MAX_LENGTH}): '{value}'" ) return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index c1849daf4b..ce941b40dc 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -149,17 +149,36 @@ def test_icon__invalid(): def test_icon__max_length(): - """Test that icons exceeding 63 characters are rejected.""" - # Exactly 63 chars should pass - max_icon = "mdi:" + "a" * 59 # 63 chars total + """Test that icons exceeding 63 bytes are rejected.""" + # Exactly 63 bytes should pass + max_icon = "mdi:" + "a" * 59 # 63 bytes total assert config_validation.icon(max_icon) == max_icon - # 64 chars should fail - too_long = "mdi:" + "a" * 60 # 64 chars total + # 64 bytes should fail + too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): config_validation.icon(too_long) +def test_byte_length() -> None: + """Test ByteLength validator checks UTF-8 byte length, not char count.""" + validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + + # ASCII: 10 chars = 10 bytes, should pass + assert validator("a" * 10) == "a" * 10 + + # ASCII: 11 chars = 11 bytes, should fail + with pytest.raises(Invalid, match="too long.*11 bytes.*max 10"): + validator("a" * 11) + + # Multibyte: 3 chars × 3 bytes = 9 bytes, should pass + assert validator("温度传") == "温度传" + + # Multibyte: 4 chars × 3 bytes = 12 bytes, should fail + with pytest.raises(Invalid, match="too long.*12 bytes.*max 10"): + validator("温度传感") + + @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): assert config_validation.boolean(value) is True @@ -567,14 +586,23 @@ def test_validate_entity_name__slash_replaced_with_warning( def test_validate_entity_name__max_length() -> None: - # 120 chars should pass + # 120 bytes should pass assert config_validation._validate_entity_name("x" * 120) == "x" * 120 - # 121 chars should fail - with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): + # 121 bytes should fail + with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): config_validation._validate_entity_name("x" * 121) +def test_validate_entity_name__multibyte_byte_length() -> None: + # 40 chars of 3-byte UTF-8 = 120 bytes, should pass + assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + + # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) + with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): + config_validation._validate_entity_name("温" * 41) + + def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None From c6c743e2bb0b7b686bd2d727480d004b879d3948 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:26:11 -1000 Subject: [PATCH 03/25] Bump pytest from 9.0.2 to 9.0.3 (#15540) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index a191378dd7..eeee3434ce 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.2 +pytest==9.0.3 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.3.0 From ef6c65c7ecb5cfeefab06035f68e9b1c6ae80f22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 10:37:19 -1000 Subject: [PATCH 04/25] [cli] Add config bundle CLI command for remote compilation (#13791) --- esphome/__main__.py | 61 + esphome/bundle.py | 699 ++++++++++ esphome/components/wifi/wpa2_eap.py | 6 +- esphome/yaml_util.py | 33 +- .../fixtures/bundle/assets/certs/ca_cert.pem | 18 + .../bundle/assets/certs/client_cert.pem | 18 + .../bundle/assets/certs/client_key.pem | 27 + .../bundle/assets/fonts/test_font.ttf | Bin 0 -> 202764 bytes .../bundle/assets/images/animation.gif | Bin 0 -> 9735 bytes .../fixtures/bundle/assets/images/logo.png | Bin 0 -> 685 bytes .../fixtures/bundle/assets/web/custom.css | 2 + .../fixtures/bundle/assets/web/custom.js | 2 + .../fixtures/bundle/bundle_test.yaml | 60 + .../fixtures/bundle/common/base.yaml | 1 + .../fixtures/bundle/includes/custom_sensor.h | 3 + .../local_components/my_component/__init__.py | 1 + .../my_component/my_component.h | 2 + tests/unit_tests/fixtures/bundle/secrets.yaml | 4 + tests/unit_tests/test_bundle.py | 1210 +++++++++++++++++ tests/unit_tests/test_main.py | 196 +++ tests/unit_tests/test_yaml_util.py | 54 + 21 files changed, 2390 insertions(+), 7 deletions(-) create mode 100644 esphome/bundle.py create mode 100644 tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem create mode 100644 tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem create mode 100644 tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem create mode 100644 tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf create mode 100644 tests/unit_tests/fixtures/bundle/assets/images/animation.gif create mode 100644 tests/unit_tests/fixtures/bundle/assets/images/logo.png create mode 100644 tests/unit_tests/fixtures/bundle/assets/web/custom.css create mode 100644 tests/unit_tests/fixtures/bundle/assets/web/custom.js create mode 100644 tests/unit_tests/fixtures/bundle/bundle_test.yaml create mode 100644 tests/unit_tests/fixtures/bundle/common/base.yaml create mode 100644 tests/unit_tests/fixtures/bundle/includes/custom_sensor.h create mode 100644 tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py create mode 100644 tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h create mode 100644 tests/unit_tests/fixtures/bundle/secrets.yaml create mode 100644 tests/unit_tests/test_bundle.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 87abd7f796..a696cceffb 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1242,6 +1242,38 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator + + creator = ConfigBundleCreator(config) + + if args.list_only: + files = creator.discover_files() + for bf in sorted(files, key=lambda f: f.path): + safe_print(f" {bf.path}") + _LOGGER.info("Found %d files", len(files)) + return 0 + + result = creator.create_bundle() + + if args.output: + output_path = Path(args.output) + else: + stem = CORE.config_path.stem + output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}" + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(result.data) + + _LOGGER.info( + "Bundle created: %s (%d files, %.1f KB)", + output_path, + len(result.files), + len(result.data) / 1024, + ) + return 0 + + def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard @@ -1517,6 +1549,7 @@ POST_CONFIG_ACTIONS = { "rename": command_rename, "discover": command_discover, "analyze-memory": command_analyze_memory, + "bundle": command_bundle, } SIMPLE_CONFIG_ACTIONS = [ @@ -1818,6 +1851,24 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) + parser_bundle = subparsers.add_parser( + "bundle", + help="Create a self-contained config bundle for remote compilation.", + ) + parser_bundle.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_bundle.add_argument( + "-o", + "--output", + help="Output path for the bundle archive.", + ) + parser_bundle.add_argument( + "--list-only", + help="List discovered files without creating the archive.", + action="store_true", + ) + # Keep backward compatibility with the old command line format of # esphome . # @@ -1896,6 +1947,16 @@ def run_esphome(argv): _LOGGER.warning("Skipping secrets file %s", conf_path) return 0 + # Bundle support: if the configuration is a .esphomebundle, extract it + # and rewrite conf_path to the extracted YAML config. + from esphome.bundle import is_bundle_path, prepare_bundle_for_compile + + if is_bundle_path(conf_path): + _LOGGER.info("Extracting config bundle %s...", conf_path) + conf_path = prepare_bundle_for_compile(conf_path) + # Update the argument so downstream code sees the extracted path + args.configuration[0] = str(conf_path) + CORE.config_path = conf_path CORE.dashboard = args.dashboard diff --git a/esphome/bundle.py b/esphome/bundle.py new file mode 100644 index 0000000000..b6816c7c95 --- /dev/null +++ b/esphome/bundle.py @@ -0,0 +1,699 @@ +"""Config bundle creator and extractor for ESPHome. + +A bundle is a self-contained .tar.gz archive containing a YAML config +and every local file it depends on. Bundles can be created from a config +and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import io +import json +import logging +from pathlib import Path +import re +import shutil +import tarfile +from typing import Any + +from esphome import const, yaml_util +from esphome.const import ( + CONF_ESPHOME, + CONF_EXTERNAL_COMPONENTS, + CONF_INCLUDES, + CONF_INCLUDES_C, + CONF_PATH, + CONF_SOURCE, + CONF_TYPE, +) +from esphome.core import CORE, EsphomeError + +_LOGGER = logging.getLogger(__name__) + +BUNDLE_EXTENSION = ".esphomebundle.tar.gz" +MANIFEST_FILENAME = "manifest.json" +CURRENT_MANIFEST_VERSION = 1 +MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB +MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB + +# Directories preserved across bundle extractions (build caches) +_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio") +_BUNDLE_STAGING_DIR = ".bundle_staging" + + +class ManifestKey(StrEnum): + """Keys used in bundle manifest.json.""" + + MANIFEST_VERSION = "manifest_version" + ESPHOME_VERSION = "esphome_version" + CONFIG_FILENAME = "config_filename" + FILES = "files" + HAS_SECRETS = "has_secrets" + + +# String prefixes that are never local file paths +_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<") + +# File extensions recognized when resolving relative path strings. +# A relative string with one of these extensions is resolved against the +# config directory and included if the file exists. +_KNOWN_FILE_EXTENSIONS = frozenset( + { + # Fonts + ".ttf", + ".otf", + ".woff", + ".woff2", + ".pcf", + ".bdf", + # Images + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".svg", + ".ico", + ".webp", + # Certificates + ".pem", + ".crt", + ".key", + ".der", + ".p12", + ".pfx", + # C/C++ includes + ".h", + ".hpp", + ".c", + ".cpp", + ".ino", + # Web assets + ".css", + ".js", + ".html", + } +) + + +# Matches !secret references in YAML text. This is intentionally a simple +# regex scan rather than a YAML parse — it may match inside comments or +# multi-line strings, which is the conservative direction (include more +# secrets rather than fewer). +_SECRET_RE = re.compile(r"!secret\s+(\S+)") + + +def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: + """Scan YAML files for ``!secret `` references.""" + keys: set[str] = set() + for fpath in yaml_files: + try: + text = fpath.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for match in _SECRET_RE.finditer(text): + keys.add(match.group(1)) + return keys + + +@dataclass +class BundleFile: + """A file to include in the bundle.""" + + path: str # Relative path inside the archive + source: Path # Absolute path on disk + + +@dataclass +class BundleResult: + """Result of creating a bundle.""" + + data: bytes + manifest: dict[str, Any] + files: list[BundleFile] + + +@dataclass +class BundleManifest: + """Parsed and validated bundle manifest.""" + + manifest_version: int + esphome_version: str + config_filename: str + files: list[str] + has_secrets: bool + + +class ConfigBundleCreator: + """Creates a self-contained bundle from an ESPHome config.""" + + def __init__(self, config: dict[str, Any]) -> None: + self._config = config + self._config_dir = CORE.config_dir + self._config_path = CORE.config_path + self._files: list[BundleFile] = [] + self._seen_paths: set[Path] = set() + self._secrets_paths: set[Path] = set() + + def discover_files(self) -> list[BundleFile]: + """Discover all files needed for the bundle.""" + self._files = [] + self._seen_paths = set() + self._secrets_paths = set() + + # The main config file + self._add_file(self._config_path) + + # Phase 1: YAML includes (tracked during config loading) + self._discover_yaml_includes() + + # Phase 2: Component-referenced files from validated config + self._discover_component_files() + + return list(self._files) + + def create_bundle(self) -> BundleResult: + """Create the bundle archive.""" + files = self.discover_files() + + # Determine which secret keys are actually referenced by the + # bundled YAML files so we only ship those, not the entire + # secrets.yaml which may contain secrets for other devices. + yaml_sources = [ + bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") + ] + used_secret_keys = _find_used_secret_keys(yaml_sources) + filtered_secrets = self._build_filtered_secrets(used_secret_keys) + + has_secrets = bool(filtered_secrets) + if has_secrets: + _LOGGER.warning( + "Bundle contains secrets (e.g. Wi-Fi passwords). " + "Do not share it with untrusted parties." + ) + + manifest = self._build_manifest(files, has_secrets=has_secrets) + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + # Add manifest first + manifest_data = json.dumps(manifest, indent=2).encode("utf-8") + _add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data) + + # Add filtered secrets files + for rel_path, data in sorted(filtered_secrets.items()): + _add_bytes_to_tar(tar, rel_path, data) + + # Add files in sorted order for determinism, skipping secrets + # files which were already added above with filtered content + for bf in sorted(files, key=lambda f: f.path): + if bf.source in self._secrets_paths: + continue + self._add_to_tar(tar, bf) + + return BundleResult(data=buf.getvalue(), manifest=manifest, files=files) + + def _add_file(self, abs_path: Path) -> bool: + """Add a file to the bundle. Returns False if already added.""" + abs_path = abs_path.resolve() + if abs_path in self._seen_paths: + return False + if not abs_path.is_file(): + _LOGGER.warning("Bundle: skipping missing file %s", abs_path) + return False + + rel_path = self._relative_to_config_dir(abs_path) + if rel_path is None: + _LOGGER.warning( + "Bundle: skipping file outside config directory: %s", abs_path + ) + return False + + self._seen_paths.add(abs_path) + self._files.append(BundleFile(path=rel_path, source=abs_path)) + return True + + def _add_directory(self, abs_path: Path) -> None: + """Recursively add all files in a directory.""" + abs_path = abs_path.resolve() + if not abs_path.is_dir(): + _LOGGER.warning("Bundle: skipping missing directory %s", abs_path) + return + for child in sorted(abs_path.rglob("*")): + if child.is_file() and "__pycache__" not in child.parts: + self._add_file(child) + + def _relative_to_config_dir(self, abs_path: Path) -> str | None: + """Get a path relative to the config directory. Returns None if outside. + + Always uses forward slashes for consistency in tar archives. + """ + try: + return abs_path.relative_to(self._config_dir).as_posix() + except ValueError: + return None + + def _discover_yaml_includes(self) -> None: + """Discover YAML files loaded during config parsing. + + We track files by wrapping _load_yaml_internal. The config has already + been loaded at this point (bundle is a POST_CONFIG_ACTION), so we + re-load just to discover the file list. + + Secrets files are tracked separately so we can filter them to + only include the keys this config actually references. + """ + with yaml_util.track_yaml_loads() as loaded_files: + try: + yaml_util.load_yaml(self._config_path) + except EsphomeError: + _LOGGER.debug( + "Bundle: re-loading YAML for include discovery failed, " + "proceeding with partial file list" + ) + + for fpath in loaded_files: + if fpath == self._config_path.resolve(): + continue # Already added as config + if fpath.name in const.SECRETS_FILES: + self._secrets_paths.add(fpath) + self._add_file(fpath) + + def _discover_component_files(self) -> None: + """Walk the validated config for file references. + + Uses a generic recursive walk to find file paths instead of + hardcoding per-component knowledge about config dict formats. + After validation, components typically resolve paths to absolute + using CORE.relative_config_path() or cv.file_(). Relative paths + with known file extensions are also resolved and checked. + + Core ESPHome concepts that use relative paths or directories + are handled explicitly. + """ + config = self._config + + # Generic walk: find all file paths in the validated config + self._walk_config_for_files(config) + + # --- Core ESPHome concepts needing explicit handling --- + + # esphome.includes / includes_c - can be relative paths and directories + esphome_conf = config.get(CONF_ESPHOME, {}) + for include_path in esphome_conf.get(CONF_INCLUDES, []): + resolved = _resolve_include_path(include_path) + if resolved is None: + continue + if resolved.is_dir(): + self._add_directory(resolved) + else: + self._add_file(resolved) + for include_path in esphome_conf.get(CONF_INCLUDES_C, []): + resolved = _resolve_include_path(include_path) + if resolved is not None: + self._add_file(resolved) + + # external_components with source: local - directories + for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []): + source = ext_conf.get(CONF_SOURCE, {}) + if not isinstance(source, dict): + continue + if source.get(CONF_TYPE) != "local": + continue + path = source.get(CONF_PATH) + if not path: + continue + p = Path(path) + if not p.is_absolute(): + p = CORE.relative_config_path(p) + self._add_directory(p) + + def _walk_config_for_files(self, obj: Any) -> None: + """Recursively walk the config dict looking for file path references.""" + if isinstance(obj, dict): + for value in obj.values(): + self._walk_config_for_files(value) + elif isinstance(obj, (list, tuple)): + for item in obj: + self._walk_config_for_files(item) + elif isinstance(obj, Path): + if obj.is_absolute() and obj.is_file(): + self._add_file(obj) + elif isinstance(obj, str): + self._check_string_path(obj) + + def _check_string_path(self, value: str) -> None: + """Check if a string value is a local file reference.""" + # Fast exits for strings that cannot be file paths + if len(value) < 2 or "\n" in value: + return + if value.startswith(_NON_PATH_PREFIXES): + return + # File paths must contain a path separator or a dot (for extension) + if "/" not in value and "\\" not in value and "." not in value: + return + + p = Path(value) + + # Absolute path - check if it points to an existing file + if p.is_absolute(): + if p.is_file(): + self._add_file(p) + return + + # Relative path with a known file extension - likely a component + # validator that forgot to resolve to absolute via cv.file_() or + # CORE.relative_config_path(). Warn and try to resolve. + if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS: + _LOGGER.warning( + "Bundle: non-absolute path in validated config: %s " + "(component validator should return absolute paths)", + value, + ) + resolved = CORE.relative_config_path(p) + if resolved.is_file(): + self._add_file(resolved) + + def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]: + """Build filtered secrets files containing only the referenced keys. + + Returns a dict mapping relative archive path to YAML bytes. + """ + if not used_keys or not self._secrets_paths: + return {} + + result: dict[str, bytes] = {} + for secrets_path in self._secrets_paths: + rel_path = self._relative_to_config_dir(secrets_path) + if rel_path is None: + continue + try: + all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False) + except EsphomeError: + _LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path) + continue + if not isinstance(all_secrets, dict): + continue + filtered = {k: v for k, v in all_secrets.items() if k in used_keys} + if filtered: + data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8") + result[rel_path] = data + return result + + def _build_manifest( + self, files: list[BundleFile], *, has_secrets: bool + ) -> dict[str, Any]: + """Build the manifest.json content.""" + return { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.ESPHOME_VERSION: const.__version__, + ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.FILES: [f.path for f in files], + ManifestKey.HAS_SECRETS: has_secrets, + } + + @staticmethod + def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: + """Add a BundleFile to the tar archive with deterministic metadata.""" + with open(bf.source, "rb") as f: + _add_bytes_to_tar(tar, bf.path, f.read()) + + +def extract_bundle( + bundle_path: Path, + target_dir: Path | None = None, +) -> Path: + """Extract a bundle archive and return the path to the config YAML. + + Sanity checks reject path traversal, symlinks, absolute paths, and + oversized archives to prevent accidental file overwrites or extraction + outside the target directory. These are **not** a security boundary — + bundles are assumed to come from the user's own machine or a trusted + build pipeline. + + Args: + bundle_path: Path to the .tar.gz bundle file. + target_dir: Directory to extract into. If None, extracts next to + the bundle file in a directory named after it. + + Returns: + Absolute path to the extracted config YAML file. + + Raises: + EsphomeError: If the bundle is invalid or extraction fails. + """ + + bundle_path = bundle_path.resolve() + if not bundle_path.is_file(): + raise EsphomeError(f"Bundle file not found: {bundle_path}") + + if target_dir is None: + target_dir = _default_target_dir(bundle_path) + + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + # Read and validate the archive + try: + with tarfile.open(bundle_path, "r:gz") as tar: + manifest = _read_manifest_from_tar(tar) + _validate_tar_members(tar, target_dir) + tar.extractall(path=target_dir, filter="data") + except tarfile.TarError as err: + raise EsphomeError(f"Failed to extract bundle: {err}") from err + + config_filename = manifest[ManifestKey.CONFIG_FILENAME] + config_path = target_dir / config_filename + if not config_path.is_file(): + raise EsphomeError( + f"Bundle manifest references config '{config_filename}' " + f"but it was not found in the archive" + ) + + return config_path + + +def read_bundle_manifest(bundle_path: Path) -> BundleManifest: + """Read and validate the manifest from a bundle without full extraction. + + Args: + bundle_path: Path to the .tar.gz bundle file. + + Returns: + Parsed BundleManifest. + + Raises: + EsphomeError: If the manifest is missing, invalid, or version unsupported. + """ + + try: + with tarfile.open(bundle_path, "r:gz") as tar: + manifest = _read_manifest_from_tar(tar) + except tarfile.TarError as err: + raise EsphomeError(f"Failed to read bundle: {err}") from err + + return BundleManifest( + manifest_version=manifest[ManifestKey.MANIFEST_VERSION], + esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), + config_filename=manifest[ManifestKey.CONFIG_FILENAME], + files=manifest.get(ManifestKey.FILES, []), + has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + ) + + +def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]: + """Read and validate manifest.json from an open tar archive.""" + + try: + member = tar.getmember(MANIFEST_FILENAME) + except KeyError: + raise EsphomeError("Invalid bundle: missing manifest.json") from None + + f = tar.extractfile(member) + if f is None: + raise EsphomeError("Invalid bundle: manifest.json is not a regular file") + + if member.size > MAX_MANIFEST_SIZE: + raise EsphomeError( + f"Invalid bundle: manifest.json too large " + f"({member.size} bytes, max {MAX_MANIFEST_SIZE})" + ) + + try: + manifest = json.loads(f.read()) + except (json.JSONDecodeError, UnicodeDecodeError) as err: + raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err + + # Version check + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if version is None: + raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'") + if not isinstance(version, int) or version < 1: + raise EsphomeError( + f"Invalid bundle: manifest_version must be a positive integer, got {version!r}" + ) + if version > CURRENT_MANIFEST_VERSION: + raise EsphomeError( + f"Bundle manifest version {version} is newer than this ESPHome " + f"version supports (max {CURRENT_MANIFEST_VERSION}). " + f"Please upgrade ESPHome to compile this bundle." + ) + + # Required fields + if ManifestKey.CONFIG_FILENAME not in manifest: + raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'") + + return manifest + + +def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: + """Sanity-check tar members to prevent mistakes and accidental overwrites. + + This is not a security boundary — bundles are created locally or come + from a trusted build pipeline. The checks catch malformed archives + and common mistakes (stray absolute paths, ``..`` components) that + could silently overwrite unrelated files. + """ + + total_size = 0 + for member in tar.getmembers(): + # Reject absolute paths (Unix and Windows) + if member.name.startswith(("/", "\\")): + raise EsphomeError( + f"Invalid bundle: absolute path in archive: {member.name}" + ) + + # Reject path traversal (split on both / and \ for cross-platform) + parts = re.split(r"[/\\]", member.name) + if ".." in parts: + raise EsphomeError( + f"Invalid bundle: path traversal in archive: {member.name}" + ) + + # Reject symlinks + if member.issym() or member.islnk(): + raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}") + + # Ensure extraction stays within target_dir + target_path = (target_dir / member.name).resolve() + if not target_path.is_relative_to(target_dir): + raise EsphomeError( + f"Invalid bundle: file would extract outside target: {member.name}" + ) + + # Track total decompressed size + total_size += member.size + if total_size > MAX_DECOMPRESSED_SIZE: + raise EsphomeError( + f"Invalid bundle: decompressed size exceeds " + f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit" + ) + + +def is_bundle_path(path: Path) -> bool: + """Check if a path looks like a bundle file.""" + return path.name.lower().endswith(BUNDLE_EXTENSION) + + +def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: + """Add in-memory bytes to a tar archive with deterministic metadata.""" + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.mode = 0o644 + tar.addfile(info, io.BytesIO(data)) + + +def _resolve_include_path(include_path: Any) -> Path | None: + """Resolve an include path to absolute, skipping system includes.""" + if isinstance(include_path, str) and include_path.startswith("<"): + return None # System include, not a local file + p = Path(include_path) + if not p.is_absolute(): + p = CORE.relative_config_path(p) + return p + + +def _default_target_dir(bundle_path: Path) -> Path: + """Compute the default extraction directory for a bundle.""" + name = bundle_path.name + if name.lower().endswith(BUNDLE_EXTENSION): + name = name[: -len(BUNDLE_EXTENSION)] + return bundle_path.parent / name + + +def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None: + """Move preserved build cache directories back into target_dir. + + If the bundle contained entries under a preserved directory name, + the extracted copy is removed so the original cache always wins. + """ + for dirname, src in preserved.items(): + dst = target_dir / dirname + if dst.exists(): + shutil.rmtree(dst) + shutil.move(str(src), str(dst)) + + +def prepare_bundle_for_compile( + bundle_path: Path, + target_dir: Path | None = None, +) -> Path: + """Extract a bundle for compilation, preserving build caches. + + Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/ + directories in the target if they already exist (for incremental builds). + + Args: + bundle_path: Path to the .tar.gz bundle file. + target_dir: Directory to extract into. Must be specified for + build server use. + + Returns: + Absolute path to the extracted config YAML file. + """ + + bundle_path = bundle_path.resolve() + if not bundle_path.is_file(): + raise EsphomeError(f"Bundle file not found: {bundle_path}") + + if target_dir is None: + target_dir = _default_target_dir(bundle_path) + + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + preserved: dict[str, Path] = {} + + # Temporarily move preserved dirs out of the way + staging = target_dir / _BUNDLE_STAGING_DIR + for dirname in _PRESERVE_DIRS: + src = target_dir / dirname + if src.is_dir(): + dst = staging / dirname + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dst)) + preserved[dirname] = dst + + try: + # Clean non-preserved content and extract fresh + for item in target_dir.iterdir(): + if item.name == _BUNDLE_STAGING_DIR: + continue + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + + config_path = extract_bundle(bundle_path, target_dir) + finally: + # Restore preserved dirs (idempotent) and clean staging + _restore_preserved_dirs(preserved, target_dir) + if staging.is_dir(): + shutil.rmtree(staging) + + return config_path diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 5d5bd8dca3..9da3494329 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -71,9 +71,11 @@ def _validate_load_certificate(value): def validate_certificate(value): + # _validate_load_certificate already calls cv.file_() internally, + # but returns the parsed certificate object. We re-call cv.file_() + # to get the resolved path string that the bundle walker can discover. _validate_load_certificate(value) - # Validation result should be the path, not the loaded certificate - return value + return str(cv.file_(value)) def _validate_load_private_key(key, cert_pw): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e001316a22..a24c1ebccb 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import suppress +from collections.abc import Callable, Generator +from contextlib import contextmanager, suppress import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -44,6 +44,27 @@ _LOGGER = logging.getLogger(__name__) SECRET_YAML = "secrets.yaml" _SECRET_CACHE = {} _SECRET_VALUES = {} +# Not thread-safe — config processing is single-threaded today. +_load_listeners: list[Callable[[Path], None]] = [] + + +@contextmanager +def track_yaml_loads() -> Generator[list[Path]]: + """Context manager that records every file loaded by the YAML loader. + + Yields a list that is populated with resolved Path objects for every + file loaded through ``_load_yaml_internal`` while the context is active. + """ + loaded: list[Path] = [] + + def _on_load(fname: Path) -> None: + loaded.append(Path(fname).resolve()) + + _load_listeners.append(_on_load) + try: + yield loaded + finally: + _load_listeners.remove(_on_load) class ESPHomeDataBase: @@ -466,6 +487,8 @@ def load_yaml(fname: Path, clear_secrets: bool = True) -> Any: def _load_yaml_internal(fname: Path) -> Any: """Load a YAML file.""" + for listener in _load_listeners: + listener(fname) try: with fname.open(encoding="utf-8") as f_handle: return parse_yaml(fname, f_handle) @@ -473,10 +496,10 @@ def _load_yaml_internal(fname: Path) -> Any: raise EsphomeError(f"Error reading file {fname}: {err}") from err -def parse_yaml( - file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal -) -> Any: +def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any: """Parse a YAML file.""" + if yaml_loader is None: + yaml_loader = _load_yaml_internal try: return _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, yaml_loader diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem b/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem new file mode 100644 index 0000000000..6d200b15ef --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzjCCAbagAwIBAgIUW3BzjtekVgMj12/oeXawSswGyXMwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWRVNQSG9tZSBCdW5kbGUgVGVzdCBDQTAeFw0yNjAyMDYx +MzMxMTZaFw0yNzAyMDYxMzMxMTZaMCExHzAdBgNVBAMMFkVTUEhvbWUgQnVuZGxl +IFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDG62vBFkGn +hEu54gh2A7b1ZwesVadZ6u0iaVO7GSWiI0o4nb6xv7ULZbGrgsKNIO6qCV4VSR3p +BfMhF5dFy8kkMzA8dKZMk16tygzocdNum2QQ8BHyIsATL7SGZ33si9Alp30gXv6h +XSlEKYDKHFavkDhWPFNa5+oeHbMS/MxjpOUXIpq32VaFpJr427d9Y9wGjuK8B7Gp +CI5Ub1g2dpC9xSHqQKD3JZokmtc70+mD74AcNWbyxWp0bkW9wOfNJJnAoiwhJxQ8 +yfE37UsUIVc8014NhdhU1K/S0iQuOKfGX1L/GAshv8syQIcDfzJuJdX+5E/leAYD +UEKqRkcLT+D5AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAF1HpJ6d+W5WrzOQrGej +41pxCDeJ9tSiSj/KtvJfjEVIpg0hMRTY7nSL7OAg9KGESfx4u1jMwVnyOv34br5B +DTlRl+wF2k7Ip8CNnyZfCC+1SVQZpUt1mVNz8BhIZZ9/a830wCILNQQrVKkSeNBk +SEc1qTt4mIhQZ+M422qAswluv4fz/FW1f4oB9KhCpzUCANjmyERnqTnImjnJu8h0 +jbPNnNsN+G+Roju8UD/7atWYfAUmDjHx72Ci/5G9SzoM5fhgxxu43XYd5RW5wBzt +j4KdKdYlDtOL62mRPKWd40uGnJcieUjisU7noRn0ErMgbUlhLdbXT9X7aNborZcu +x6I= +-----END CERTIFICATE----- diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem b/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem new file mode 100644 index 0000000000..6d200b15ef --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzjCCAbagAwIBAgIUW3BzjtekVgMj12/oeXawSswGyXMwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWRVNQSG9tZSBCdW5kbGUgVGVzdCBDQTAeFw0yNjAyMDYx +MzMxMTZaFw0yNzAyMDYxMzMxMTZaMCExHzAdBgNVBAMMFkVTUEhvbWUgQnVuZGxl +IFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDG62vBFkGn +hEu54gh2A7b1ZwesVadZ6u0iaVO7GSWiI0o4nb6xv7ULZbGrgsKNIO6qCV4VSR3p +BfMhF5dFy8kkMzA8dKZMk16tygzocdNum2QQ8BHyIsATL7SGZ33si9Alp30gXv6h +XSlEKYDKHFavkDhWPFNa5+oeHbMS/MxjpOUXIpq32VaFpJr427d9Y9wGjuK8B7Gp +CI5Ub1g2dpC9xSHqQKD3JZokmtc70+mD74AcNWbyxWp0bkW9wOfNJJnAoiwhJxQ8 +yfE37UsUIVc8014NhdhU1K/S0iQuOKfGX1L/GAshv8syQIcDfzJuJdX+5E/leAYD +UEKqRkcLT+D5AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAF1HpJ6d+W5WrzOQrGej +41pxCDeJ9tSiSj/KtvJfjEVIpg0hMRTY7nSL7OAg9KGESfx4u1jMwVnyOv34br5B +DTlRl+wF2k7Ip8CNnyZfCC+1SVQZpUt1mVNz8BhIZZ9/a830wCILNQQrVKkSeNBk +SEc1qTt4mIhQZ+M422qAswluv4fz/FW1f4oB9KhCpzUCANjmyERnqTnImjnJu8h0 +jbPNnNsN+G+Roju8UD/7atWYfAUmDjHx72Ci/5G9SzoM5fhgxxu43XYd5RW5wBzt +j4KdKdYlDtOL62mRPKWd40uGnJcieUjisU7noRn0ErMgbUlhLdbXT9X7aNborZcu +x6I= +-----END CERTIFICATE----- diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem b/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem new file mode 100644 index 0000000000..6182f45d8b --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxutrwRZBp4RLueIIdgO29WcHrFWnWertImlTuxkloiNKOJ2+ +sb+1C2Wxq4LCjSDuqgleFUkd6QXzIReXRcvJJDMwPHSmTJNercoM6HHTbptkEPAR +8iLAEy+0hmd97IvQJad9IF7+oV0pRCmAyhxWr5A4VjxTWufqHh2zEvzMY6TlFyKa +t9lWhaSa+Nu3fWPcBo7ivAexqQiOVG9YNnaQvcUh6kCg9yWaJJrXO9Ppg++AHDVm +8sVqdG5FvcDnzSSZwKIsIScUPMnxN+1LFCFXPNNeDYXYVNSv0tIkLjinxl9S/xgL +Ib/LMkCHA38ybiXV/uRP5XgGA1BCqkZHC0/g+QIDAQABAoIBAEpsFwcJNCwf95MG +qcK5lhCPaRQFgdTG68ylmoGUIXvddy3ies+W2X33oLb5958ElLaCRbRyBCJEKxgU +8vBWk50bF69uty9MLa6YuyaWO5QUyCX8I8KzVKh4/zIP81F2Z7xGwy5CzEKED+Xk +Hz6+xoHt094TuN34iaOV2gM/GJsok4Wp/lzsuT3X6i3Nad9YGrV2yL/wv5c542bw +vrFDtYQ/+ADZZPW4+xK0ShiarSqV3iXB2cEjc4JX7yLX1hB4LY8VHRzl+Byjdl0/ +lheiIesl5htl82SFxquZDimDsbilTm7TLW2bbm3b3/oC7DchTx6COBjp90VJqk3R +QrO5dicCgYEA80pyA7tCB0bGnJ7KWkteKddyOdakeYeM7Bpfv17qbCm9ciMw9nqt +KJVZPtAuqZGTpfSJseOCIyz9zloB79hVJ3mdWpGJVvmNM5H+BJyCciXpwfqp64QG +1gMqGlSy/MwsZHqNCsOIvrzH09GFN0LSPNKeXN7GNAtU1vI5s7Xf158CgYEA0U+Y +Qe1qJY4m597spHNFfkGznoFXAjHOoWYHv95902cH6JD4GnYPfwFXxgFsrJhFaFMC +jXlT0fRFAIe4NuUJhGD6TYSJqsFkH3xJkAepvKpfjM5qJ7+PQHRnED/E5OS2Nj0R ++cxBhTEWTw9YiOFBRbj6hlphkj8izVGJZ2pL4GcCgYEApsjiYKx/F33tqnExR7Vj +WEvagswi9S137mQmP4tSKdRzi0uUxWRUUP4RsH4HfzfNgHej7c+J55Nwa4ZIzaQA +vI8i0HP1MyrhIflzqrWgt6BGIDU3R7268fw5YNOv4J4X0Moy5q4lkJzaYNvB96BX +gFrjNceDGSqrfq+P3yNP0QECgYBNQfHTM8ygPA4EO/Zg5ONbrOidsuPovXWlgUGP +ApKy+y6iGxBYxAcIO/in71KrijDkRu+ERKo5rs3hWjcWnAedQyZggnFGA8fvDzMf +5JQ0PTazhGUOcthvVAfOqZsFWZ4f+v6tk0UD4pB3chSdwXcUQyjFeorVLlSsMFJl +R4jmNQKBgG38YFR2bqIc7jJItr+34POXdJ4te8Dm1jJHbo8xXsnjVSaxjc5PGs3p +OuJpwuMwzEuFEnE7XLkQxTJw54OBLMmDgK0XUOPDq6eLzrKkW5NlpejqaQV9Piyo +q1kqbJan20jfJQUGTcX7FXHMUThzqJltHILR1GTW6I9z4k8xdsDY +-----END RSA PRIVATE KEY----- diff --git a/tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf b/tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4066b0a9889c2505d31b953487ac1d48fafaf9e9 GIT binary patch literal 202764 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
    &*0%>Ew${CQ^ppsZnSrpWw`S5|+16o-y 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 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/bundle/assets/images/animation.gif b/tests/unit_tests/fixtures/bundle/assets/images/animation.gif new file mode 100644 index 0000000000000000000000000000000000000000..9932e774483eb3516bec26187591b997e6d41859 GIT binary patch literal 9735 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 WpJ(0v^UUJ>&U5AKJ~uEhSOWldK6Y9F literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/bundle/assets/images/logo.png b/tests/unit_tests/fixtures/bundle/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bd2fd547833635ab465a796c9a936dcfdf4b3086 GIT binary patch literal 685 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSV36QoW?*1g)637sz`*c4z$e5tBjbR_9FGGv z1|4%&yeKd*FzIM$5D>`Wn31BfV2MXV%Z!ME28jTL1v3O94kRpCap1!ohXwSdn8;&?68Kv!P?kf*ErpDk3B@as&?KR3xO#*r1~@qh-Q@mW~4< z5-TJmG!AG~)KpC9$>`{j2#}G;@G$5o5r{}QP?1tl(ehx!o(U@^EO=q#kTIvBpkhVD zoP>gw2@xw24(MnEgaj;DBGD0{prE3_<}P)Qfq}8n)5S4F3z2@FlsK$s7;x*#cS2xIVzl27!7YGlpAJTWL~5zX7qB=y4|@V4$`mB_usE@ zUhls6(dk0vySo2gKXL9{DD`+3 z+ozL}c3Gb`eezyEZD-}ln?~oKXCFT$IrT}8!ehU46+6WYTU`7UEZ7~JSSndHJRIM2 u*M?6J3sPw9wJQkST)Ot_rLO&F^NY`J=VxKsf0u!Qfx*+&&t;ucLK6VvyDrWE literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/bundle/assets/web/custom.css b/tests/unit_tests/fixtures/bundle/assets/web/custom.css new file mode 100644 index 0000000000..992b81c80e --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/web/custom.css @@ -0,0 +1,2 @@ +/* Dummy CSS for bundle testing */ +body { color: red; } diff --git a/tests/unit_tests/fixtures/bundle/assets/web/custom.js b/tests/unit_tests/fixtures/bundle/assets/web/custom.js new file mode 100644 index 0000000000..9be8a6b2dc --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/web/custom.js @@ -0,0 +1,2 @@ +// Dummy JS for bundle testing +console.log("test"); diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml new file mode 100644 index 0000000000..f834a8d867 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -0,0 +1,60 @@ +esphome: + name: bundle-test + includes: + - includes/custom_sensor.h + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + <<: !include common/base.yaml + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + +ota: + - platform: esphome + password: !secret ota_password + +web_server: + port: 80 + css_include: assets/web/custom.css + js_include: assets/web/custom.js + +i2c: + sda: GPIO21 + scl: GPIO22 + +font: + - id: test_font + file: assets/fonts/test_font.ttf + size: 16 + +image: + - id: test_image + file: assets/images/logo.png + type: BINARY + resize: 16x16 + +animation: + - id: test_animation + file: assets/images/animation.gif + type: BINARY + resize: 16x16 + +display: + - platform: ssd1306_i2c + model: SSD1306_128X64 + address: 0x3C + lambda: |- + it.image(0, 0, id(test_image)); + +external_components: + - source: + type: local + path: local_components diff --git a/tests/unit_tests/fixtures/bundle/common/base.yaml b/tests/unit_tests/fixtures/bundle/common/base.yaml new file mode 100644 index 0000000000..58e1083e82 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/base.yaml @@ -0,0 +1 @@ +level: DEBUG diff --git a/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h b/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h new file mode 100644 index 0000000000..7f0ff474ee --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h @@ -0,0 +1,3 @@ +// Dummy custom sensor header for bundle testing +#pragma once +#include "esphome/core/component.h" diff --git a/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py b/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py new file mode 100644 index 0000000000..aa9fc1474b --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py @@ -0,0 +1 @@ +# Dummy local external component for bundle testing diff --git a/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h b/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h new file mode 100644 index 0000000000..19b89ecc82 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h @@ -0,0 +1,2 @@ +// Dummy component header for bundle testing +#pragma once diff --git a/tests/unit_tests/fixtures/bundle/secrets.yaml b/tests/unit_tests/fixtures/bundle/secrets.yaml new file mode 100644 index 0000000000..47acddb4d9 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/secrets.yaml @@ -0,0 +1,4 @@ +wifi_ssid: "TestNetwork" +wifi_password: "TestPassword123" +api_key: "unused_secret_should_not_appear" +ota_password: "ota_test_password" diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py new file mode 100644 index 0000000000..b8b2d0ffd1 --- /dev/null +++ b/tests/unit_tests/test_bundle.py @@ -0,0 +1,1210 @@ +"""Tests for esphome.bundle module.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +import tarfile +from typing import Any + +import pytest + +from esphome.bundle import ( + BUNDLE_EXTENSION, + CURRENT_MANIFEST_VERSION, + MANIFEST_FILENAME, + BundleManifest, + ConfigBundleCreator, + ManifestKey, + _add_bytes_to_tar, + _default_target_dir, + _find_used_secret_keys, + extract_bundle, + is_bundle_path, + prepare_bundle_for_compile, + read_bundle_manifest, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_bundle( + tmp_path: Path, + config_filename: str = "test.yaml", + config_content: str = "esphome:\n name: test\n", + manifest_overrides: dict[str, Any] | None = None, + extra_files: dict[str, bytes] | None = None, + *, + include_manifest: bool = True, + raw_members: list[tarfile.TarInfo] | None = None, +) -> Path: + """Create a minimal bundle tar.gz for testing.""" + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + if include_manifest: + manifest: dict[str, Any] = { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.ESPHOME_VERSION: "2026.2.0-test", + ManifestKey.CONFIG_FILENAME: config_filename, + ManifestKey.FILES: [config_filename], + ManifestKey.HAS_SECRETS: False, + } + if manifest_overrides: + manifest.update(manifest_overrides) + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + + _add_bytes_to_tar(tar, config_filename, config_content.encode()) + + if extra_files: + for name, data in extra_files.items(): + _add_bytes_to_tar(tar, name, data) + + if raw_members: + for info in raw_members: + tar.addfile(info, io.BytesIO(b"")) + + bundle_path.write_bytes(buf.getvalue()) + return bundle_path + + +def _setup_config_dir( + tmp_path: Path, + files: dict[str, str] | None = None, +) -> Path: + """Set up a fake config directory with files and configure CORE.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + config_yaml = "esphome:\n name: test\n" + (config_dir / "test.yaml").write_text(config_yaml) + + if files: + for rel_path, content in files.items(): + p = config_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + + CORE.config_path = config_dir / "test.yaml" + return config_dir + + +# --------------------------------------------------------------------------- +# is_bundle_path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + (f"my_device{BUNDLE_EXTENSION}", True), + (f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True), + ("my_device.yaml", False), + ("my_device.tar.gz", False), + ("my_device.zip", False), + ("", False), + ], +) +def test_is_bundle_path(filename: str, expected: bool) -> None: + assert is_bundle_path(Path(filename)) is expected + + +# --------------------------------------------------------------------------- +# _default_target_dir +# --------------------------------------------------------------------------- + + +def test_default_target_dir_strips_extension() -> None: + p = Path(f"/builds/device{BUNDLE_EXTENSION}") + result = _default_target_dir(p) + assert result == Path("/builds/device") + + +def test_default_target_dir_no_extension() -> None: + p = Path("/builds/device.other") + result = _default_target_dir(p) + assert result == Path("/builds/device.other") + + +# --------------------------------------------------------------------------- +# _find_used_secret_keys +# --------------------------------------------------------------------------- + + +def test_find_used_secret_keys(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n") + yaml2 = tmp_path / "b.yaml" + yaml2.write_text("api:\n key: !secret api_key\n") + + keys = _find_used_secret_keys([yaml1, yaml2]) + assert keys == {"wifi_ssid", "wifi_pw", "api_key"} + + +def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("esphome:\n name: test\n") + + keys = _find_used_secret_keys([yaml1]) + assert keys == set() + + +def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None: + missing = tmp_path / "does_not_exist.yaml" + keys = _find_used_secret_keys([missing]) + assert keys == set() + + +def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("a: !secret key1\nb: !secret key1\n") + + keys = _find_used_secret_keys([yaml1]) + assert keys == {"key1"} + + +# --------------------------------------------------------------------------- +# _add_bytes_to_tar +# --------------------------------------------------------------------------- + + +def test_add_bytes_to_tar_deterministic_metadata() -> None: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + _add_bytes_to_tar(tar, "hello.txt", b"world") + + buf.seek(0) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + member = tar.getmember("hello.txt") + assert member.size == 5 + assert member.mtime == 0 + assert member.uid == 0 + assert member.gid == 0 + assert member.mode == 0o644 + assert tar.extractfile(member).read() == b"world" + + +# --------------------------------------------------------------------------- +# ManifestKey +# --------------------------------------------------------------------------- + + +def test_manifest_key_values() -> None: + assert ManifestKey.MANIFEST_VERSION == "manifest_version" + assert ManifestKey.ESPHOME_VERSION == "esphome_version" + assert ManifestKey.CONFIG_FILENAME == "config_filename" + assert ManifestKey.FILES == "files" + assert ManifestKey.HAS_SECRETS == "has_secrets" + + +def test_manifest_key_is_str() -> None: + """Verify ManifestKey values work as dict keys and JSON keys.""" + d: dict[str, int] = {ManifestKey.MANIFEST_VERSION: 1} + assert d["manifest_version"] == 1 + + +# --------------------------------------------------------------------------- +# extract_bundle +# --------------------------------------------------------------------------- + + +def test_extract_bundle_basic(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "output" + + config_path = extract_bundle(bundle_path, target) + + assert config_path.is_file() + assert config_path.name == "test.yaml" + assert config_path.read_text().startswith("esphome:") + assert (target / MANIFEST_FILENAME).is_file() + + +def test_extract_bundle_default_target_dir(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + + config_path = extract_bundle(bundle_path) + + expected_dir = tmp_path / "device" + assert config_path.parent == expected_dir + + +def test_extract_bundle_missing_file(tmp_path: Path) -> None: + missing = tmp_path / f"missing{BUNDLE_EXTENSION}" + with pytest.raises(EsphomeError, match="Bundle file not found"): + extract_bundle(missing) + + +def test_extract_bundle_missing_manifest(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path, include_manifest=False) + with pytest.raises(EsphomeError, match="missing manifest.json"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_future_manifest_version(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: 999}, + ) + with pytest.raises(EsphomeError, match="newer than this ESPHome"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_missing_config_filename_in_manifest(tmp_path: Path) -> None: + """Manifest exists but is missing config_filename key.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = {ManifestKey.MANIFEST_VERSION: 1} + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="missing 'config_filename'"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_config_not_in_archive(tmp_path: Path) -> None: + """Manifest references a config file that isn't in the archive.""" + bundle_path = _make_bundle( + tmp_path, + config_filename="test.yaml", + manifest_overrides={ManifestKey.CONFIG_FILENAME: "missing.yaml"}, + ) + with pytest.raises(EsphomeError, match="was not found in the archive"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_with_extra_files(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + extra_files={ + "common/base.yaml": b"level: DEBUG\n", + "includes/sensor.h": b"#pragma once\n", + }, + ) + target = tmp_path / "out" + extract_bundle(bundle_path, target) + + assert (target / "common" / "base.yaml").read_text() == "level: DEBUG\n" + assert (target / "includes" / "sensor.h").read_text() == "#pragma once\n" + + +# --------------------------------------------------------------------------- +# extract_bundle - security validation +# --------------------------------------------------------------------------- + + +def test_extract_bundle_rejects_absolute_path(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="/etc/passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="absolute path"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_path_traversal(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="../../../etc/passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="path traversal"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_backslash_path_traversal(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="foo\\..\\..\\etc\\passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="path traversal"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_symlink(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="evil_link") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="symlink"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_oversized( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Archive whose total decompressed size exceeds the limit is rejected.""" + # Lower the limit so we don't need huge test data + monkeypatch.setattr("esphome.bundle.MAX_DECOMPRESSED_SIZE", 100) + + bundle_path = _make_bundle( + tmp_path, + extra_files={"big.bin": b"\x00" * 200}, + ) + + with pytest.raises(EsphomeError, match="decompressed size exceeds"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_corrupted_tar(tmp_path: Path) -> None: + """Corrupted tar file raises EsphomeError.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"not a tar file at all") + + with pytest.raises(EsphomeError, match="Failed to extract bundle"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_malformed_manifest_json(tmp_path: Path) -> None: + """Invalid JSON in manifest.json raises EsphomeError.""" + bundle_path = tmp_path / f"badjson{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + _add_bytes_to_tar(tar, MANIFEST_FILENAME, b"{invalid json") + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="malformed manifest.json"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_missing_manifest_version(tmp_path: Path) -> None: + """Manifest without manifest_version raises EsphomeError.""" + bundle_path = tmp_path / f"nover{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = {ManifestKey.CONFIG_FILENAME: "test.yaml"} + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="missing 'manifest_version'"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_invalid_manifest_version_type(tmp_path: Path) -> None: + """Non-integer manifest_version raises EsphomeError.""" + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: "not_an_int"}, + ) + + with pytest.raises(EsphomeError, match="must be a positive integer"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_version_zero(tmp_path: Path) -> None: + """manifest_version of 0 is rejected.""" + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: 0}, + ) + + with pytest.raises(EsphomeError, match="must be a positive integer"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_too_large( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Oversized manifest.json is rejected.""" + monkeypatch.setattr("esphome.bundle.MAX_MANIFEST_SIZE", 50) + + bundle_path = _make_bundle(tmp_path) + + with pytest.raises(EsphomeError, match="manifest.json too large"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_not_regular_file(tmp_path: Path) -> None: + """manifest.json that is a directory entry raises EsphomeError.""" + bundle_path = tmp_path / f"dirmanifest{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + # Add manifest.json as a directory instead of a file + dir_info = tarfile.TarInfo(name=MANIFEST_FILENAME) + dir_info.type = tarfile.DIRTYPE + dir_info.size = 0 + tar.addfile(dir_info) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="not a regular file"): + extract_bundle(bundle_path, tmp_path / "out") + + +# --------------------------------------------------------------------------- +# read_bundle_manifest +# --------------------------------------------------------------------------- + + +def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None: + """Corrupted tar file raises EsphomeError via read_bundle_manifest.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"not a tar file") + + with pytest.raises(EsphomeError, match="Failed to read bundle"): + read_bundle_manifest(bundle_path) + + +def test_read_bundle_manifest(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.HAS_SECRETS: True}, + extra_files={"secrets.yaml": b"wifi: test\n"}, + ) + + manifest = read_bundle_manifest(bundle_path) + + assert isinstance(manifest, BundleManifest) + assert manifest.manifest_version == CURRENT_MANIFEST_VERSION + assert manifest.esphome_version == "2026.2.0-test" + assert manifest.config_filename == "test.yaml" + assert manifest.has_secrets is True + + +def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: + """Manifest with only required fields.""" + bundle_path = tmp_path / f"min{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = { + ManifestKey.MANIFEST_VERSION: 1, + ManifestKey.CONFIG_FILENAME: "cfg.yaml", + } + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "cfg.yaml", b"") + bundle_path.write_bytes(buf.getvalue()) + + result = read_bundle_manifest(bundle_path) + assert result.esphome_version == "unknown" + assert result.files == [] + assert result.has_secrets is False + + +# --------------------------------------------------------------------------- +# prepare_bundle_for_compile +# --------------------------------------------------------------------------- + + +def test_prepare_bundle_preserves_build_cache(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "work" + target.mkdir() + + # Pre-existing build cache + esphome_dir = target / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "build_state.json").write_text('{"cached": true}') + + pio_dir = target / ".pioenvs" + pio_dir.mkdir() + (pio_dir / "firmware.bin").write_bytes(b"\x00" * 100) + + config_path = prepare_bundle_for_compile(bundle_path, target) + + assert config_path.is_file() + # Build caches should be preserved + assert (target / ".esphome" / "build_state.json").read_text() == '{"cached": true}' + assert (target / ".pioenvs" / "firmware.bin").read_bytes() == b"\x00" * 100 + + +def test_prepare_bundle_cleans_old_config(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "work" + target.mkdir() + + # Old config from previous extraction + (target / "old_config.yaml").write_text("old: true") + old_dir = target / "old_includes" + old_dir.mkdir() + (old_dir / "old.h").write_text("// old") + + prepare_bundle_for_compile(bundle_path, target) + + # Old files should be cleaned + assert not (target / "old_config.yaml").exists() + assert not (target / "old_includes").exists() + # New config should exist + assert (target / "test.yaml").is_file() + + +def test_prepare_bundle_missing_file(tmp_path: Path) -> None: + missing = tmp_path / f"missing{BUNDLE_EXTENSION}" + with pytest.raises(EsphomeError, match="Bundle file not found"): + prepare_bundle_for_compile(missing) + + +def test_prepare_bundle_cache_wins_over_bundle_content(tmp_path: Path) -> None: + """Pre-existing build cache is restored even if the bundle contains those dirs.""" + bundle_path = _make_bundle( + tmp_path, + extra_files={ + ".esphome/from_bundle.json": b'{"from": "bundle"}', + }, + ) + target = tmp_path / "work" + target.mkdir() + + # Pre-existing build cache + esphome_dir = target / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "local_cache.json").write_text('{"from": "local"}') + + prepare_bundle_for_compile(bundle_path, target) + + # Local cache should win over bundle content + assert (target / ".esphome" / "local_cache.json").read_text() == '{"from": "local"}' + assert not (target / ".esphome" / "from_bundle.json").exists() + + +def test_prepare_bundle_default_target_dir(tmp_path: Path) -> None: + """prepare_bundle_for_compile uses default dir when target_dir is None.""" + bundle_path = _make_bundle(tmp_path) + + config_path = prepare_bundle_for_compile(bundle_path) + + expected_dir = tmp_path / "device" + assert config_path.parent == expected_dir + assert config_path.is_file() + + +# --------------------------------------------------------------------------- +# ConfigBundleCreator - file discovery +# --------------------------------------------------------------------------- + + +def test_discover_files_includes_config(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_finds_path_objects(tmp_path: Path) -> None: + """Path objects in validated config are discovered.""" + config_dir = _setup_config_dir( + tmp_path, + files={"assets/font.ttf": "fake font data"}, + ) + + config: dict[str, Any] = {"font": [{"file": config_dir / "assets" / "font.ttf"}]} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "assets/font.ttf" in paths + + +def test_discover_files_finds_absolute_string_paths(tmp_path: Path) -> None: + """Absolute string paths in validated config are discovered.""" + config_dir = _setup_config_dir( + tmp_path, + files={"assets/logo.png": "fake png data"}, + ) + + abs_path = str(config_dir / "assets" / "logo.png") + config: dict[str, Any] = {"image": [{"file": abs_path}]} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "assets/logo.png" in paths + + +def test_discover_files_skips_non_path_prefixes(tmp_path: Path) -> None: + """Remote URLs and special prefixes are not treated as file paths.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "font": [ + {"file": "https://example.com/font.ttf"}, + {"file": "mdi:home"}, + {"file": "http://example.com/icon.png"}, + ] + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file itself + assert len(files) == 1 + assert files[0].path == "test.yaml" + + +def test_discover_files_skips_multiline_strings(tmp_path: Path) -> None: + """Lambda/template strings are not treated as file paths.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "sensor": [{"lambda": "auto val = id(sensor1);\nreturn val;"}] + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_deduplicates(tmp_path: Path) -> None: + """Same file referenced twice is only included once.""" + config_dir = _setup_config_dir( + tmp_path, + files={"cert.pem": "fake cert"}, + ) + + abs_path = str(config_dir / "cert.pem") + config: dict[str, Any] = { + "a": {"cert": abs_path}, + "b": {"cert": abs_path}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + cert_files = [f for f in files if f.path == "cert.pem"] + assert len(cert_files) == 1 + + +def test_discover_files_skips_outside_config_dir(tmp_path: Path) -> None: + """Files outside the config directory are skipped.""" + _setup_config_dir(tmp_path) + + outside_file = tmp_path / "outside.pem" + outside_file.write_text("outside cert") + + config: dict[str, Any] = {"cert": str(outside_file)} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "outside.pem" not in paths + + +def test_discover_files_esphome_includes(tmp_path: Path) -> None: + """Paths listed in esphome.includes are discovered.""" + _setup_config_dir( + tmp_path, + files={"my_sensor.h": "#pragma once\n"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["my_sensor.h"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_sensor.h" in paths + + +def test_discover_files_esphome_includes_directory(tmp_path: Path) -> None: + """esphome.includes pointing to a directory adds all files.""" + _setup_config_dir( + tmp_path, + files={ + "my_lib/a.h": "// a", + "my_lib/b.cpp": "// b", + }, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["my_lib"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_lib/a.h" in paths + assert "my_lib/b.cpp" in paths + + +def test_discover_files_esphome_includes_skips_system(tmp_path: Path) -> None: + """System includes like are not added.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "esphome": {"includes": [""]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert len(paths) == 1 # Just test.yaml + + +def test_discover_files_external_components_local(tmp_path: Path) -> None: + """external_components with type: local adds the directory.""" + _setup_config_dir( + tmp_path, + files={ + "components/my_comp/__init__.py": "# comp", + "components/my_comp/sensor.py": "# sensor", + }, + ) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": "components"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "components/my_comp/__init__.py" in paths + assert "components/my_comp/sensor.py" in paths + + +def test_discover_files_external_components_skips_pycache(tmp_path: Path) -> None: + """__pycache__ directories inside local external_components are excluded.""" + _setup_config_dir( + tmp_path, + files={ + "components/my_comp/__init__.py": "# comp", + "components/my_comp/__pycache__/module.cpython-313.pyc": "bytecode", + }, + ) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": "components"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "components/my_comp/__init__.py" in paths + assert not any("__pycache__" in p for p in paths) + + +def test_discover_files_external_components_non_dict_source(tmp_path: Path) -> None: + """external_components with string source (e.g. github shorthand) is skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [{"source": "github://user/repo@main"}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file itself - no crash from non-dict source + assert len(files) == 1 + assert files[0].path == "test.yaml" + + +def test_discover_files_nested_config_values(tmp_path: Path) -> None: + """Deeply nested Path objects in lists/dicts are found.""" + config_dir = _setup_config_dir( + tmp_path, + files={"deep/file.pem": "cert data"}, + ) + + config: dict[str, Any] = { + "level1": {"level2": [{"level3": config_dir / "deep" / "file.pem"}]} + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "deep/file.pem" in paths + + +def test_discover_files_idempotent_secrets(tmp_path: Path) -> None: + """Calling discover_files twice does not accumulate secrets paths.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("k: v\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + creator = ConfigBundleCreator({}) + files1 = creator.discover_files() + files2 = creator.discover_files() + + # Both calls should return the same result (secrets not accumulated) + paths1 = [f.path for f in files1] + paths2 = [f.path for f in files2] + assert "secrets.yaml" in paths1 + assert paths1 == paths2 + + +def test_discover_files_skips_missing_file(tmp_path: Path) -> None: + """_add_file logs warning for non-existent files via includes.""" + _setup_config_dir(tmp_path) + + # Include references a file that doesn't exist on disk + config: dict[str, Any] = { + "esphome": {"includes": ["nonexistent.h"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "nonexistent.h" not in paths + + +def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: + """_add_directory logs warning for non-existent directories.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [ + {"source": {"type": "local", "path": "nonexistent_dir"}} + ], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file + assert len(files) == 1 + + +def test_discover_files_yaml_reload_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """YAML reload failure during include discovery is handled gracefully.""" + _setup_config_dir(tmp_path) + + def _raise_error(*args, **kwargs): + raise EsphomeError("parse error") + + monkeypatch.setattr("esphome.yaml_util.load_yaml", _raise_error) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + # Should still have the config file at minimum + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_esphome_includes_c(tmp_path: Path) -> None: + """Paths listed in esphome.includes_c are discovered.""" + _setup_config_dir( + tmp_path, + files={"my_code.c": "// c code"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes_c": ["my_code.c"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_code.c" in paths + + +def test_discover_files_external_components_non_local_type(tmp_path: Path) -> None: + """external_components with type != 'local' are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [ + {"source": {"type": "git", "url": "https://github.com/user/repo"}} + ], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_external_components_no_path(tmp_path: Path) -> None: + """external_components with local type but missing path are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_external_components_absolute_path(tmp_path: Path) -> None: + """external_components with absolute path are resolved correctly.""" + config_dir = _setup_config_dir( + tmp_path, + files={"ext/comp/__init__.py": "# comp"}, + ) + + abs_path = str(config_dir / "ext") + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": abs_path}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "ext/comp/__init__.py" in paths + + +def test_discover_files_relative_string_with_known_extension(tmp_path: Path) -> None: + """Relative strings with known extensions are resolved and warned.""" + _setup_config_dir( + tmp_path, + files={"my_cert.pem": "cert data"}, + ) + + config: dict[str, Any] = { + "component": {"cert": "my_cert.pem"}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_cert.pem" in paths + + +def test_discover_files_relative_string_missing_file(tmp_path: Path) -> None: + """Relative strings with known extensions that don't exist are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "component": {"cert": "nonexistent.pem"}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_esphome_includes_absolute_path(tmp_path: Path) -> None: + """esphome.includes with absolute path is handled.""" + config_dir = _setup_config_dir( + tmp_path, + files={"my_code.h": "#pragma once"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes": [str(config_dir / "my_code.h")]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_code.h" in paths + + +def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: + """Tuples in config are walked like lists.""" + config_dir = _setup_config_dir( + tmp_path, + files={"a.pem": "cert"}, + ) + + config: dict[str, Any] = { + "items": (config_dir / "a.pem",), + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "a.pem" in paths + + +# --------------------------------------------------------------------------- +# ConfigBundleCreator - create_bundle +# --------------------------------------------------------------------------- + + +def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + + # Verify it's a valid tar.gz + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + names = tar.getnames() + assert MANIFEST_FILENAME in names + assert "test.yaml" in names + + +def test_create_bundle_manifest_content(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + manifest = result.manifest + assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION + assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml" + assert "test.yaml" in manifest[ManifestKey.FILES] + + +def test_create_bundle_filters_secrets(tmp_path: Path) -> None: + config_dir = _setup_config_dir(tmp_path) + + # Create secrets.yaml with multiple secrets + secrets = config_dir / "secrets.yaml" + secrets.write_text( + "wifi_ssid: MyNetwork\nwifi_pw: secret123\nunused: should_not_appear\n" + ) + + # Config that references only some secrets + config_yaml = "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n" + (config_dir / "test.yaml").write_text(config_yaml) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + # Extract and check secrets + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + + assert "wifi_ssid" in secrets_data + assert "wifi_pw" in secrets_data + assert "unused" not in secrets_data + assert "should_not_appear" not in secrets_data + + +def test_create_bundle_no_secrets(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_load_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Secrets file that fails to load during filtering is skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("k: v\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + from esphome import yaml_util as yu + + original_load = yu.load_yaml + + def _failing_on_filter(fname, *args, clear_secrets=True, **kwargs): + # Fail only when _build_filtered_secrets calls with clear_secrets=False + if not clear_secrets and "secrets" in str(fname): + raise EsphomeError("corrupt secrets") + return original_load(fname, *args, clear_secrets=clear_secrets, **kwargs) + + monkeypatch.setattr(yu, "load_yaml", _failing_on_filter) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + # Should succeed without secrets since the filtered load failed + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_non_dict(tmp_path: Path) -> None: + """Secrets file that parses to non-dict is skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("- item1\n- item2\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_no_matching_keys(tmp_path: Path) -> None: + """Secrets with no matching keys produces empty filtered result.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("other_key: value\n") + (config_dir / "test.yaml").write_text("a: !secret nonexistent\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_deterministic_order(tmp_path: Path) -> None: + """Files are added in sorted order for reproducibility.""" + _setup_config_dir( + tmp_path, + files={ + "z_last.h": "// z", + "a_first.h": "// a", + "m_middle.h": "// m", + }, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["z_last.h", "a_first.h", "m_middle.h"]}, + } + creator = ConfigBundleCreator(config) + result = creator.create_bundle() + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + names = tar.getnames() + + # manifest.json is always first, then files in sorted order + assert names[0] == MANIFEST_FILENAME + file_names = [n for n in names if n != MANIFEST_FILENAME] + assert file_names == sorted(file_names) + + +# --------------------------------------------------------------------------- +# Round-trip: create then extract +# --------------------------------------------------------------------------- + + +def test_bundle_round_trip(tmp_path: Path) -> None: + """A bundle created by ConfigBundleCreator can be extracted.""" + _setup_config_dir( + tmp_path, + files={"include.h": "#pragma once\n"}, + ) + config: dict[str, Any] = {"esphome": {"includes": ["include.h"]}} + + creator = ConfigBundleCreator(config) + result = creator.create_bundle() + + bundle_path = tmp_path / f"roundtrip{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + + target = tmp_path / "extracted" + config_path = extract_bundle(bundle_path, target) + + assert config_path.is_file() + assert (target / "include.h").read_text() == "#pragma once\n" + + manifest = read_bundle_manifest(bundle_path) + assert manifest.config_filename == "test.yaml" + assert "include.h" in manifest.files + + +def test_bundle_round_trip_with_secrets(tmp_path: Path) -> None: + """Secrets survive round-trip with correct filtering.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("key1: val1\nkey2: val2\nunused: nope\n") + (config_dir / "test.yaml").write_text("a: !secret key1\nb: !secret key2\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + bundle_path = tmp_path / f"secrets{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + + target = tmp_path / "extracted" + extract_bundle(bundle_path, target) + + secrets_content = (target / "secrets.yaml").read_text() + assert "key1" in secrets_content + assert "key2" in secrets_content + assert "unused" not in secrets_content + + manifest = read_bundle_manifest(bundle_path) + assert manifest.has_secrets is True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 115ce38c93..85536d2f1c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, choose_upload_log_host, command_analyze_memory, + command_bundle, command_clean_all, command_rename, command_update_all, @@ -47,6 +48,7 @@ from esphome.__main__ import ( upload_using_picotool, upload_using_platformio, ) +from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, @@ -1101,6 +1103,8 @@ class MockArgs: name: str | None = None dashboard: bool = False reset: bool = False + list_only: bool = False + output: str | None = None def test_upload_program_serial_esp32( @@ -3765,6 +3769,198 @@ esp32: assert "secrets.yaml" not in summary_section +# --- command_bundle tests --- + + +def test_command_bundle_list_only( + tmp_path: Path, + capsys: CaptureFixture[str], +) -> None: + """Test command_bundle with --list-only prints files and returns 0.""" + mock_files = [ + BundleFile(path="device.yaml", source=tmp_path / "device.yaml"), + BundleFile(path="secrets.yaml", source=tmp_path / "secrets.yaml"), + BundleFile(path="common/base.yaml", source=tmp_path / "common" / "base.yaml"), + ] + + args = MockArgs(list_only=True) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.discover_files.return_value = mock_files + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + captured = capsys.readouterr() + # Files should be printed in sorted order + assert "common/base.yaml" in captured.out + assert "device.yaml" in captured.out + assert "secrets.yaml" in captured.out + + +def test_command_bundle_list_only_empty( + tmp_path: Path, + capsys: CaptureFixture[str], +) -> None: + """Test command_bundle --list-only with no files discovered.""" + args = MockArgs(list_only=True) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.discover_files.return_value = [] + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + + +def test_command_bundle_creates_archive(tmp_path: Path) -> None: + """Test command_bundle creates archive at default output path.""" + CORE.config_path = tmp_path / "mydevice.yaml" + + mock_result = BundleResult( + data=b"fake-tar-gz-data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs() + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + output_path = tmp_path / f"mydevice{BUNDLE_EXTENSION}" + assert output_path.exists() + assert output_path.read_bytes() == b"fake-tar-gz-data" + + +def test_command_bundle_custom_output(tmp_path: Path) -> None: + """Test command_bundle with -o custom output path.""" + custom_output = tmp_path / "output" / "custom.esphomebundle.tar.gz" + mock_result = BundleResult( + data=b"custom-output-data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs(output=str(custom_output)) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + assert custom_output.exists() + assert custom_output.read_bytes() == b"custom-output-data" + + +def test_command_bundle_creates_parent_dirs(tmp_path: Path) -> None: + """Test command_bundle creates parent directories for output path.""" + nested_output = tmp_path / "deep" / "nested" / "dir" / "out.tar.gz" + mock_result = BundleResult( + data=b"data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs(output=str(nested_output)) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + assert nested_output.exists() + + +def test_command_bundle_logs_info( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_bundle logs bundle creation info.""" + CORE.config_path = tmp_path / "mydevice.yaml" + + mock_result = BundleResult( + data=b"x" * 2048, + manifest={"manifest_version": 1}, + files=[ + BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml"), + BundleFile(path="secrets.yaml", source=tmp_path / "secrets.yaml"), + ], + ) + + args = MockArgs() + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with ( + patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator), + caplog.at_level(logging.INFO), + ): + result = command_bundle(args, config) + + assert result == 0 + assert "Bundle created" in caplog.text + assert "2 files" in caplog.text + assert "2.0 KB" in caplog.text + + +def test_run_esphome_bundle_detection(tmp_path: Path) -> None: + """Test run_esphome detects .esphomebundle.tar.gz and extracts it.""" + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"fake-bundle") + + extracted_yaml = tmp_path / "extracted" / "device.yaml" + + with ( + patch("esphome.bundle.is_bundle_path", return_value=True) as mock_is_bundle, + patch( + "esphome.bundle.prepare_bundle_for_compile", + return_value=extracted_yaml, + ) as mock_prepare, + patch("esphome.__main__.read_config", return_value=None), + ): + result = run_esphome(["esphome", "compile", str(bundle_path)]) + + mock_is_bundle.assert_called_once() + mock_prepare.assert_called_once_with(bundle_path) + # read_config returns None → exit code 2 + assert result == 2 + + +def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: + """Test run_esphome does not extract for regular .yaml files.""" + yaml_file = tmp_path / "device.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + with ( + patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, + patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, + patch("esphome.__main__.read_config", return_value=None), + ): + result = run_esphome(["esphome", "compile", str(yaml_file)]) + + mock_is_bundle.assert_called_once() + mock_prepare.assert_not_called() + assert result == 2 + + def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 667b593819..0342d12540 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -323,6 +323,60 @@ def test_dump_sort_keys() -> None: assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:") +# --------------------------------------------------------------------------- +# track_yaml_loads +# --------------------------------------------------------------------------- + + +def test_track_yaml_loads_records_files(tmp_path: Path) -> None: + """track_yaml_loads records every file loaded inside the context.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + + with yaml_util.track_yaml_loads() as loaded: + yaml_util.load_yaml(yaml_file) + + assert len(loaded) == 1 + assert loaded[0] == yaml_file.resolve() + + +def test_track_yaml_loads_records_includes(tmp_path: Path) -> None: + """track_yaml_loads records nested !include files.""" + inc = tmp_path / "included.yaml" + inc.write_text("included_key: 42\n") + main = tmp_path / "main.yaml" + main.write_text("child: !include included.yaml\n") + + with yaml_util.track_yaml_loads() as loaded: + yaml_util.load_yaml(main) + + resolved = [p.name for p in loaded] + assert "main.yaml" in resolved + assert "included.yaml" in resolved + + +def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: + """Files loaded outside the context are not recorded.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + + with yaml_util.track_yaml_loads() as loaded: + pass # load nothing inside + + yaml_util.load_yaml(yaml_file) + assert loaded == [] + + +def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: + """Listener is removed even if the body raises.""" + before = len(yaml_util._load_listeners) + + with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): + raise RuntimeError("boom") + + assert len(yaml_util._load_listeners) == before + + @pytest.mark.parametrize( "data", [ From ac14b9e5584d8c2bea962529522aaa46df8a41e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:40:21 -1000 Subject: [PATCH 05/25] Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#15541) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba6db99b84..9e8a040888 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true From 9d396cea5a3d5ad5d1ae50f29c81825b9d5c3120 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:56:25 -0400 Subject: [PATCH 06/25] [grove_tb6612fng] Move direction logic from Python to C++ to fix lambda crash (#15513) --- esphome/components/grove_tb6612fng/__init__.py | 4 +--- esphome/components/grove_tb6612fng/grove_tb6612fng.h | 10 +++++++++- tests/components/grove_tb6612fng/common.yaml | 5 +++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 210e2f7bab..27a47953b3 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -80,11 +80,9 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): template_channel = await cg.templatable(config[CONF_CHANNEL], args, int) template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16) - template_speed = ( - template_speed if config[CONF_DIRECTION] == "FORWARD" else -template_speed - ) cg.add(var.set_channel(template_channel)) cg.add(var.set_speed(template_speed)) + cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD")) return var diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index a36cb85cff..bf47163226 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -168,11 +168,19 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parentedforward_ = forward; } + void play(const Ts &...x) override { auto channel = this->channel_.value(x...); - auto speed = this->speed_.value(x...); + int16_t speed = this->speed_.value(x...); + if (!this->forward_) { + speed = -speed; + } this->parent_->dc_motor_run(channel, speed); } + + protected: + bool forward_{true}; }; template diff --git a/tests/components/grove_tb6612fng/common.yaml b/tests/components/grove_tb6612fng/common.yaml index 52d5ead96e..7c6d65e9a6 100644 --- a/tests/components/grove_tb6612fng/common.yaml +++ b/tests/components/grove_tb6612fng/common.yaml @@ -6,6 +6,11 @@ esphome: speed: 255 direction: BACKWARD id: test_motor + - grove_tb6612fng.run: + channel: 0 + speed: !lambda "return 200;" + direction: BACKWARD + id: test_motor - grove_tb6612fng.stop: channel: 1 id: test_motor From 186525e77d127234887b6a9c05aa3a4b4136134c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:57:26 -0400 Subject: [PATCH 07/25] [ld2420] Fix select options wrapped in extra list (#15524) --- esphome/components/ld2420/select/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index 6ccc00b41c..3d078eba68 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -28,7 +28,7 @@ async def to_code(config): if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( operating_mode_config, - options=[CONF_SELECTS], + options=CONF_SELECTS, ) await cg.register_parented(sel, config[CONF_LD2420_ID]) cg.add(LD2420_component.set_operating_mode_select(sel)) From 687753b0bebe95b0510f32f932042d7571f7a080 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:03:55 -0400 Subject: [PATCH 08/25] [lightwaverf] Fix write pin using input schema instead of output (#15525) --- esphome/components/lightwaverf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index acbbbb4de9..46c400cb0e 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LIGHTWAVERFComponent), cv.Optional(CONF_READ_PIN, default=13): pins.internal_gpio_input_pin_schema, - cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_output_pin_schema, } ).extend(cv.polling_component_schema("1s")) From 17ec5389d88e9562eee2fcc80acdbe44b8e73cf7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:07:28 -0400 Subject: [PATCH 09/25] [mcp4461] Fix terminal disable passing string where C++ expects char (#15528) --- esphome/components/mcp4461/output/__init__.py | 6 +++--- tests/components/mcp4461/common.yaml | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 02bdbefed5..0d145d81d3 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -48,11 +48,11 @@ async def to_code(config): config[CONF_CHANNEL], ) if not config[CONF_TERMINAL_A]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "a")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("a"))) if not config[CONF_TERMINAL_B]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "b")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("b"))) if not config[CONF_TERMINAL_W]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "w")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("w"))) if CONF_INITIAL_VALUE in config: cg.add( parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) diff --git a/tests/components/mcp4461/common.yaml b/tests/components/mcp4461/common.yaml index 92fd789dcb..71e2528aa4 100644 --- a/tests/components/mcp4461/common.yaml +++ b/tests/components/mcp4461/common.yaml @@ -22,3 +22,11 @@ output: id: digipot_wiper_4 mcp4461_id: mcp4461_digipot_01 channel: D + + - platform: mcp4461 + id: digipot_wiper_5 + mcp4461_id: mcp4461_digipot_01 + channel: A + terminal_a: false + terminal_b: false + terminal_w: false From d354747da041f4189c6fd17416429d9735d119c7 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:10:56 +0200 Subject: [PATCH 10/25] [nextion] Fix format specifiers and error message typos in command handlers (#15542) --- esphome/components/nextion/nextion.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4a15cbe64f..6b806e0988 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -706,7 +706,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { ESP_LOGE(TAG, "Bad switch data (0x90)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -732,7 +732,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) != 4) { ESP_LOGE(TAG, "Bad sensor data (0x91)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -765,7 +765,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { ESP_LOGE(TAG, "Bad text data (0x92)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -798,8 +798,8 @@ void Nextion::process_nextion_commands_() { // Get variable name auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { - ESP_LOGE(TAG, "Bad binary data (0x92)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGE(TAG, "Bad binary data (0x93)"); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } From 2fe6cb392bd687c21f9967f042d7927391ba8217 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:40:43 -0400 Subject: [PATCH 11/25] [rotary_encoder] Fix set_value action accepting any sensor ID (#15535) --- esphome/components/rotary_encoder/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 20c757f093..246db023f4 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -120,7 +120,7 @@ async def to_code(config): RotaryEncoderSetValueAction, cv.Schema( { - cv.Required(CONF_ID): cv.use_id(sensor.Sensor), + cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor), cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), From 4ebfe71b8fa5cc9eb8c2ce2a1db31a86182dbc3b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:42:33 -0400 Subject: [PATCH 12/25] [seeed_mr24hpc1] Move baud rate validation to FINAL_VALIDATE_SCHEMA (#15536) --- esphome/components/seeed_mr24hpc1/__init__.py | 1 + esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index e80470bde1..f71239d18c 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -33,6 +33,7 @@ CONFIG_SCHEMA = ( # This authentication mode requires that the device must have transmit and receive functionality, a parity mode of "NONE", and a stop bit of one. FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "seeed_mr24hpc1", + baud_rate=115200, require_tx=True, require_rx=True, parity="NONE", diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index c9fe3a2e6e..b44c5ce83d 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -62,8 +62,6 @@ void MR24HPC1Component::dump_config() { // Initialisation functions void MR24HPC1Component::setup() { - this->check_uart_settings(115200); - #ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); // Zero out the custom mode From 3ca3cdc5e20b17b1506eff4b8dda26a453ee80cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:44:28 -0400 Subject: [PATCH 13/25] [multiple] Fix missing entity base classes in Python class declarations (#15534) --- esphome/components/bh1900nux/sensor.py | 2 +- esphome/components/gl_r01_i2c/sensor.py | 2 +- esphome/components/ld2420/select/__init__.py | 2 +- esphome/components/sdp3x/sensor.py | 5 ++++- esphome/components/sen0321/sensor.py | 2 +- esphome/components/sen21231/sensor.py | 2 +- esphome/components/tc74/sensor.py | 4 +++- 7 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/bh1900nux/sensor.py b/esphome/components/bh1900nux/sensor.py index 5e1c0395af..a70db3555a 100644 --- a/esphome/components/bh1900nux/sensor.py +++ b/esphome/components/bh1900nux/sensor.py @@ -12,7 +12,7 @@ CODEOWNERS = ["@B48D81EFCC"] sensor_ns = cg.esphome_ns.namespace("bh1900nux") BH1900NUXSensor = sensor_ns.class_( - "BH1900NUXSensor", cg.PollingComponent, i2c.I2CDevice + "BH1900NUXSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/gl_r01_i2c/sensor.py b/esphome/components/gl_r01_i2c/sensor.py index 58db72540e..6a8d47213c 100644 --- a/esphome/components/gl_r01_i2c/sensor.py +++ b/esphome/components/gl_r01_i2c/sensor.py @@ -13,7 +13,7 @@ DEPENDENCIES = ["i2c"] gl_r01_i2c_ns = cg.esphome_ns.namespace("gl_r01_i2c") GLR01I2CComponent = gl_r01_i2c_ns.class_( - "GLR01I2CComponent", i2c.I2CDevice, cg.PollingComponent + "GLR01I2CComponent", sensor.Sensor, i2c.I2CDevice, cg.PollingComponent ) CONFIG_SCHEMA = ( diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index 3d078eba68..b9059c120f 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -12,7 +12,7 @@ CONF_SELECTS = [ "Simple", ] -LD2420Select = ld2420_ns.class_("LD2420Select", cg.Component) +LD2420Select = ld2420_ns.class_("LD2420Select", select.Select, cg.Component) CONFIG_SCHEMA = { cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component), diff --git a/esphome/components/sdp3x/sensor.py b/esphome/components/sdp3x/sensor.py index 169ed374ed..be2eec7baf 100644 --- a/esphome/components/sdp3x/sensor.py +++ b/esphome/components/sdp3x/sensor.py @@ -14,7 +14,10 @@ CODEOWNERS = ["@Azimath"] sdp3x_ns = cg.esphome_ns.namespace("sdp3x") SDP3XComponent = sdp3x_ns.class_( - "SDP3XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice + "SDP3XComponent", + sensor.Sensor, + cg.PollingComponent, + sensirion_common.SensirionI2CDevice, ) diff --git a/esphome/components/sen0321/sensor.py b/esphome/components/sen0321/sensor.py index e1c1d4e94b..3910e6e4c9 100644 --- a/esphome/components/sen0321/sensor.py +++ b/esphome/components/sen0321/sensor.py @@ -12,7 +12,7 @@ DEPENDENCIES = ["i2c"] sen0321_sensor_ns = cg.esphome_ns.namespace("sen0321_sensor") Sen0321Sensor = sen0321_sensor_ns.class_( - "Sen0321Sensor", cg.PollingComponent, i2c.I2CDevice + "Sen0321Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/sen21231/sensor.py b/esphome/components/sen21231/sensor.py index 52cecbfb69..781a1213ac 100644 --- a/esphome/components/sen21231/sensor.py +++ b/esphome/components/sen21231/sensor.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["i2c"] sen21231_sensor_ns = cg.esphome_ns.namespace("sen21231_sensor") Sen21231Sensor = sen21231_sensor_ns.class_( - "Sen21231Sensor", cg.PollingComponent, i2c.I2CDevice + "Sen21231Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/tc74/sensor.py b/esphome/components/tc74/sensor.py index 18fc2d9a42..18a94016fb 100644 --- a/esphome/components/tc74/sensor.py +++ b/esphome/components/tc74/sensor.py @@ -11,7 +11,9 @@ CODEOWNERS = ["@sethgirvan"] DEPENDENCIES = ["i2c"] tc74_ns = cg.esphome_ns.namespace("tc74") -TC74Component = tc74_ns.class_("TC74Component", cg.PollingComponent, i2c.I2CDevice) +TC74Component = tc74_ns.class_( + "TC74Component", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice +) CONFIG_SCHEMA = ( sensor.sensor_schema( From 5a52936f7281a1e044c8f43833b648f8d4c826f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:52:33 -0400 Subject: [PATCH 14/25] [graph] Fix legend config incorrectly accepting a list (#15522) --- esphome/components/graph/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index d72fe40dd2..0749d7e2a3 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -110,7 +110,7 @@ GRAPH_SCHEMA = cv.Schema( cv.Optional(CONF_MIN_RANGE): cv.float_range(min=0, min_included=False), cv.Optional(CONF_MAX_RANGE): cv.float_range(min=0, min_included=False), cv.Optional(CONF_TRACES): cv.ensure_list(GRAPH_TRACE_SCHEMA), - cv.Optional(CONF_LEGEND): cv.ensure_list(GRAPH_LEGEND_SCHEMA), + cv.Optional(CONF_LEGEND): GRAPH_LEGEND_SCHEMA, } ) @@ -192,7 +192,7 @@ async def to_code(config): cg.add(var.add_trace(tr)) # Add legend if CONF_LEGEND in config: - lgd = config[CONF_LEGEND][0] + lgd = config[CONF_LEGEND] legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) if CONF_NAME_FONT in lgd: font = await cg.get_variable(lgd[CONF_NAME_FONT]) From 3073f3ec5c09cfff14867d140faa590000aae270 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:53:16 -0400 Subject: [PATCH 15/25] [haier] Fix control_method schema incorrectly using ensure_list (#15523) --- esphome/components/haier/climate.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index d485c1d5d4..424ef46392 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -215,9 +215,7 @@ CONFIG_SCHEMA = cv.All( { cv.Optional( CONF_CONTROL_METHOD, default="SET_GROUP_PARAMETERS" - ): cv.ensure_list( - cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True) - ), + ): cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True), cv.Optional(CONF_BEEPER): cv.invalid( f"The {CONF_BEEPER} option is deprecated, use beeper_on/beeper_off actions or beeper switch for a haier platform instead" ), From cbcf80081b9cf5c5adf6cbcf06e930c363cecb9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:54:12 -0400 Subject: [PATCH 16/25] [pcf8563] Fix default I2C address from 8-bit (0xA3) to 7-bit (0x51) (#15526) --- esphome/components/pcf8563/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 0d4de3cb73..1502158c29 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -21,7 +21,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(pcf8563Component), } -).extend(i2c.i2c_device_schema(0xA3)) +).extend(i2c.i2c_device_schema(0x51)) @automation.register_action( From e7ddc6f6d39c720d5ea667f7cd76bba2858ddc34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:54:57 -0400 Subject: [PATCH 17/25] [multiple] Fix validation ranges (batch 2) (#15533) --- esphome/components/dsmr/__init__.py | 2 +- esphome/components/hlk_fm22x/__init__.py | 2 +- esphome/components/micronova/button/__init__.py | 2 +- esphome/components/micronova/switch/__init__.py | 8 ++++++-- esphome/components/pca6416a/__init__.py | 2 +- esphome/components/pcf8574/__init__.py | 2 +- esphome/components/xiaomi_mue4094rt/binary_sensor.py | 8 +++++--- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index dd7f2b9f56..9c493bfcff 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, cv.Optional(CONF_THERMAL_MBUS_ID, default=3): cv.int_, - cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_, + cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_range(min=1), cv.Optional(CONF_REQUEST_PIN): pins.gpio_output_pin_schema, cv.Optional( CONF_REQUEST_INTERVAL, default="0ms" diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index 8f55d5dc08..c1aa81f6d4 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -131,7 +131,7 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), - cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t), + cv.Required(CONF_FACE_ID): cv.templatable(cv.int_range(min=0, max=32767)), }, key=CONF_FACE_ID, ), diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 1ef359ea6c..63b127e63d 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( is_polling_component=False, ) ) - .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}), + .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range(min=0x00, max=0xFF)}), } ) diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index d9722b5d48..e149ee3ce3 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -37,8 +37,12 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( { - cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range(), - cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range(), + cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range( + min=0x00, max=0xFF + ), + cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range( + min=0x00, max=0xFF + ), } ), } diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index e540edb91f..b6e156e7ff 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -51,7 +51,7 @@ PCA6416A_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=16), + cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { cv.Optional(CONF_INPUT, default=False): cv.boolean, diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 902efd2279..d8a1e20db6 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -55,7 +55,7 @@ def validate_mode(value): PCF8574_PIN_SCHEMA = pins.gpio_base_schema( PCF8574GPIOPin, - cv.int_range(min=0, max=17), + cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], mode_validator=validate_mode, invertible=True, diff --git a/esphome/components/xiaomi_mue4094rt/binary_sensor.py b/esphome/components/xiaomi_mue4094rt/binary_sensor.py index 911d179d8b..c5d93384c9 100644 --- a/esphome/components/xiaomi_mue4094rt/binary_sensor.py +++ b/esphome/components/xiaomi_mue4094rt/binary_sensor.py @@ -1,3 +1,4 @@ +from esphome import core import esphome.codegen as cg from esphome.components import binary_sensor, esp32_ble_tracker import esphome.config_validation as cv @@ -21,9 +22,10 @@ CONFIG_SCHEMA = cv.All( .extend( { cv.Required(CONF_MAC_ADDRESS): cv.mac_address, - cv.Optional( - CONF_TIMEOUT, default="5s" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIMEOUT, default="5s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=65535)), + ), } ) .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) From 97ad5ab35fd0432851fdcdb0ebfa474f176fbc12 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:56:01 -0400 Subject: [PATCH 18/25] [udp] Fix on_receive only processing first automation (#15538) --- esphome/components/udp/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 17bbf19c9e..5dfd188f0f 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -130,12 +130,9 @@ async def to_code(config): if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) - if on_receive := config.get(CONF_ON_RECEIVE): - on_receive = on_receive[0] - trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID]) - trigger = await automation.build_automation( - trigger_id, trigger_argtype, on_receive - ) + for conf in config.get(CONF_ON_RECEIVE, []): + trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + trigger = await automation.build_automation(trigger_id, trigger_argtype, conf) trigger_lambda = await cg.process_lambda( trigger.trigger( cg.std_vector.template(cg.uint8)( @@ -146,6 +143,7 @@ async def to_code(config): listener_argtype, ) cg.add(var.add_listener(trigger_lambda)) + if config.get(CONF_ON_RECEIVE): cg.add(var.set_should_listen()) From 9fe4d5c63db19b0166de6be7eb1dabea4761ccef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:56:50 -0400 Subject: [PATCH 19/25] [rp2040_pio_led_strip][rp2040_pio] Fix CUSTOM chipset crash and improve error message (#15537) --- esphome/components/rp2040_pio/__init__.py | 9 ++++++++- esphome/components/rp2040_pio_led_strip/light.py | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2040_pio/__init__.py index 4bd46731df..eecfedaa75 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2040_pio/__init__.py @@ -1,6 +1,7 @@ import platform import esphome.codegen as cg +import esphome.config_validation as cv DEPENDENCIES = ["rp2040"] @@ -31,7 +32,13 @@ async def to_code(config): # "earlephilhower/tool-pioasm-rp2040-earlephilhower", # ], # ) - file = PIOASM_DOWNLOADS[platform.system().lower()][platform.machine().lower()] + os_name = platform.system().lower() + arch = platform.machine().lower() + if os_name not in PIOASM_DOWNLOADS or arch not in PIOASM_DOWNLOADS[os_name]: + raise cv.Invalid( + f"pioasm is not available for {platform.system()} {platform.machine()}" + ) + file = PIOASM_DOWNLOADS[os_name][arch] cg.add_platformio_option( "platform_packages", [f"earlephilhower/tool-pioasm-rp2040-earlephilhower@{PIOASM_REPO_BASE}/{file}"], diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 62f7fffdc9..274f059bd5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -148,7 +148,6 @@ CHIPSETS = { "WS2812B": Chipset.CHIPSET_WS2812B, "SK6812": Chipset.CHIPSET_SK6812, "SM16703": Chipset.CHIPSET_SM16703, - "CUSTOM": Chipset.CHIPSET_CUSTOM, } From 5d31f4aeba5376e09ae7ad42025030b28404e0f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 12:00:17 -1000 Subject: [PATCH 20/25] [light] Use function-pointer fields in LightControlAction (#15132) --- esphome/components/light/automation.h | 61 ++++---- esphome/components/light/automation.py | 97 ++++++------ .../fixtures/light_control_action.yaml | 139 ++++++++++++++++++ .../integration/test_light_control_action.py | 95 ++++++++++++ 4 files changed, 320 insertions(+), 72 deletions(-) create mode 100644 tests/integration/fixtures/light_control_action.yaml create mode 100644 tests/integration/test_light_control_action.py diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2854bc62d9..a5c9220a23 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,46 +24,51 @@ template class ToggleAction : public Action { LightState *state_; }; +/// Compact light control action — each field is a function pointer (nullptr = unset). +/// Codegen wraps constants in stateless lambdas. 72 bytes vs 128 with TemplatableValue. template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(ColorMode, color_mode) - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(uint32_t, transition_length) - TEMPLATABLE_VALUE(uint32_t, flash_length) - TEMPLATABLE_VALUE(float, brightness) - TEMPLATABLE_VALUE(float, color_brightness) - TEMPLATABLE_VALUE(float, red) - TEMPLATABLE_VALUE(float, green) - TEMPLATABLE_VALUE(float, blue) - TEMPLATABLE_VALUE(float, white) - TEMPLATABLE_VALUE(float, color_temperature) - TEMPLATABLE_VALUE(float, cold_white) - TEMPLATABLE_VALUE(float, warm_white) - TEMPLATABLE_VALUE(uint32_t, effect) +#define LIGHT_CONTROL_FIELDS(X) \ + X(ColorMode, color_mode) \ + X(bool, state) \ + X(uint32_t, transition_length) \ + X(uint32_t, flash_length) \ + X(float, brightness) \ + X(float, color_brightness) \ + X(float, red) \ + X(float, green) \ + X(float, blue) \ + X(float, white) \ + X(float, color_temperature) \ + X(float, cold_white) \ + X(float, warm_white) \ + X(uint32_t, effect) + +#define LIGHT_FIELD_SETTER_(type, name) \ + void set_##name(type (*f)(Ts...)) { this->name##_ = f; } +#define LIGHT_FIELD_APPLY_(type, name) \ + if (this->name##_) \ + call.set_##name(this->name##_(x...)); +#define LIGHT_FIELD_DECL_(type, name) type (*name##_)(Ts...){nullptr}; + + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) void play(const Ts &...x) override { auto call = this->parent_->make_call(); - call.set_color_mode(this->color_mode_.optional_value(x...)); - call.set_state(this->state_.optional_value(x...)); - call.set_brightness(this->brightness_.optional_value(x...)); - call.set_color_brightness(this->color_brightness_.optional_value(x...)); - call.set_red(this->red_.optional_value(x...)); - call.set_green(this->green_.optional_value(x...)); - call.set_blue(this->blue_.optional_value(x...)); - call.set_white(this->white_.optional_value(x...)); - call.set_color_temperature(this->color_temperature_.optional_value(x...)); - call.set_cold_white(this->cold_white_.optional_value(x...)); - call.set_warm_white(this->warm_white_.optional_value(x...)); - call.set_effect(this->effect_.optional_value(x...)); - call.set_flash_length(this->flash_length_.optional_value(x...)); - call.set_transition_length(this->transition_length_.optional_value(x...)); + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) call.perform(); } protected: LightState *parent_; + LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) + +#undef LIGHT_FIELD_DECL_ +#undef LIGHT_FIELD_APPLY_ +#undef LIGHT_FIELD_SETTER_ +#undef LIGHT_CONTROL_FIELDS }; template class DimRelativeAction : public Action { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 16e7d72f6b..365a64584c 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.config import path_context @@ -28,7 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError, Lambda from esphome.cpp_generator import LambdaExpression -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType from .types import ( COLOR_MODES, @@ -141,6 +143,28 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) +async def _as_lambda( + value: Any, + args: list[tuple[SafeExpType, str]], + output_type: SafeExpType, +) -> LambdaExpression: + """Return a stateless lambda expression for a templatable value. + + If value is already a lambda, process it normally. Otherwise wrap + the constant in a ``[](...) -> T { return ; }`` expression + so that LightControlAction can store every field as a plain + function pointer. + """ + if cg.is_template(value): + return await cg.process_lambda(value, args, return_type=output_type) + return LambdaExpression( + f"return {cg.safe_exp(value)};", + args, + capture="", + return_type=output_type, + ) + + def _resolve_effect_index(config: ConfigType) -> int: """Resolve a static effect name to its 1-based index at codegen time. @@ -179,47 +203,29 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_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 CONF_COLOR_MODE in config: - template_ = await cg.templatable(config[CONF_COLOR_MODE], args, ColorMode) - cg.add(var.set_color_mode(template_)) - if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, bool) - cg.add(var.set_state(template_)) - if CONF_TRANSITION_LENGTH in config: - template_ = await cg.templatable( - config[CONF_TRANSITION_LENGTH], args, cg.uint32 - ) - cg.add(var.set_transition_length(template_)) - if CONF_FLASH_LENGTH in config: - template_ = await cg.templatable(config[CONF_FLASH_LENGTH], args, cg.uint32) - cg.add(var.set_flash_length(template_)) - if CONF_BRIGHTNESS in config: - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, float) - cg.add(var.set_brightness(template_)) - if CONF_COLOR_BRIGHTNESS in config: - template_ = await cg.templatable(config[CONF_COLOR_BRIGHTNESS], args, float) - cg.add(var.set_color_brightness(template_)) - if CONF_RED in config: - template_ = await cg.templatable(config[CONF_RED], args, float) - cg.add(var.set_red(template_)) - if CONF_GREEN in config: - template_ = await cg.templatable(config[CONF_GREEN], args, float) - cg.add(var.set_green(template_)) - if CONF_BLUE in config: - template_ = await cg.templatable(config[CONF_BLUE], args, float) - cg.add(var.set_blue(template_)) - if CONF_WHITE in config: - template_ = await cg.templatable(config[CONF_WHITE], args, float) - cg.add(var.set_white(template_)) - if CONF_COLOR_TEMPERATURE in config: - template_ = await cg.templatable(config[CONF_COLOR_TEMPERATURE], args, float) - cg.add(var.set_color_temperature(template_)) - if CONF_COLD_WHITE in config: - template_ = await cg.templatable(config[CONF_COLD_WHITE], args, float) - cg.add(var.set_cold_white(template_)) - if CONF_WARM_WHITE in config: - template_ = await cg.templatable(config[CONF_WARM_WHITE], args, float) - cg.add(var.set_warm_white(template_)) + + # (config_key, setter_name, c++ type) + FIELDS = ( + (CONF_COLOR_MODE, "set_color_mode", ColorMode), + (CONF_STATE, "set_state", 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), + ) + for conf_key, setter, type_ in FIELDS: + if conf_key in config: + cg.add( + getattr(var, setter)(await _as_lambda(config[conf_key], args, type_)) + ) + if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): # Lambda returns a string — wrap in a C++ lambda that resolves @@ -242,8 +248,11 @@ async def light_control_to_code(config, action_id, template_arg, args): cg.add(var.set_effect(wrapper)) else: # Static string — resolve effect name to index at codegen time - effect_index = _resolve_effect_index(config) - cg.add(var.set_effect(effect_index)) + cg.add( + var.set_effect( + await _as_lambda(_resolve_effect_index(config), args, cg.uint32) + ) + ) return var diff --git a/tests/integration/fixtures/light_control_action.yaml b/tests/integration/fixtures/light_control_action.yaml new file mode 100644 index 0000000000..66f0cf1873 --- /dev/null +++ b/tests/integration/fixtures/light_control_action.yaml @@ -0,0 +1,139 @@ +esphome: + name: light-control-action-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +globals: + - id: test_brightness + type: float + initial_value: "0.75" + +output: + - platform: template + id: test_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_blue + type: float + write_action: + - lambda: "" + - platform: template + id: test_cold_white + type: float + write_action: + - lambda: "" + - platform: template + id: test_warm_white + type: float + write_action: + - lambda: "" + +light: + - platform: rgbww + name: "Test Light" + id: test_light + red: test_red + green: test_green + blue: test_blue + cold_white: test_cold_white + warm_white: test_warm_white + cold_white_color_temperature: 6536 K + warm_white_color_temperature: 2000 K + effects: + - random: + name: "Test Effect" + transition_length: 10ms + update_interval: 10ms + +button: + # Test 1: light.turn_on with RGB constants + - platform: template + id: btn_turn_on_rgb + name: "Turn On RGB" + on_press: + - light.turn_on: + id: test_light + brightness: 1.0 + red: 0.0 + green: 0.0 + blue: 1.0 + + # Test 2: light.turn_off + - platform: template + id: btn_turn_off + name: "Turn Off" + on_press: + - light.turn_off: + id: test_light + + # Test 3: light.turn_on with color_temperature + - platform: template + id: btn_turn_on_ct + name: "Turn On CT" + on_press: + - light.turn_on: + id: test_light + color_temperature: 4000 K + brightness: 0.8 + + # Test 4: light.turn_on with effect + - platform: template + id: btn_turn_on_effect + name: "Turn On Effect" + on_press: + - light.turn_on: + id: test_light + effect: "Test Effect" + + # Test 5: light.turn_on with effect none to clear it + - platform: template + id: btn_clear_effect + name: "Clear Effect" + on_press: + - light.turn_on: + id: test_light + effect: "None" + + # Test 6: light.control with cold/warm white + - platform: template + id: btn_control_cw + name: "Control CW" + on_press: + - light.control: + id: test_light + cold_white: 0.9 + warm_white: 0.1 + + # Test 7: light.turn_on with lambda brightness (tests lambda path) + - platform: template + id: btn_lambda_brightness + name: "Lambda Brightness" + on_press: + - light.turn_on: + id: test_light + brightness: !lambda "return id(test_brightness);" + red: 1.0 + green: 0.0 + blue: 0.0 + + # Test 8: light.turn_on with transition_length + - platform: template + id: btn_turn_on_transition + name: "Turn On Transition" + on_press: + - light.turn_on: + id: test_light + brightness: 0.5 + transition_length: 0s + red: 0.5 + green: 0.5 + blue: 0.0 diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py new file mode 100644 index 0000000000..9a5c16a04d --- /dev/null +++ b/tests/integration/test_light_control_action.py @@ -0,0 +1,95 @@ +"""Integration test for LightControlAction. + +Tests that light.turn_on, light.turn_off, and light.control automation actions +work correctly with the compact per-field union storage. Exercises both constant +value and lambda paths. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LightControlAction with constants and lambdas.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + """Wait for a state change for the given entity key.""" + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + """Press a button and wait for light state change.""" + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(light.key) + + # Test 1: light.turn_on with RGB constants + state = await press_and_wait("Turn On RGB") + assert state.state is True + assert state.brightness == pytest.approx(1.0) + assert state.red == pytest.approx(0.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(1.0, abs=0.01) + + # Test 2: light.turn_off + state = await press_and_wait("Turn Off") + assert state.state is False + + # Test 3: light.turn_on with color_temperature + state = await press_and_wait("Turn On CT") + assert state.state is True + assert state.brightness == pytest.approx(0.8) + assert state.color_temperature == pytest.approx(250.0) # 4000K = 250 mireds + + # Test 4: light.turn_on with effect + state = await press_and_wait("Turn On Effect") + assert state.effect == "Test Effect" + + # Test 5: Clear effect + state = await press_and_wait("Clear Effect") + assert state.effect == "None" + + # Test 6: light.control with cold/warm white + state = await press_and_wait("Control CW") + assert state.cold_white == pytest.approx(0.9, abs=0.1) + assert state.warm_white == pytest.approx(0.1, abs=0.1) + + # Test 7: light.turn_on with lambda brightness + # The global test_brightness is 0.75 + state = await press_and_wait("Lambda Brightness") + assert state.state is True + assert state.brightness == pytest.approx(0.75, abs=0.05) + assert state.red == pytest.approx(1.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(0.0, abs=0.01) + + # Test 8: light.turn_on with transition_length and brightness + state = await press_and_wait("Turn On Transition") + assert state.state is True + assert state.brightness == pytest.approx(0.5) From ee7b38504b936b5bae5a9666c25a50e3329fb92b Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:13:58 +0200 Subject: [PATCH 21/25] [nextion] Expose custom protocol frames as automation triggers (#13248) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/nextion/automation.h | 3 + esphome/components/nextion/base_component.py | 4 ++ esphome/components/nextion/display.py | 41 ++++++++++++ esphome/components/nextion/nextion.cpp | 20 ++++++ esphome/components/nextion/nextion.h | 66 ++++++++++++++++++++ esphome/core/defines.h | 4 ++ tests/components/nextion/common.yaml | 27 +++++++- 7 files changed, 164 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index 17f6c77e17..e039dae615 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -1,5 +1,8 @@ #pragma once + #include "esphome/core/automation.h" +#include "esphome/core/string_ref.h" + #include "nextion.h" namespace esphome::nextion { diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 7705b21b0b..74a50a95d4 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -19,6 +19,10 @@ CONF_MAX_COMMANDS_PER_LOOP = "max_commands_per_loop" CONF_MAX_QUEUE_AGE = "max_queue_age" CONF_MAX_QUEUE_SIZE = "max_queue_size" CONF_ON_BUFFER_OVERFLOW = "on_buffer_overflow" +CONF_ON_CUSTOM_BINARY_SENSOR = "on_custom_binary_sensor" +CONF_ON_CUSTOM_SENSOR = "on_custom_sensor" +CONF_ON_CUSTOM_SWITCH = "on_custom_switch" +CONF_ON_CUSTOM_TEXT_SENSOR = "on_custom_text_sensor" CONF_ON_PAGE = "on_page" CONF_ON_SETUP = "on_setup" CONF_ON_SLEEP = "on_sleep" diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index e477ab7182..4d42898a10 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -20,6 +20,10 @@ from .base_component import ( CONF_MAX_QUEUE_AGE, CONF_MAX_QUEUE_SIZE, CONF_ON_BUFFER_OVERFLOW, + CONF_ON_CUSTOM_BINARY_SENSOR, + CONF_ON_CUSTOM_SENSOR, + CONF_ON_CUSTOM_SWITCH, + CONF_ON_CUSTOM_TEXT_SENSOR, CONF_ON_PAGE, CONF_ON_SETUP, CONF_ON_SLEEP, @@ -88,6 +92,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_COMMANDS_PER_LOOP): cv.uint16_t, cv.Optional(CONF_MAX_QUEUE_SIZE): cv.positive_int, cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_BINARY_SENSOR): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CUSTOM_SENSOR): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_SWITCH): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_TEXT_SENSOR): automation.validate_automation({}), cv.Optional(CONF_ON_PAGE): automation.validate_automation({}), cv.Optional(CONF_ON_SETUP): automation.validate_automation({}), cv.Optional(CONF_ON_SLEEP): automation.validate_automation({}), @@ -163,8 +173,36 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_BINARY_SENSOR, + "add_custom_binary_sensor_callback", + [(cg.StringRef, "key"), (cg.bool_, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_SENSOR, + "add_custom_sensor_callback", + [(cg.StringRef, "key"), (cg.int32, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_SWITCH, + "add_custom_switch_callback", + [(cg.StringRef, "key"), (cg.bool_, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_TEXT_SENSOR, + "add_custom_text_sensor_callback", + [(cg.StringRef, "key"), (cg.StringRef, "value")], + ), ) +# Map custom trigger config keys to their conditional defines +_CUSTOM_TRIGGER_DEFINES = { + CONF_ON_CUSTOM_BINARY_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR", + CONF_ON_CUSTOM_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_SENSOR", + CONF_ON_CUSTOM_SWITCH: "USE_NEXTION_TRIGGER_CUSTOM_SWITCH", + CONF_ON_CUSTOM_TEXT_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR", +} + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -253,5 +291,8 @@ async def to_code(config): cg.add(var.set_max_commands_per_loop(max_commands_per_loop)) await display.register_display(var, config) + for conf_key, define_name in _CUSTOM_TRIGGER_DEFINES.items(): + if config.get(conf_key): + cg.add_define(define_name) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 6b806e0988..b0e14b5ea3 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,8 +1,11 @@ #include "nextion.h" + #include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/string_ref.h" #include "esphome/core/util.h" namespace esphome::nextion { @@ -715,6 +718,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Switch %s: %s", ONOFF(to_process[index] != 0), variable_name.c_str()); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + this->custom_switch_callback_.call(StringRef(variable_name), to_process[index] != 0); +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH + for (auto *switchtype : this->switchtype_) { switchtype->process_bool(variable_name, to_process[index] != 0); } @@ -744,6 +751,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Sensor: %s=%d", variable_name.c_str(), value); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + this->custom_sensor_callback_.call(StringRef(variable_name), value); +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR + for (auto *sensor : this->sensortype_) { sensor->process_sensor(variable_name, value); } @@ -781,6 +792,11 @@ void Nextion::process_nextion_commands_() { // nq->variable_name = variable_name; // nq->state = text_value; // this->textsensorq_.push_back(nq); + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + this->custom_text_sensor_callback_.call(StringRef(variable_name), StringRef(text_value)); +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + for (auto *textsensortype : this->textsensortype_) { textsensortype->process_text(variable_name, text_value); } @@ -808,6 +824,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Binary sensor: %s=%s", variable_name.c_str(), ONOFF(to_process[index] != 0)); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + this->custom_binary_sensor_callback_.call(StringRef(variable_name), to_process[index] != 0); +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + for (auto *binarysensortype : this->binarysensortype_) { binarysensortype->process_bool(&variable_name[0], to_process[index] != 0); } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d910389289..c84a5cd49c 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -7,6 +7,7 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/components/uart/uart.h" #include "esphome/core/defines.h" +#include "esphome/core/string_ref.h" #include "esphome/core/time.h" #ifdef USE_NEXTION_WAVEFORM @@ -1183,6 +1184,59 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe this->buffer_overflow_callback_.add(std::forward(callback)); } + // Callbacks for Nextion "custom protocol" frames (0x90..0x93) +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + /** Add a callback to be notified when Nextion sends a custom binary sensor protocol frame (0x93). + * + * This callback is invoked when a Nextion custom binary sensor frame is received, + * providing the component name as the key and the decoded boolean value. + * + * @param callback The void(const StringRef &key, bool value) callback. + */ + template void add_custom_binary_sensor_callback(F &&callback) { + this->custom_binary_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + /** Add a callback to be notified when Nextion sends a custom sensor protocol frame (0x91). + * + * This callback is invoked when a Nextion custom sensor frame is received, + * providing the component name as the key and the decoded integer value. + * + * @param callback The void(StringRef key, int32_t value) callback. + */ + template void add_custom_sensor_callback(F &&callback) { + this->custom_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + /** Add a callback to be notified when Nextion sends a custom switch protocol frame (0x90). + * + * This callback is invoked when a Nextion custom switch frame is received, + * providing the component name as the key and the decoded boolean value. + * + * @param callback The void(const StringRef &key, bool value) callback. + */ + template void add_custom_switch_callback(F &&callback) { + this->custom_switch_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + /** Add a callback to be notified when Nextion sends a custom text sensor protocol frame (0x92). + * + * This callback is invoked when a Nextion custom text sensor frame is received, + * providing the component name as the key and the decoded text value. + * + * @param callback The void(const StringRef &key, const StringRef &value) callback. + */ + template void add_custom_text_sensor_callback(F &&callback) { + this->custom_text_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + void update_all_components(); /** @@ -1535,6 +1589,18 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe CallbackManager page_callback_{}; CallbackManager touch_callback_{}; CallbackManager buffer_overflow_callback_{}; +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + CallbackManager custom_binary_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + CallbackManager custom_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + CallbackManager custom_switch_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + CallbackManager custom_text_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR nextion_writer_t writer_; optional brightness_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9c90790f3a..4939c194e3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,6 +123,10 @@ #define USE_NEXTION_MAX_COMMANDS_PER_LOOP #define USE_NEXTION_MAX_QUEUE_SIZE #define USE_NEXTION_TFT_UPLOAD +#define USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR +#define USE_NEXTION_TRIGGER_CUSTOM_SENSOR +#define USE_NEXTION_TRIGGER_CUSTOM_SWITCH +#define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d9493db50c..0616b9a41a 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -286,6 +286,31 @@ display: on_buffer_overflow: then: logger.log: "Nextion reported a buffer overflow!" + on_custom_text_sensor: + then: + - lambda: |- + // key: StringRef, value: StringRef + if (key == "csv") { + // parse value here, or forward to your own component + ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); + } + on_custom_sensor: + then: + - lambda: |- + // key: StringRef, value: int32_t + if (key == "temperature_raw") { + ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + } + on_custom_binary_sensor: + then: + - lambda: |- + if (key == "btn1") { + ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value)); + } + on_custom_switch: + then: + - lambda: |- + ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); on_page: then: lambda: 'ESP_LOGD("display","Display shows new page %u", x);' @@ -304,8 +329,8 @@ display: on_wake: then: lambda: 'ESP_LOGD("display","Display woke up");' - update_interval: 5s start_up_page: 1 startup_override_ms: 10000ms # Wait 10s for display ready touch_sleep_timeout: 3 + update_interval: 5s wake_up_page: 2 From 0d7f2f05b914bd7b1e3fc066af49386371a10227 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:16:37 -0400 Subject: [PATCH 22/25] [libretiny] Fix board pin alias resolution TypeError (#15527) --- esphome/components/libretiny/gpio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/libretiny/gpio.py b/esphome/components/libretiny/gpio.py index 9bad400eb7..9f8d96de24 100644 --- a/esphome/components/libretiny/gpio.py +++ b/esphome/components/libretiny/gpio.py @@ -41,7 +41,7 @@ def _lookup_board_pins(board): board_pins = component.board_pins.get(board, {}) # Resolve aliased board pins (shorthand when two boards have the same pin configuration) while isinstance(board_pins, str): - board_pins = board_pins[board_pins] + board_pins = component.board_pins[board_pins] return board_pins From 14bcdfe7004a6ea77425c0f4e5ab77af21e70827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:29:55 +0200 Subject: [PATCH 23/25] [emontx] emonTx component (#9027) Co-authored-by: Claude Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/emontx/__init__.py | 152 ++++++++++++++++++ esphome/components/emontx/emontx.cpp | 116 +++++++++++++ esphome/components/emontx/emontx.h | 69 ++++++++ esphome/components/emontx/sensor/__init__.py | 133 +++++++++++++++ .../emontx/sensor/emontx_sensor.cpp | 10 ++ .../components/emontx/sensor/emontx_sensor.h | 13 ++ tests/components/emontx/common.yaml | 25 +++ tests/components/emontx/test.esp32-idf.yaml | 4 + tests/components/emontx/test.esp8266-ard.yaml | 4 + tests/components/emontx/test.rp2040-ard.yaml | 4 + .../common/uart_115200/esp32-ard.yaml | 1 + .../common/uart_115200/esp32-c3-ard.yaml | 1 + .../common/uart_115200/esp32-c3-idf.yaml | 1 + .../common/uart_115200/esp32-idf.yaml | 1 + .../common/uart_115200/esp8266-ard.yaml | 1 + .../common/uart_115200/rp2040-ard.yaml | 1 + 17 files changed, 537 insertions(+) create mode 100644 esphome/components/emontx/__init__.py create mode 100644 esphome/components/emontx/emontx.cpp create mode 100644 esphome/components/emontx/emontx.h create mode 100644 esphome/components/emontx/sensor/__init__.py create mode 100644 esphome/components/emontx/sensor/emontx_sensor.cpp create mode 100644 esphome/components/emontx/sensor/emontx_sensor.h create mode 100644 tests/components/emontx/common.yaml create mode 100644 tests/components/emontx/test.esp32-idf.yaml create mode 100644 tests/components/emontx/test.esp8266-ard.yaml create mode 100644 tests/components/emontx/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c466204b66..5b1ae65f1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -148,6 +148,7 @@ esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull esphome/components/emmeti/* @E440QF +esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea esphome/components/ens160/* @latonita esphome/components/ens160_base/* @latonita @vincentscode esphome/components/ens160_i2c/* @latonita diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py new file mode 100644 index 0000000000..a2d4349698 --- /dev/null +++ b/esphome/components/emontx/__init__.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass, field + +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_COMMAND, + CONF_ID, + CONF_ON_DATA, + CONF_RX_BUFFER_SIZE, + CONF_UART_ID, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["json"] +CODEOWNERS = ["@FredM67", "@TrystanLea", "@glynhudson"] +DEPENDENCIES = ["uart"] + +emontx_ns = cg.esphome_ns.namespace("emontx") +EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) + +# Action to send command to emonTx +EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action) + +CONF_EMONTX_ID = "emontx_id" +CONF_TAG_NAME = "tag_name" +CONF_ON_JSON = "on_json" + +DOMAIN = "emontx" + +MINIMUM_RX_BUFFER_SIZE = 2048 + + +@dataclass +class EmonTxData: + sensor_counts: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> EmonTxData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EmonTxData() + return CORE.data[DOMAIN] + + +# Main configuration schema +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(EmonTx), + cv.Optional(CONF_ON_JSON): automation.validate_automation({}), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + + # Count sensors registered to this hub (IDs are resolved at final_validate stage) + hub_id = str(config[CONF_ID]) + sensor_count = sum( + 1 + for s in full_config.get("sensor", []) + if s.get("platform") == "emontx" and str(s.get(CONF_EMONTX_ID)) == hub_id + ) + _get_data().sensor_counts[hub_id] = sensor_count + + # Ensure UART RX buffer size is large enough to handle data bursts from firmware + for uart_conf in full_config["uart"]: + if uart_conf[CONF_ID] == config[CONF_UART_ID]: + current_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] + if current_buffer_size < MINIMUM_RX_BUFFER_SIZE: + raise cv.Invalid( + f"Component emontx requires UART '{config[CONF_UART_ID]}' to have " + f"rx_buffer_size of at least {MINIMUM_RX_BUFFER_SIZE} bytes " + f"(currently set to {current_buffer_size} bytes). " + f"Please add 'rx_buffer_size: {MINIMUM_RX_BUFFER_SIZE}' to your uart configuration.", + path=[CONF_UART_ID], + ) + break + + # Validate UART settings + schema = uart.final_validate_device_schema( + "emontx", + baud_rate=115200, + require_tx=False, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, + ) + return schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_JSON, + "add_on_json_callback", + [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + ), + automation.CallbackAutomation( + CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + ), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + # Initialize sensor storage with count from final_validate + sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0) + if sensor_count > 0: + cg.add(var.init_sensors(sensor_count)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +# Action: emontx.send_command + +EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(EmonTx), + cv.Required(CONF_COMMAND): cv.templatable(cv.string), + } +) + + +@automation.register_action( + "emontx.send_command", + EmonTxSendCommandAction, + EMONTX_SEND_COMMAND_ACTION_SCHEMA, + synchronous=True, +) +async def emontx_send_command_action_to_code( + config: ConfigType, action_id, template_arg, args +) -> None: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) + cg.add(var.set_command(template_)) + return var diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp new file mode 100644 index 0000000000..7a1b084fe0 --- /dev/null +++ b/esphome/components/emontx/emontx.cpp @@ -0,0 +1,116 @@ +#include "emontx.h" +#include "esphome/core/log.h" +#include "esphome/components/json/json_util.h" + +namespace esphome::emontx { + +static const char *const TAG = "emontx"; + +void EmonTx::setup() { this->buffer_pos_ = 0; } + +/** + * @brief Implements the main loop for parsing data from the serial port. + * + * @details Continuously processes incoming UART data line-by-line: + * 1. Fire on_data callbacks for all received lines + * 2. If line starts with '{', parse as JSON and update sensors/callbacks + */ +void EmonTx::loop() { + // Read all available data to prevent UART buffer overflow + while (this->available() > 0) { + uint8_t received = this->read(); + + if (received == '\r') { + continue; // Ignore CR + } else if (received == '\n') { + // End of line - process the buffer + if (this->buffer_pos_ > 0) { + // Null-terminate for safe logging and c_str() use + size_t len = this->buffer_pos_; + this->buffer_[len] = '\0'; + this->buffer_pos_ = 0; + + StringRef line(this->buffer_.data(), len); + ESP_LOGD(TAG, "Received line: %s", line.c_str()); + + // Fire data callbacks for all received lines + this->data_callbacks_.call(line); + + // Check if this line is JSON (starts with '{') + if (this->buffer_[0] == '{') { + ESP_LOGV(TAG, "Line is JSON, parsing..."); + this->parse_json_(this->buffer_.data(), len); + } + } + } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + this->buffer_pos_ = 0; + } else { + this->buffer_[this->buffer_pos_++] = static_cast(received); + } + } +} + +void EmonTx::parse_json_(const char *data, size_t len) { + bool success = json::parse_json(reinterpret_cast(data), len, [this, data, len](JsonObject root) { +#ifdef USE_SENSOR + for (auto &sensor_pair : this->sensors_) { + auto val = root[sensor_pair.first]; + if (val.is()) { + float value = val; + ESP_LOGV(TAG, "Updating sensor '%s' with value: %.2f", sensor_pair.first, value); + sensor_pair.second->publish_state(value); + } + } +#endif + + this->json_callbacks_.call(root, StringRef(data, len)); + return true; + }); + + if (!success) { + ESP_LOGW(TAG, "Failed to parse JSON"); + } +} + +/** + * @brief Logs the EmonTx component configuration details. + */ +void EmonTx::dump_config() { + ESP_LOGCONFIG(TAG, "EmonTx:"); + +#ifdef USE_SENSOR + ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); + for (const auto &sensor_pair : this->sensors_) { + ESP_LOGCONFIG(TAG, " Sensor: %s", sensor_pair.first); + } +#else + ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); +#endif +} + +/** + * @brief Sends a command string to the emonTx device via UART. + * + * @param command The command string to send (LF will be appended automatically). + */ +void EmonTx::send_command(const std::string &command) { + ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str()); + this->write_str(command.c_str()); + this->write_byte('\n'); +} + +#ifdef USE_SENSOR +/** + * @brief Registers a sensor to receive updates for a specific JSON tag. + * + * @param tag_name The JSON key to monitor for this sensor (must be a string literal). + * @param sensor Pointer to the sensor that will receive value updates. + */ +void EmonTx::register_sensor(const char *tag_name, sensor::Sensor *sensor) { + ESP_LOGCONFIG(TAG, "Registering sensor for tag: %s", tag_name); + this->sensors_.emplace_back(tag_name, sensor); +} +#endif + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h new file mode 100644 index 0000000000..67e7f5bffc --- /dev/null +++ b/esphome/components/emontx/emontx.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/json/json_util.h" + +#include + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +namespace esphome::emontx { + +/// Maximum line length in bytes (plus one byte reserved for null terminator) +static constexpr size_t MAX_LINE_LENGTH = 1024; + +/** + * @class EmonTx + * @brief Main class for the EmonTx component. + * + * The EmonTx processes incoming data frames via UART, + * extracts tags and values, and publishes them to registered sensors. + */ +class EmonTx : public Component, public uart::UARTDevice { + public: + EmonTx() = default; + + void loop() override; + void setup() override; + void dump_config() override; + + template void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(callback)); } + + template void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward(callback)); } + + // Send command to emonTx via UART + void send_command(const std::string &command); + +#ifdef USE_SENSOR + void init_sensors(size_t count) { this->sensors_.init(count); } + void register_sensor(const char *tag_name, sensor::Sensor *sensor); +#endif + + protected: + void parse_json_(const char *data, size_t len); + +#ifdef USE_SENSOR + FixedVector> sensors_{}; +#endif + LazyCallbackManager json_callbacks_; + LazyCallbackManager data_callbacks_; + uint16_t buffer_pos_{0}; + std::array buffer_{}; +}; + +// Action to send command to emonTx +template class EmonTxSendCommandAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, command) + + void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); } +}; + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py new file mode 100644 index 0000000000..83a972c5e0 --- /dev/null +++ b/esphome/components/emontx/sensor/__init__.py @@ -0,0 +1,133 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PULSES, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns + +EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component) + +# Define sensor type configurations by prefix +SENSOR_CONFIGS = { + "P": { + CONF_UNIT_OF_MEASUREMENT: UNIT_WATT, + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + "E": { + CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS, + CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + CONF_ACCURACY_DECIMALS: 0, + }, + "V": { + CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT, + CONF_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, + "I": { + CONF_UNIT_OF_MEASUREMENT: UNIT_AMPERE, + CONF_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, + "T": { + CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, + CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Pattern-based configurations +PATTERN_CONFIGS = { + "PULSE": { + CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, + CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_ACCURACY_DECIMALS: 0, + }, + "PF": { + CONF_UNIT_OF_MEASUREMENT: UNIT_EMPTY, + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER_FACTOR, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Create a base schema that's flexible for any tag +BASE_SCHEMA = sensor.sensor_schema( + EmonTxSensor, + state_class=STATE_CLASS_MEASUREMENT, + accuracy_decimals=0, +).extend( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Required(CONF_TAG_NAME): cv.string, + } +) + + +def apply_tag_defaults(config: ConfigType) -> ConfigType: + """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" + tag = config[CONF_TAG_NAME] + + # Skip if tag is too short + if len(tag) < 2: + return config + + # Check if this tag starts with a known prefix + tag_upper = tag.upper() + + for pattern, pattern_config in PATTERN_CONFIGS.items(): + if tag_upper.startswith(pattern): + # Apply pattern defaults if not overridden by user + for key, value in pattern_config.items(): + if key not in config: + config[key] = value + return config + + # Only apply defaults for known prefixes with numeric indices + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): + # Apply defaults for known tag types, but only if not overridden by user + defaults = SENSOR_CONFIGS[prefix] + for key, value in defaults.items(): + if key not in config: + config[key] = value + + return config + + +CONFIG_SCHEMA = cv.All(BASE_SCHEMA, apply_tag_defaults) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + cg.add(hub.register_sensor(config[CONF_TAG_NAME], var)) diff --git a/esphome/components/emontx/sensor/emontx_sensor.cpp b/esphome/components/emontx/sensor/emontx_sensor.cpp new file mode 100644 index 0000000000..142df0150e --- /dev/null +++ b/esphome/components/emontx/sensor/emontx_sensor.cpp @@ -0,0 +1,10 @@ +#include "emontx_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::emontx { + +static const char *const TAG = "emontx_sensor"; + +void EmonTxSensor::dump_config() { LOG_SENSOR(" ", "EmonTx Sensor", this); } + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h new file mode 100644 index 0000000000..9714acdf0d --- /dev/null +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::emontx { + +class EmonTxSensor : public sensor::Sensor, public Component { + public: + void dump_config() override; +}; + +} // namespace esphome::emontx diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml new file mode 100644 index 0000000000..5c25e37abb --- /dev/null +++ b/tests/components/emontx/common.yaml @@ -0,0 +1,25 @@ +button: + - platform: template + name: Send command test + on_press: + - emontx.send_command: + id: test_emontx + command: "v" + +emontx: + id: test_emontx + on_json: + - then: + - logger.log: "Got JSON" + on_data: + - then: + - logger.log: + format: "Got data: %s" + args: [data.c_str()] + +sensor: + - platform: emontx + name: Power + tag_name: P1 + emontx_id: test_emontx + unit_of_measurement: W diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml new file mode 100644 index 0000000000..3a3747f3a5 --- /dev/null +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml new file mode 100644 index 0000000000..31c5731589 --- /dev/null +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml new file mode 100644 index 0000000000..ff55e8263d --- /dev/null +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/uart_115200/esp32-ard.yaml b/tests/test_build_components/common/uart_115200/esp32-ard.yaml index 9102910f31..108f12110d 100644 --- a/tests/test_build_components/common/uart_115200/esp32-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml index 87a969c6a3..5176a4e8e2 100644 --- a/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml index f3768592e5..f61d01d206 100644 --- a/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml @@ -10,3 +10,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-idf.yaml b/tests/test_build_components/common/uart_115200/esp32-idf.yaml index e405f74fe7..b432d31a7e 100644 --- a/tests/test_build_components/common/uart_115200/esp32-idf.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-idf.yaml @@ -10,3 +10,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp8266-ard.yaml b/tests/test_build_components/common/uart_115200/esp8266-ard.yaml index 2dcf1c4a5d..c4b9170c2f 100644 --- a/tests/test_build_components/common/uart_115200/esp8266-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp8266-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/rp2040-ard.yaml b/tests/test_build_components/common/uart_115200/rp2040-ard.yaml index 62a7b5aed2..874b09217b 100644 --- a/tests/test_build_components/common/uart_115200/rp2040-ard.yaml +++ b/tests/test_build_components/common/uart_115200/rp2040-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 From aad898503d8200600f1ec285b0444f7d38906ba2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:37:17 -0400 Subject: [PATCH 24/25] [multiple] Fix channel/pin range validation and widen channel types (#15529) --- esphome/components/bp1658cj/output.py | 2 +- esphome/components/mcp3008/sensor/__init__.py | 2 +- esphome/components/my9231/my9231.cpp | 4 ++-- esphome/components/my9231/my9231.h | 6 +++--- esphome/components/sm16716/output.py | 2 +- esphome/components/sm2135/output.py | 2 +- esphome/components/sm2235/output.py | 2 +- esphome/components/sm2335/output.py | 2 +- esphome/components/tlc5947/output/__init__.py | 2 +- esphome/components/tlc5947/output/tlc5947_output.h | 4 ++-- esphome/components/tlc5971/output/__init__.py | 2 +- esphome/components/tlc5971/output/tlc5971_output.h | 4 ++-- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 023b6ecd1e..78cf717aba 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_BP1658CJ_ID): cv.use_id(BP1658CJ), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp3008/sensor/__init__.py b/esphome/components/mcp3008/sensor/__init__.py index e85ce2955d..2576ef50e5 100644 --- a/esphome/components/mcp3008/sensor/__init__.py +++ b/esphome/components/mcp3008/sensor/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = ( .extend( { cv.GenerateID(CONF_MCP3008_ID): cv.use_id(MCP3008), - cv.Required(CONF_NUMBER): cv.int_, + cv.Required(CONF_NUMBER): cv.int_range(min=0, max=7), cv.Optional(CONF_REFERENCE_VOLTAGE, default="3.3V"): cv.voltage, } ) diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index 5b77a49e72..25f7e6925d 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -81,9 +81,9 @@ void MY9231OutputComponent::loop() { } this->update_ = false; } -void MY9231OutputComponent::set_channel_value_(uint8_t channel, uint16_t value) { +void MY9231OutputComponent::set_channel_value_(uint16_t channel, uint16_t value) { ESP_LOGV(TAG, "set channels %u to %u", channel, value); - uint8_t index = this->num_channels_ - channel - 1; + uint16_t index = this->num_channels_ - channel - 1; if (this->pwm_amounts_[index] != value) { this->update_ = true; } diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 77c1259853..dff68d247c 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -30,7 +30,7 @@ class MY9231OutputComponent : public Component { class Channel : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } - void set_channel(uint8_t channel) { channel_ = channel; } + void set_channel(uint16_t channel) { channel_ = channel; } protected: void write_state(float state) override { @@ -39,13 +39,13 @@ class MY9231OutputComponent : public Component { } MY9231OutputComponent *parent_; - uint8_t channel_; + uint16_t channel_; }; protected: uint16_t get_max_amount_() const { return (uint32_t(1) << this->bit_depth_) - 1; } - void set_channel_value_(uint8_t channel, uint16_t value); + void set_channel_value_(uint16_t channel, uint16_t value); void init_chips_(uint8_t command); void write_word_(uint16_t value, uint8_t bits); void send_di_pulses_(uint8_t count); diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 50f6ec759f..2cfc38f5cc 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM16716_ID): cv.use_id(SM16716), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=254), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2135/output.py b/esphome/components/sm2135/output.py index 71c4af2253..a4ac7fc7da 100644 --- a/esphome/components/sm2135/output.py +++ b/esphome/components/sm2135/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2135_ID): cv.use_id(SM2135), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2235/output.py b/esphome/components/sm2235/output.py index 2a9698d645..b17af2b1e0 100644 --- a/esphome/components/sm2235/output.py +++ b/esphome/components/sm2235/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2235_ID): cv.use_id(SM2235), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2335/output.py b/esphome/components/sm2335/output.py index ef7fec7307..7fd00917bd 100644 --- a/esphome/components/sm2335/output.py +++ b/esphome/components/sm2335/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2335_ID): cv.use_id(SM2335), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tlc5947/output/__init__.py b/esphome/components/tlc5947/output/__init__.py index a1290add81..6bea1546d3 100644 --- a/esphome/components/tlc5947/output/__init__.py +++ b/esphome/components/tlc5947/output/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_TLC5947_ID): cv.use_id(TLC5947), cv.Required(CONF_ID): cv.declare_id(TLC5947Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.uint16_t, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tlc5947/output/tlc5947_output.h b/esphome/components/tlc5947/output/tlc5947_output.h index 5b2c51020c..0faec96acb 100644 --- a/esphome/components/tlc5947/output/tlc5947_output.h +++ b/esphome/components/tlc5947/output/tlc5947_output.h @@ -11,11 +11,11 @@ namespace tlc5947 { class TLC5947Channel : public output::FloatOutput, public Parented { public: - void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_channel(uint16_t channel) { this->channel_ = channel; } protected: void write_state(float state) override; - uint8_t channel_; + uint16_t channel_; }; } // namespace tlc5947 diff --git a/esphome/components/tlc5971/output/__init__.py b/esphome/components/tlc5971/output/__init__.py index ae000ae0a9..854fbbd810 100644 --- a/esphome/components/tlc5971/output/__init__.py +++ b/esphome/components/tlc5971/output/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_TLC5971_ID): cv.use_id(TLC5971), cv.Required(CONF_ID): cv.declare_id(TLC5971Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.uint16_t, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 944ee19b2d..ca3099e7b2 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -11,11 +11,11 @@ namespace tlc5971 { class TLC5971Channel : public output::FloatOutput, public Parented { public: - void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_channel(uint16_t channel) { this->channel_ = channel; } protected: void write_state(float state) override; - uint8_t channel_; + uint16_t channel_; }; } // namespace tlc5971 From b307c7c74ca18922b4580e7344f5b6a4a354a371 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:44:52 +1200 Subject: [PATCH 25/25] [config_validation] Add unbounded percentage validators (#15500) --- esphome/config_validation.py | 65 ++++++--- tests/unit_tests/test_config_validation.py | 149 +++++++++++++++++++++ 2 files changed, 197 insertions(+), 17 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 7805de98db..b0bd9e6231 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1468,17 +1468,53 @@ hex_uint64_t = hex_int_range(min=0, max=18446744073709551615) i2c_address = hex_uint8_t -def percentage(value): +def percentage(value: object) -> float: """Validate that the value is a percentage. - The resulting value is an integer in the range 0.0 to 1.0. + The resulting value is a float in the range 0.0 to 1.0. """ - value = possibly_negative_percentage(value) + value = _parse_percentage(value) return zero_to_one_float(value) -def possibly_negative_percentage(value): - has_percent_sign = False +def possibly_negative_percentage(value: object) -> float: + """Validate that the value is a possibly negative percentage. + + The resulting value is a float in the range -1.0 to 1.0. + """ + value = _parse_percentage(value) + return negative_one_to_one_float(value) + + +def unbounded_percentage(value: object) -> float: + """Validate that the value is a percentage, allowing values above 100%. + + The resulting value is a non-negative float with no upper bound. + For example, "150%" returns 1.5 and "50%" returns 0.5. + """ + value = _parse_percentage(value) + if value < 0: + raise Invalid("Percentage must not be negative") + return value + + +def unbounded_possibly_negative_percentage(value: object) -> float: + """Validate that the value is a possibly negative percentage without bounds. + + The resulting value is an unbounded float. + For example, "200%" returns 2.0 and "-150%" returns -1.5. + """ + return _parse_percentage(value) + + +def _parse_percentage(value: object) -> float: + """Parse a percentage string or number into a float. + + Handles both "50%" style strings and raw float values. + Values without a percent sign above 1.0 or below -1.0 are rejected + to prevent user mistakes (e.g. writing 50 instead of 50%). + """ + has_percent_sign: bool = False if isinstance(value, str): try: if value.endswith("%"): @@ -1490,21 +1526,16 @@ def possibly_negative_percentage(value): # pylint: disable=raise-missing-from raise Invalid("invalid number") try: - if value > 1: - msg = "Percentage must not be higher than 100%." - if not has_percent_sign: - msg += " Please put a percent sign after the number!" - raise Invalid(msg) - if value < -1: - msg = "Percentage must not be smaller than -100%." - if not has_percent_sign: - msg += " Please put a percent sign after the number!" - raise Invalid(msg) + if not has_percent_sign and (value > 1 or value < -1): + raise Invalid( + "Percentage value must use a percent sign for values " + "outside -1.0 to 1.0. Please put a percent sign after the number!" + ) except TypeError: raise Invalid( # pylint: disable=raise-missing-from - "Expected percentage or float between -1.0 and 1.0" + "Expected percentage or float" ) - return negative_one_to_one_float(value) + return float(value) def percentage_int(value): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ce941b40dc..ac84ce7cc8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -616,3 +616,152 @@ def test_validate_entity_name__none_with_friendly_name() -> None: result = config_validation._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset + + +# --- percentage validators --- + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + (0.0, 0.0), + (0.5, 0.5), + (1.0, 1.0), + ("0.0", 0.0), + ("0.5", 0.5), + ("1.0", 1.0), + ), +) +def test_percentage__valid(value: object, expected: float) -> None: + assert config_validation.percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "150%", + "-10%", + "-0.1", + "1.1", + 2, + -1, + "foo", + None, + ), +) +def test_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + ("-50%", -0.5), + ("-100%", -1.0), + (0.0, 0.0), + (0.5, 0.5), + (-0.5, -0.5), + (1.0, 1.0), + (-1.0, -1.0), + ), +) +def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: + assert config_validation.possibly_negative_percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "150%", + "-150%", + 2, + -2, + "foo", + None, + ), +) +def test_possibly_negative_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.possibly_negative_percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + ("150%", 1.5), + ("200%", 2.0), + (0.0, 0.0), + (0.5, 0.5), + (1.0, 1.0), + ), +) +def test_unbounded_percentage__valid(value: object, expected: float) -> None: + assert config_validation.unbounded_percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "-10%", + "-0.5", + -1, + "foo", + None, + ), +) +def test_unbounded_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.unbounded_percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("150%", 1.5), + ("-50%", -0.5), + ("-150%", -1.5), + ("200%", 2.0), + ("-200%", -2.0), + (0.0, 0.0), + (0.5, 0.5), + (-0.5, -0.5), + (1.0, 1.0), + (-1.0, -1.0), + ), +) +def test_unbounded_possibly_negative_percentage__valid( + value: object, expected: float +) -> None: + assert config_validation.unbounded_possibly_negative_percentage(value) == expected + + +@pytest.mark.parametrize("value", ("foo", None)) +def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.unbounded_possibly_negative_percentage(value) + + +@pytest.mark.parametrize( + "value", + (50, -50, 2, -2), +) +def test_percentage_validators__raw_number_above_one_without_percent_sign( + value: object, +) -> None: + """Raw numeric values outside [-1, 1] must use a percent sign.""" + with pytest.raises(Invalid, match="percent sign"): + config_validation.unbounded_percentage(value) + with pytest.raises(Invalid, match="percent sign"): + config_validation.unbounded_possibly_negative_percentage(value)