diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e31d0352e9..d16e8ae03c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2248,6 +2248,62 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_vfs_fatfs_sdkconfig( + disable_vfs_termios: bool, + disable_vfs_select: bool, + disable_vfs_dir: bool, + disable_fatfs: bool, +) -> None: + """Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win.""" + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off. + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios) + + # VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread); + # sockets use lwip_select() either way. ~2.7KB flash when off. + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_SELECT", True) + else: + set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select) + + # Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off. + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_DIR", True) + else: + set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir) + + # FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only; + # sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set + # any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack. + lfn_keys = ( + "CONFIG_FATFS_LFN_NONE", + "CONFIG_FATFS_LFN_HEAP", + "CONFIG_FATFS_LFN_STACK", + ) + user_picked_lfn = any(k in opts for k in lfn_keys) + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", False) + set_opt("CONFIG_FATFS_LFN_HEAP", True) + set_opt("CONFIG_FATFS_MAX_LFN", 255) + set_opt("CONFIG_FATFS_VOLUME_COUNT", 4) + elif disable_fatfs: + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", True) + # Kconfig range is [1,10]; 0 gets clamped to the default. + set_opt("CONFIG_FATFS_VOLUME_COUNT", 1) + + @coroutine_with_priority(CoroPriority.FINAL - 1) async def _finalize_arduino_aware_flags(): """Build flags that depend on whether arduino-esp32 is linked in. @@ -2603,47 +2659,6 @@ async def to_code(config): if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) - # Disable VFS support for termios (terminal I/O functions) - # USB Serial JTAG VFS functions require termios support. - # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected - # as the logger output) call require_vfs_termios(). - # Saves approximately 1.8KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): - # Component requires VFS termios - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) - - # Disable VFS support for select() with file descriptors - # ESPHome only uses select() with sockets via lwip_select(), which still works. - # VFS select is only needed for UART/eventfd file descriptors. - # Components that need it (e.g., openthread) call require_vfs_select(). - # Saves approximately 2.7KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): - # Component requires VFS select - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] - ) - - # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) - # ESPHome doesn't use directory functions on ESP32. - # Components that need it (e.g., storage components) call require_vfs_dir(). - # Saves approximately 0.5KB+ of flash when disabled (default). - if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): - # Component requires VFS directory support - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] - ) - if use_platformio: cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: @@ -2878,6 +2893,16 @@ async def to_code(config): # FINAL priority: runs after every network/coexistence request_*() call CORE.add_job(_reconcile_network_sdkconfig) + # FINAL: require_*() calls can come from to_code at or below this priority, so an + # inline read would be iteration-order-dependent; reconcile once after every job ran. + CORE.add_job( + _reconcile_vfs_fatfs_sdkconfig, + advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS], + advanced[CONF_DISABLE_VFS_SUPPORT_SELECT], + advanced[CONF_DISABLE_VFS_SUPPORT_DIR], + advanced[CONF_DISABLE_FATFS], + ) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: @@ -2893,17 +2918,6 @@ async def to_code(config): ): add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) - # Disable FATFS support - # Components that need FATFS (SD card, etc.) can call require_fatfs() - if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): - # Component called require_fatfs() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) - elif advanced[CONF_DISABLE_FATFS]: - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - # Kconfig range is [1,10]; 0 gets clamped to the default. - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 5620a220f8..1fd835076d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,11 +10,16 @@ from typing import Any import pytest from esphome.components.esp32 import ( + KEY_FATFS_REQUIRED, + KEY_VFS_DIR_REQUIRED, + KEY_VFS_SELECT_REQUIRED, + KEY_VFS_TERMIOS_REQUIRED, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, + _reconcile_vfs_fatfs_sdkconfig, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -614,6 +619,160 @@ def test_reconcile_network_sdkconfig( assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected +@pytest.mark.parametrize( + ("requires", "fatfs_required", "disables", "preset", "expected"), + [ + # Nothing required and every disable_* flag off (NOT the shipped defaults, which + # disable everything): VFS enabled, FATFS left untouched entirely. + pytest.param( + {}, + False, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="nothing_disabled_nothing_required", + ), + # The shipped out-of-the-box path: every disable_* flag defaults to True and nothing + # is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume). + pytest.param( + {}, + False, + (True, True, True, True), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": False, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": False, + "CONFIG_FATFS_LFN_NONE": True, + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="all_disabled_fatfs_fallback", + ), + # A component's require_* beats the user's disable_* flag for every VFS feature. + pytest.param( + { + KEY_VFS_TERMIOS_REQUIRED: True, + KEY_VFS_SELECT_REQUIRED: True, + KEY_VFS_DIR_REQUIRED: True, + }, + False, + (True, True, True, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="require_beats_disable", + ), + # A user sdkconfig_options preset wins over a require (the set_opt guard). + pytest.param( + {KEY_VFS_SELECT_REQUIRED: True}, + False, + (False, False, False, False), + {"CONFIG_VFS_SUPPORT_SELECT": False}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="user_preset_wins_over_require", + ), + # require_fatfs() with no user preset: long filenames on the heap, 255 chars, + # four volumes. + pytest.param( + {}, + True, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": False, + "CONFIG_FATFS_LFN_HEAP": True, + "CONFIG_FATFS_MAX_LFN": 255, + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_required_defaults", + ), + # CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member + # (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_STACK": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_STACK": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_stack_untouched", + ), + # disable_fatfs (the shipped default) with a user LFN pick: the choice group is the + # user's -- no LFN_NONE=y written next to their member, only the volume fallback. + pytest.param( + {}, + False, + (False, False, False, True), + {"CONFIG_FATFS_LFN_HEAP": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_HEAP": "y", + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="disable_fatfs_user_lfn_untouched", + ), + # Same for an explicit LFN_NONE preset: the group is the user's, only the volume + # count default is added. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_NONE": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_none_untouched", + ), + ], +) +def test_reconcile_vfs_fatfs_sdkconfig( + set_core_config: SetCoreConfigCallable, + requires: dict[str, bool], + fatfs_required: bool, + disables: tuple[bool, bool, bool, bool], + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves the VFS feature flags and the FATFS + defaults from the recorded require_* calls, with user sdkconfig_options winning + and the LFN Kconfig choice treated as one group.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)} + if fatfs_required: + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + for key, value in requires.items(): + CORE.data[key] = value + + asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables)) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + def test_network_wifi_only_reconciles_end_to_end( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path],