diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a276020be4..fd6eb7980b 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -233,6 +233,28 @@ bool str_contains_ignore_case_fallback(const char *haystack, const char *needle) return false; } +bool str_contains_ignore_case_p(const char *haystack, const char *needle) { + if (haystack == nullptr || needle == nullptr) { + return false; + } + const auto *needle_p = reinterpret_cast(needle); + if (progmem_read_byte(needle_p) == '\0') { + return true; + } + for (const char *p = haystack; *p != '\0'; p++) { + size_t i = 0; + uint8_t n; + // Never reads past the haystack terminator: comparing '\0' against a non-'\0' needle byte fails + while ((n = progmem_read_byte(needle_p + i)) != '\0' && ::tolower(static_cast(p[i])) == ::tolower(n)) { + i++; + } + if (n == '\0') { + return true; + } + } + return false; +} + // str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5a9c120b84..9ea9280f3d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1000,6 +1000,11 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { #endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) } +/// Case-insensitive check if needle string is contained in haystack (no heap allocation). +/// The needle may live in flash (PROGMEM) on ESP8266 — wrap literals with PSTR(). +/// On all other platforms PROGMEM reads are plain dereferences, so any string works. +bool str_contains_ignore_case_p(const char *haystack, const char *needle); + // str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 // str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index d5219f9d47..77b8d98c78 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -129,4 +129,24 @@ TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) { EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc")); } +TEST(StringContainsIgnoreCaseTest, ProgmemVariantNullPointerAlwaysFalse) { + const char *haystack = nullptr; + const char *needle = nullptr; + + EXPECT_FALSE(str_contains_ignore_case_p(haystack, needle)); + EXPECT_FALSE(str_contains_ignore_case_p("Hello World", needle)); + EXPECT_FALSE(str_contains_ignore_case_p(haystack, "anything")); +} + +TEST(StringContainsIgnoreCaseTest, ProgmemVariantMatchesFallback) { + const char *haystack = "Hello World"; + for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) { + EXPECT_EQ(str_contains_ignore_case_p(haystack, needle), str_contains_ignore_case_fallback(haystack, needle)) + << "needle: " << needle; + } + EXPECT_TRUE(str_contains_ignore_case_p("", "")); + EXPECT_FALSE(str_contains_ignore_case_p("", "a")); + EXPECT_FALSE(str_contains_ignore_case_p("ab", "abc")); +} + } // namespace esphome