diff --git a/esphome/automation.py b/esphome/automation.py index 393a79422b..4a2c10cb52 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.progmem_string(value)) +def flash_compare(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266.""" + if CORE.is_esp8266: + return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}" + return str(cg.safe_exp(value)) + + +def _default_std_string( + members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str] +) -> list[tuple[str, Any, Any]]: + """Give ``cg.std_string`` members without a renderer the given default.""" + return [ + (key, t, fn or (renderer if t is cg.std_string else None)) + for key, t, fn in members + ] + + def literal_with_length(config: ConfigType, value: str) -> str: """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. @@ -491,10 +508,7 @@ def apply_action_builder( statements_spec = [ ( c.target, - [ - (key, t, fn or (flash_string if t is cg.std_string else None)) - for key, t, fn in c.members - ], + _default_std_string(c.members, flash_string), ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -563,11 +577,12 @@ def register_apply_condition( ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. - String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=`` + against a ``std::string`` or ``StringRef`` member; other string types are plain literals. Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) - members = call.members + members = _default_std_string(call.members, flash_compare) _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 1427541229..e9e31411e4 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -188,6 +188,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); } inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } + +/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. +struct ProgmemStringRef { + PGM_P str; + size_t len; +}; + +inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } + +inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f2d4628044..fe0b1b02ed 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1293,13 +1293,25 @@ async def test_apply_condition_compares_config_value( @pytest.mark.asyncio -@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) -async def test_apply_condition_string_constant_is_a_plain_literal( - registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +@pytest.mark.parametrize( + ("platform", "value", "expected"), + [ + ("esp32", "two", '"two"'), + ("esp8266", "two", '::esphome::ProgmemStringRef{PSTR("two"), 3}'), + # The length is in bytes, not characters + ("esp8266", "é", '::esphome::ProgmemStringRef{PSTR("\\303\\251"), 2}'), + ], +) +async def test_apply_condition_string_constant( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + value: str, + expected: str, ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) - await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + await _run_apply_condition(registries, check, {"state": value}, platform=platform) + assert f"return ::{PARENT_OBJ}->state == {expected};" in _apply_definition(mock_cg) @pytest.mark.asyncio