From 993c96875cfd7e016c48d190cef23c519bc397ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:05:24 +0100 Subject: [PATCH] [core][select] Pass the select.set option as a plain literal (#19568) --- esphome/automation.py | 9 +++++++++ esphome/components/climate/__init__.py | 10 ++-------- esphome/components/select/__init__.py | 7 ++++++- tests/unit_tests/test_automation.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 850ac554af..a8c421f065 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -224,6 +224,15 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.safe_exp(value)) +def literal_with_length(config: ConfigType, value: str) -> str: + """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. + + The target compares or copies the bytes in place, so it needs the RAM literal rather than + the PROGMEM rendering on ESP8266, and the length saves a strlen. + """ + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 4c9f58c7a4..e64092d30e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -494,12 +494,6 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -def _literal_with_length(config: ConfigType, value: str) -> str: - # The (const char *, size_t) overload compares bytes in place, so it needs a plain - # literal rather than the default PROGMEM rendering on ESP8266, and skips a strlen. - return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" - - automation.register_apply_action( "climate.control", CLIMATE_CONTROL_ACTION_SCHEMA, @@ -517,14 +511,14 @@ automation.register_apply_action( CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), automation.ApplyField( CONF_CUSTOM_PRESET, "set_preset", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), call="make_call", diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 2db4c8cc34..6b7461bfa8 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -142,7 +142,12 @@ automation.register_apply_action( cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - automation.ApplyField(CONF_OPTION, "set_option", cg.std_string), + automation.ApplyField( + CONF_OPTION, + "set_option", + cg.std_string, + const_fn=automation.literal_with_length, + ), call="make_call", ) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c4265a7888..f6aaff3d92 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -18,6 +18,7 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + literal_with_length, maybe_simple_id, register_apply_action, register_apply_condition, @@ -838,6 +839,23 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_literal_with_length_is_plain_on_every_platform( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + """A (const char *, size_t) target gets the RAM literal and its byte length, never a flash copy.""" + fields = ( + ApplyField("option", "set_option", cg.std_string, const_fn=literal_with_length), + ) + await _run_apply_action( + registries, fields, {"option": "h\u00e9llo"}, platform=platform + ) + text = _apply_lambda(mock_cg) + assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text + assert "progmem_string" not in text + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen