diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9ed00f6b27c..16f2723e3e9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2025,7 +2025,7 @@ FRAMEWORK_SCHEMA = cv.Schema( CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[] ): cv.ensure_list(cv.string_strict), cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, - cv.Optional(CONF_ENABLE_FULL_SCANF, default=False): cv.boolean, + cv.Optional(CONF_ENABLE_FULL_SCANF): cv.boolean, cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean, cv.Optional( @@ -2326,36 +2326,52 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_sscanf_stub(enable_full_scanf: bool) -> None: - """Wrap sscanf when Bluetooth is the only reason newlib's scanf engine links. +def _libc_is_newlib() -> bool: + """Whether the ESP-IDF build links newlib rather than picolibc. - Runs at FINAL priority so every request_bluetooth() call has happened. - bluedroid is the only sscanf caller in an ESPHome image (~13 KB of engine - for two "%02x" parses); see sscanf_stubs.cpp. Newlib only, like the printf - wrap: IDF 6.0+ switches to picolibc, where the saving is unmeasured. A - lambda that scans a float keeps the libc sscanf, since the stub has no - floating-point conversions and the wrap applies to the whole image. The - --undefined flag is needed because libsrc.a is scanned before libbt.a, so - the stub would otherwise never be pulled from the archive. + IDF 5.x defaults to newlib on every variant; IDF 6.0+ switches to picolibc + on every variant. The printf and sscanf wraps are only measured against + newlib. + """ + return not CORE.using_arduino and idf_version() < cv.Version(6, 0, 0) + + +def _add_wrap_stub(symbol: str, define: str) -> None: + """Emit a linker wrap for ``symbol`` served by a stub compiled under ``define``. + + The --undefined flag is needed because libsrc.a is scanned before the IDF + libraries that reference the symbol, so the stub would otherwise never be + pulled from the archive. + """ + cg.add_define(define) + cg.add_build_flag(f"-Wl,--wrap={symbol}") + cg.add_build_flag(f"-Wl,--undefined=__wrap_{symbol}") + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_sscanf_stub(enable_full_scanf: bool | None) -> None: + """Wrap sscanf when bluedroid is the only reason newlib's scanf engine links. + + Runs at FINAL priority so every request_bluetooth() call has happened; + see sscanf_stubs.cpp for what the stub covers. A lambda that scans a float + keeps the libc sscanf unless enable_full_scanf is set explicitly, since the + stub has no floating-point conversions and the wrap applies to the whole + image. """ if ( enable_full_scanf - or CORE.using_arduino - or idf_version() >= cv.Version(6, 0, 0) + or not _libc_is_newlib() or not _network_sdkconfig().bluetooth or get_esp32_variant() in ROM_SSCANF_VARIANTS ): return - if lambdas_use_scanf_float(CORE.config): + if enable_full_scanf is None and lambdas_use_scanf_float(CORE.config): _LOGGER.warning( "Lambda uses scanf with a float format specifier; " "keeping the libc sscanf (~13KB flash)" ) return - cg.add_define("USE_ESP32_SSCANF_STUB") - cg.add_build_flag("-Wl,--wrap=sscanf") - cg.add_build_flag("-Wl,--undefined=__wrap_sscanf") + _add_wrap_stub("sscanf", "USE_ESP32_SSCANF_STUB") @coroutine_with_priority(CoroPriority.FINAL) @@ -2700,12 +2716,8 @@ async def to_code(config): # implements vsnprintf by building a string-output FILE and calling # vfprintf, so vfprintf is unconditionally linked in by any caller # of snprintf/vsnprintf — effectively every build — and the wrap - # saves nothing while costing ~170 B of shim. IDF 5.x defaults to - # newlib on every variant; IDF 6.0+ switches to picolibc on every - # variant. - if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version( - 6, 0, 0 - ): + # saves nothing while costing ~170 B of shim. + if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or not _libc_is_newlib(): cg.add_define("USE_FULL_PRINTF") else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): @@ -2713,14 +2725,9 @@ async def to_code(config): # esp_http_client calls vasprintf, which on the ESP32-C6 is the only # reference to newlib's full printf engine (~20 KB: _svfprintf_r, # _dtoa_r and their helpers); every other caller resolves to the - # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed - # because libsrc.a is scanned before the IDF libraries that - # reference the symbol, so the stub would otherwise never be pulled - # from the archive. + # ROM. See vasprintf_stubs.cpp. if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: - cg.add_define("USE_ESP32_VASPRINTF_STUB") - cg.add_build_flag("-Wl,--wrap=vasprintf") - cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") + _add_wrap_stub("vasprintf", "USE_ESP32_VASPRINTF_STUB") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -3144,7 +3151,7 @@ async def to_code(config): CORE.add_job(_reconcile_network_sdkconfig) # FINAL priority: runs after every request_bluetooth() call - CORE.add_job(_add_sscanf_stub, advanced[CONF_ENABLE_FULL_SCANF]) + CORE.add_job(_add_sscanf_stub, advanced.get(CONF_ENABLE_FULL_SCANF)) # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) @@ -3694,5 +3701,5 @@ def process_stacktrace(config, line, backtrace_state): # gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which # are instantiated solely by the pin schema codegen (esp32_pin_to_code) FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} + {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO", "sscanf_stubs.cpp": "USE_ESP32_SSCANF_STUB"} ) diff --git a/esphome/components/esp32/sscanf_stubs.cpp b/esphome/components/esp32/sscanf_stubs.cpp index 1cd5c611841..6bc2ab65f3d 100644 --- a/esphome/components/esp32/sscanf_stubs.cpp +++ b/esphome/components/esp32/sscanf_stubs.cpp @@ -1,19 +1,20 @@ /* - * Linker wrap stub for sscanf() (ESP-IDF, newlib only). + * Linker wrap stub for sscanf on ESP-IDF, newlib only. * - * Nothing in ESPHome calls sscanf(), but bluedroid does in two places: - * btc_config_get_bin() decodes stored bonding keys with "%02x" and - * string_to_bdaddr() parses "%02x:%02x:%02x:%02x:%02x:%02x". Those two + * Nothing in ESPHome calls sscanf, but bluedroid does in two places: + * btc_config_get_bin decodes stored bonding keys with "%02x" and + * string_to_bdaddr parses "%02x:%02x:%02x:%02x:%02x:%02x". Those two * calls are the only reference to newlib's scanf engine (__ssvfscanf_r, * _strtod_l and their helpers, ~13 KB) in a Bluetooth build on variants * whose ROM does not export sscanf. * - * This stub replaces sscanf() with a small scanner covering the integer, - * character, string and scanset conversions, so any other sscanf() caller - * in the image (user lambdas included) keeps working. Floating-point - * conversions are the one gap: supporting them would pull _strtod_l - * straight back, so a format using %f and friends aborts with a message - * pointing at enable_full_scanf: true. + * This stub replaces sscanf with a small scanner covering the integer, + * character, string and scanset conversions, so any other caller in the + * image (user lambdas included) keeps working. Floating-point conversions + * are the one gap: supporting them would pull _strtod_l straight back, so + * a format using %f and friends aborts with a message pointing at + * enable_full_scanf: true. Codegen already keeps the libc sscanf when a + * lambda scans a float. * * Only compiled in when codegen defines USE_ESP32_SSCANF_STUB, which is * gated on a Bluetooth component being present, on the variant's ROM and @@ -30,75 +31,59 @@ #include #include "esp_system.h" +#include "esphome/core/helpers.h" namespace esphome::esp32 { namespace { +// Store width of an integer conversion. Every ESP32 target is ILP32, so the +// bare length, l, z and t all store 32 bits and ll and j store 64. enum class SscanfLength : uint8_t { SSCANF_LENGTH_NONE, SSCANF_LENGTH_HH, SSCANF_LENGTH_H, - SSCANF_LENGTH_L, SSCANF_LENGTH_LL, - SSCANF_LENGTH_Z, - SSCANF_LENGTH_J, - SSCANF_LENGTH_T, }; bool is_space(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); } -int digit_value(char c, unsigned base) { - unsigned value; - if (c >= '0' && c <= '9') { - value = c - '0'; - } else if (c >= 'a' && c <= 'z') { - value = c - 'a' + 10; - } else if (c >= 'A' && c <= 'Z') { - value = c - 'A' + 10; - } else { - return -1; - } - return value < base ? static_cast(value) : -1; -} - -// Stores through the unsigned counterpart of each length; the bit pattern is -// what the signed conversions want as well. +// The bit pattern is what the signed conversions want as well. All object +// pointers share one representation here, so the argument is fetched once +// as void * rather than once per pointee type. void store_int(va_list &ap, SscanfLength length, unsigned long long value) { + void *dest = va_arg(ap, void *); switch (length) { case SscanfLength::SSCANF_LENGTH_HH: - *va_arg(ap, unsigned char *) = static_cast(value); + *static_cast(dest) = static_cast(value); break; case SscanfLength::SSCANF_LENGTH_H: - *va_arg(ap, unsigned short *) = static_cast(value); - break; - case SscanfLength::SSCANF_LENGTH_L: - *va_arg(ap, unsigned long *) = static_cast(value); + *static_cast(dest) = static_cast(value); break; case SscanfLength::SSCANF_LENGTH_LL: - case SscanfLength::SSCANF_LENGTH_J: - *va_arg(ap, unsigned long long *) = value; - break; - case SscanfLength::SSCANF_LENGTH_Z: - *va_arg(ap, size_t *) = static_cast(value); - break; - case SscanfLength::SSCANF_LENGTH_T: - *va_arg(ap, ptrdiff_t *) = static_cast(value); + *static_cast(dest) = value; break; default: - *va_arg(ap, unsigned int *) = static_cast(value); + *static_cast(dest) = static_cast(value); break; } } -// Parses a "[set]" directive starting just after the '[' into a 256-bit -// membership bitmap and returns the position after the closing ']'. -const char *parse_scanset(const char *f, uint8_t *bitmap, bool &negate) { - for (size_t i = 0; i < 32; i++) - bitmap[i] = 0; - negate = false; +struct Scanset { + uint8_t bits[32]{}; + bool negate{false}; + + bool contains(char c) const { + auto u = static_cast(c); + return ((this->bits[u >> 3] >> (u & 7)) & 1u) != this->negate; + } +}; + +// Parses a "[set]" directive starting just after the '[' and returns the +// position after the closing ']'. +const char *parse_scanset(const char *f, Scanset &set) { if (*f == '^') { - negate = true; + set.negate = true; f++; } // A ']' right after '[' or '[^' is a member, not the terminator. @@ -112,20 +97,33 @@ const char *parse_scanset(const char *f, uint8_t *bitmap, bool &negate) { f += 2; } for (unsigned c = lo; c <= hi; c++) - bitmap[c >> 3] |= static_cast(1u << (c & 7)); + set.bits[c >> 3] |= static_cast(1u << (c & 7)); } return *f == ']' ? f + 1 : f; } -bool in_scanset(const uint8_t *bitmap, bool negate, char c) { - auto u = static_cast(c); - return ((bitmap[u >> 3] >> (u & 7)) & 1u) != negate; -} - [[noreturn]] void unsupported_conversion() { esp_system_abort("sscanf: unsupported conversion; set enable_full_scanf: true in esp32 framework advanced config"); } +// Radix of an integer conversion, 0 meaning "detect from the prefix" (%i). +unsigned int_base(char conv) { + switch (conv) { + case 'd': + case 'u': + return 10; + case 'i': + return 0; + case 'o': + return 8; + case 'x': + case 'X': + return 16; + default: + unsupported_conversion(); + } +} + int vsscanf_stub(const char *str, const char *fmt, va_list ap) { const char *in = str; int assigned = 0; @@ -136,7 +134,15 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { in++; continue; } - if (*f != '%') { + bool literal = *f != '%'; + if (!literal && f[1] == '%') { + // "%%" matches one '%' after skipping input whitespace + f++; + while (is_space(*in)) + in++; + literal = true; + } + if (literal) { if (*in != *f) { input_failure = *in == '\0'; break; @@ -145,16 +151,6 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { continue; } f++; - if (*f == '%') { - while (is_space(*in)) - in++; - if (*in != '%') { - input_failure = *in == '\0'; - break; - } - in++; - continue; - } bool suppress = false; if (*f == '*') { suppress = true; @@ -168,6 +164,7 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { if (width == 0) width = SIZE_MAX; auto length = SscanfLength::SSCANF_LENGTH_NONE; + bool has_length = true; switch (*f) { case 'h': f++; @@ -183,26 +180,18 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { if (*f == 'l') { length = SscanfLength::SSCANF_LENGTH_LL; f++; - } else { - length = SscanfLength::SSCANF_LENGTH_L; } break; case 'j': - length = SscanfLength::SSCANF_LENGTH_J; + length = SscanfLength::SSCANF_LENGTH_LL; f++; break; case 'z': - length = SscanfLength::SSCANF_LENGTH_Z; - f++; - break; case 't': - length = SscanfLength::SSCANF_LENGTH_T; - f++; - break; - case 'L': f++; break; default: + has_length = false; break; } const char conv = *f; @@ -213,7 +202,9 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { store_int(ap, length, static_cast(in - str)); continue; } - const bool wide = length == SscanfLength::SSCANF_LENGTH_L || length == SscanfLength::SSCANF_LENGTH_LL; + const bool string_conv = conv == 'c' || conv == 's' || conv == '['; + if (string_conv && has_length) + unsupported_conversion(); // wide characters if (conv != 'c' && conv != '[') { while (is_space(*in)) in++; @@ -222,43 +213,18 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { input_failure = true; break; } - unsigned base; - switch (conv) { - case 'd': - case 'u': - base = 10; - break; - case 'i': - base = 0; - break; - case 'o': - base = 8; - break; - case 'x': - case 'X': - base = 16; - break; - case 'c': - case 's': - case '[': - if (wide) - unsupported_conversion(); - base = 1; - break; - default: - unsupported_conversion(); - } - if (base != 1) { + if (!string_conv) { + unsigned base = int_base(conv); const char *p = in; size_t n = 0; bool negative = false; - if ((*p == '+' || *p == '-') && n < width) { + if (*p == '+' || *p == '-') { negative = *p == '-'; p++; n++; } if ((base == 0 || base == 16) && p[0] == '0' && (p[1] == 'x' || p[1] == 'X') && n + 2 < width && - digit_value(p[2], 16) >= 0) { + parse_hex_char(p[2]) != INVALID_HEX_CHAR) { p += 2; n += 2; base = 16; @@ -267,13 +233,8 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { base = *p == '0' ? 8 : 10; unsigned long long value = 0; bool any_digit = false; - while (n < width) { - int digit = digit_value(*p, base); - if (digit < 0) - break; - value = value * base + static_cast(digit); - p++; - n++; + for (uint8_t digit; n < width && (digit = parse_hex_char(*p)) < base; p++, n++) { + value = value * base + digit; any_digit = true; } if (!any_digit) @@ -286,33 +247,25 @@ int vsscanf_stub(const char *str, const char *fmt, va_list ap) { continue; } char *dest = suppress ? nullptr : va_arg(ap, char *); - if (conv == 'c') { - // Like newlib, a short read still assigns whatever was available. - const size_t count = width == SIZE_MAX ? 1 : width; - size_t n = 0; - while (n < count && in[n] != '\0') { - if (dest != nullptr) - dest[n] = in[n]; - n++; - } - in += n; - } else { - uint8_t bitmap[32]; - bool negate = false; - if (conv == '[') - f = parse_scanset(f + 1, bitmap, negate) - 1; - size_t n = 0; - while (n < width && in[n] != '\0' && (conv == '[' ? in_scanset(bitmap, negate, in[n]) : !is_space(in[n]))) { - if (dest != nullptr) - dest[n] = in[n]; - n++; - } + Scanset set; + if (conv == '[') + f = parse_scanset(f + 1, set) - 1; // the loop's f++ steps past the ']' + // %c reads exactly width characters (default 1); like newlib, a short + // read still assigns whatever was available. + const size_t limit = conv == 'c' && width == SIZE_MAX ? 1 : width; + size_t n = 0; + while (n < limit && in[n] != '\0' && (conv == 'c' || (conv == '[' ? set.contains(in[n]) : !is_space(in[n])))) { + if (dest != nullptr) + dest[n] = in[n]; + n++; + } + if (conv != 'c') { if (n == 0) break; if (dest != nullptr) dest[n] = '\0'; - in += n; } + in += n; if (!suppress) assigned++; } diff --git a/script/ci-custom.py b/script/ci-custom.py index 0d8f6662ff4..e2b7cd8d376 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1055,8 +1055,6 @@ def lint_no_std_to_string(fname, match): # [^\w] ensures we match function calls, not substrings r"[^\w]((?:std::)?v?[fs]?scanf)\s*\(" + CPP_RE_EOL, include=cpp_include, - # The linker wrap that keeps the scanf engine out of Bluetooth builds - exclude=["esphome/components/esp32/sscanf_stubs.cpp"], ) def lint_no_scanf(fname, match): func = match.group(1) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 74b246f4105..566a38d9b7e 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1416,54 +1416,43 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS -_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} - - @pytest.mark.parametrize( - ("config_file", "expected"), + ("config_file", "symbol", "expected"), [ - pytest.param("vasprintf_stub_c6.yaml", True, id="c6"), - pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"), - pytest.param("exclusion_reincludes.yaml", False, id="esp32"), + # vasprintf: only where the ROM lacks vasprintf but has vsnprintf + pytest.param("vasprintf_stub_c6.yaml", "vasprintf", True, id="vasprintf_c6"), + pytest.param( + "vasprintf_stub_c6_full_printf.yaml", + "vasprintf", + False, + id="vasprintf_c6_full_printf", + ), + pytest.param( + "exclusion_reincludes.yaml", "vasprintf", False, id="vasprintf_esp32" + ), + # sscanf: only for Bluetooth builds on variants whose ROM lacks sscanf, + # and not when a lambda scans a float + pytest.param("sscanf_stub_ble.yaml", "sscanf", True, id="sscanf_esp32_ble"), + pytest.param("sscanf_stub_ble_c6.yaml", "sscanf", False, id="sscanf_c6_rom"), + pytest.param( + "sscanf_stub_ble_full_scanf.yaml", "sscanf", False, id="sscanf_full_scanf" + ), + pytest.param( + "sscanf_stub_ble_lambda.yaml", "sscanf", False, id="sscanf_lambda_float" + ), + pytest.param("exclusion_reincludes.yaml", "sscanf", False, id="sscanf_no_ble"), ], ) -def test_vasprintf_stub_only_on_rom_vsnprintf_variants( +def test_wrap_stub_emission( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], config_file: str, + symbol: str, expected: bool, ) -> None: - """The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf.""" + """A linker wrap stub is emitted as a define plus --wrap/--undefined flag pair.""" generate_main(component_config_path(config_file)) - assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected + flags = {f"-Wl,--wrap={symbol}", f"-Wl,--undefined=__wrap_{symbol}"} + assert (CORE.build_flags >= flags) is expected defines = {define.name for define in CORE.defines} - assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected - - -_SSCANF_STUB_FLAGS = {"-Wl,--wrap=sscanf", "-Wl,--undefined=__wrap_sscanf"} - - -@pytest.mark.parametrize( - ("config_file", "expected"), - [ - pytest.param("sscanf_stub_ble.yaml", True, id="esp32_ble"), - pytest.param("sscanf_stub_ble_c6.yaml", False, id="c6_ble_rom_sscanf"), - pytest.param("sscanf_stub_ble_full_scanf.yaml", False, id="full_scanf"), - pytest.param("sscanf_stub_ble_lambda.yaml", False, id="lambda_calls_sscanf"), - pytest.param("exclusion_reincludes.yaml", False, id="esp32_no_ble"), - ], -) -def test_sscanf_stub_only_for_bluetooth_without_rom_sscanf( - generate_main: Callable[[str | Path], str], - component_config_path: Callable[[str], Path], - config_file: str, - expected: bool, -) -> None: - """The sscanf wrap is emitted only for Bluetooth builds on variants whose ROM lacks sscanf. - - User code that calls a scanf function opts the build out automatically. - """ - generate_main(component_config_path(config_file)) - assert (CORE.build_flags >= _SSCANF_STUB_FLAGS) is expected - defines = {define.name for define in CORE.defines} - assert ("USE_ESP32_SSCANF_STUB" in defines) is expected + assert (f"USE_ESP32_{symbol.upper()}_STUB" in defines) is expected diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py index fb0e437d241..219c19288a1 100644 --- a/tests/unit_tests/components/test_esp8266.py +++ b/tests/unit_tests/components/test_esp8266.py @@ -8,64 +8,8 @@ from unittest.mock import MagicMock, patch import pytest from esphome.components import esp8266 -from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float -from esphome.core import EsphomeError, Lambda -from esphome.types import ConfigType - - -@pytest.mark.parametrize( - ("src", "expected"), - [ - # Basic float formats - ('sscanf(buf, "%f", &v)', True), - ('sscanf(buf, "%F", &v)', True), - ('sscanf(buf, "%e", &v)', True), - ('sscanf(buf, "%E", &v)', True), - ('sscanf(buf, "%g", &v)', True), - ('sscanf(buf, "%G", &v)', True), - ('sscanf(buf, "%a", &v)', True), - ('sscanf(buf, "%A", &v)', True), - # With modifiers - ('sscanf(buf, "%lf", &v)', True), - ('sscanf(buf, "%Lf", &v)', True), - ('sscanf(buf, "%8lf", &v)', True), - ('sscanf(buf, "%*f")', True), - ('sscanf(buf, "%.2f", &v)', True), - # Mixed formats - ('sscanf(buf, "%d,%f", &a, &b)', True), - # fscanf and std::sscanf - ('fscanf(fp, "%f", &v)', True), - ('std::sscanf(buf, "%f", &v)', True), - # Multi-line - ('sscanf(buf,\n"%f", &v)', True), - # No float format - ('sscanf(buf, "%d", &v)', False), - ('sscanf(buf, "%s", s)', False), - # printf not scanf - ('printf("%f", val)', False), - # %f in a different statement after scanf - ('sscanf(buf, "%d", &x); printf("%f", val);', False), - # scanf %f in comment only - ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), - ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), - ], -) -def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: - """Test scanf float detection in lambda source.""" - config: ConfigType = {"test": [Lambda(src)]} - assert lambdas_use_scanf_float(config) is expected - - -def test_lambdas_use_scanf_float_no_lambdas() -> None: - """Test with config containing no lambdas.""" - config: ConfigType = {"key": "value", "list": [1, 2]} - assert lambdas_use_scanf_float(config) is False - - -def test_lambdas_use_scanf_float_nested() -> None: - """Test detection in deeply nested config.""" - config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} - assert lambdas_use_scanf_float(config) is True +from esphome.components.esp8266 import check_rosetta +from esphome.core import EsphomeError @pytest.fixture diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index e53016dfc39..2f175416eed 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -10,6 +10,7 @@ from esphome.config_helpers import ( filter_source_files_from_platform, frameworks_for_platforms, get_logger_level, + lambdas_use_scanf_float, ) from esphome.const import ( CONF_LEVEL, @@ -19,7 +20,8 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PlatformFramework, ) -from esphome.core import Define +from esphome.core import Define, Lambda +from esphome.types import ConfigType def test_filter_source_files_from_platform_esp32() -> None: @@ -172,3 +174,58 @@ def test_filter_source_files_from_defines() -> None: mock_core.defines = set() assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"] + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + # Basic float formats + ('sscanf(buf, "%f", &v)', True), + ('sscanf(buf, "%F", &v)', True), + ('sscanf(buf, "%e", &v)', True), + ('sscanf(buf, "%E", &v)', True), + ('sscanf(buf, "%g", &v)', True), + ('sscanf(buf, "%G", &v)', True), + ('sscanf(buf, "%a", &v)', True), + ('sscanf(buf, "%A", &v)', True), + # With modifiers + ('sscanf(buf, "%lf", &v)', True), + ('sscanf(buf, "%Lf", &v)', True), + ('sscanf(buf, "%8lf", &v)', True), + ('sscanf(buf, "%*f")', True), + ('sscanf(buf, "%.2f", &v)', True), + # Mixed formats + ('sscanf(buf, "%d,%f", &a, &b)', True), + # fscanf and std::sscanf + ('fscanf(fp, "%f", &v)', True), + ('std::sscanf(buf, "%f", &v)', True), + # Multi-line + ('sscanf(buf,\n"%f", &v)', True), + # No float format + ('sscanf(buf, "%d", &v)', False), + ('sscanf(buf, "%s", s)', False), + # printf not scanf + ('printf("%f", val)', False), + # %f in a different statement after scanf + ('sscanf(buf, "%d", &x); printf("%f", val);', False), + # scanf %f in comment only + ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), + ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), + ], +) +def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: + """Test scanf float detection in lambda source.""" + config: ConfigType = {"test": [Lambda(src)]} + assert lambdas_use_scanf_float(config) is expected + + +def test_lambdas_use_scanf_float_no_lambdas() -> None: + """Test with config containing no lambdas.""" + config: ConfigType = {"key": "value", "list": [1, 2]} + assert lambdas_use_scanf_float(config) is False + + +def test_lambdas_use_scanf_float_nested() -> None: + """Test detection in deeply nested config.""" + config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} + assert lambdas_use_scanf_float(config) is True