diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ab526134f80..72a99675909 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 +075ed2142432dc59883bb52db8ac11270f952851d6400deae080f5468c7cb592 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a7a02a266c..29f63b54b52 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "--privileged", "-e", "GIT_EDITOR=code --wait" - // uncomment and edit the path in order to pass though local USB serial to the conatiner + // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], "appPort": 6052, diff --git a/esphome/automation.py b/esphome/automation.py index b4dcc419950..97d9a0a47a8 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -199,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False): return cv.Schema([schema])(value) except cv.Invalid as err2: if "extra keys not allowed" in str(err2) and len(err2.path) == 2: - # pylint: disable=raise-missing-from - raise err + raise err from None if "Unable to find action" in str(err): - raise err2 - raise cv.MultipleInvalid([err, err2]) + raise err2 from None + raise cv.MultipleInvalid([err, err2]) from None elif isinstance(value, dict): if CONF_THEN in value: return [schema(value)] diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c95e..efa80acc8cc 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index b141329e945..444306cec3a 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -83,7 +83,7 @@ def angle_to_position(value, min=-360, max=360): value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION except cv.Invalid as e: - raise cv.Invalid(f"When using angle, {e.error_message}") + raise cv.Invalid(f"When using angle, {e.error_message}") from e def percent_to_position(value): @@ -164,7 +164,7 @@ def has_valid_range_config(): except cv.Invalid as e: raise cv.Invalid( f"The range between start and end position is invalid. It was was {range} but {e.error_message}" - ) + ) from e return validator diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 3ed6c1cd928..bb1ce257db4 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -116,7 +116,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 0b36c299f6a..29ddbab02cd 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -332,8 +332,9 @@ def parse_multi_click_timing_str(value): try: state = cv.boolean(parts[0]) except cv.Invalid: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"First word must either be ON or OFF, not {parts[0]}") + raise cv.Invalid( + f"First word must either be ON or OFF, not {parts[0]}" + ) from None if parts[1] != "for": raise cv.Invalid(f"Second word must be 'for', got {parts[1]}") @@ -350,7 +351,9 @@ def parse_multi_click_timing_str(value): try: length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing length failed: {err}" + ) from err return {CONF_STATE: state, key: str(length)} if parts[3] != "to": @@ -359,12 +362,16 @@ def parse_multi_click_timing_str(value): try: min_length = cv.positive_time_period_milliseconds(parts[2]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing minimum length failed: {err}" + ) from err try: max_length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing maximum length failed: {err}" + ) from err return { CONF_STATE: state, diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 062094c036f..d911301c9d3 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -63,7 +63,7 @@ void BM8563::read_time() { rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index b63443c5f30..b56217fac18 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -171,7 +171,9 @@ async def to_code_base(config): with open(path, encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: - raise core.EsphomeError(f"Could not open binary configuration file {path}: {e}") + raise core.EsphomeError( + f"Could not open binary configuration file {path}: {e}" + ) from e # Convert retrieved BSEC2 config to an array of ints rhs = [int(x) for x in bsec2_iaq_config.split(",")] diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 8fff4213b4c..ba2ad6032f4 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -44,7 +44,7 @@ void DS1307Component::read_time() { .year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b3f9da3da6..a68614cb436 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1222,7 +1222,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a1339a4bc11..a10c45a9d78 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -325,7 +325,7 @@ def download_gfont(value): raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" - ) + ) from e match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) if match is None: raise cv.Invalid( diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 3c2021d40e8..390b26ba1d4 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -60,20 +60,35 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): - var = await binary_sensor.new_binary_sensor(config) - await cg.register_component(var, config) +def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: + """Check if pin is shared exclusively with deep_sleep (wakeup pin).""" + pin_key = (CORE.target_platform, CORE.target_platform, pin_num) + pin_users = pins.PIN_SCHEMA_REGISTRY.pins_used.get(pin_key, []) + if len(pin_users) != 2: + return False + return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - # Check for ESP8266 GPIO16 interrupt limitation - # GPIO16 on ESP8266 is a special pin that doesn't support interrupts through - # the Arduino attachInterrupt() function. This is the only known GPIO pin - # across all supported platforms that has this limitation, so we handle it - # here instead of in the platform-specific code. +def _final_validate(config): use_interrupt = config[CONF_USE_INTERRUPT] - if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16: + if not use_interrupt: + return config + + pin_num = config[CONF_PIN][CONF_NUMBER] + + # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt + # attachment — only internal/native GPIO pins do. + if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: + _LOGGER.info( + "GPIO binary_sensor '%s': Pin is not an internal GPIO, " + "falling back to polling mode.", + config.get(CONF_NAME, config[CONF_ID]), + ) + config[CONF_USE_INTERRUPT] = False + return config + + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). + if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( "GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. " "Falling back to polling mode (same as in ESPHome <2025.7). " @@ -81,22 +96,45 @@ async def to_code(config): "performance with interrupts.", config.get(CONF_NAME, config[CONF_ID]), ) - use_interrupt = False + config[CONF_USE_INTERRUPT] = False + return config - # Check if pin is shared with other components (allow_other_uses) # When a pin is shared, interrupts can interfere with other components - # (e.g., duty_cycle sensor) that need to monitor the pin's state changes - if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): - _LOGGER.info( - "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. " - "The sensor will use polling mode for compatibility with other pin uses.", - config.get(CONF_NAME, config[CONF_ID]), - config[CONF_PIN][CONF_NUMBER], - ) - use_interrupt = False + # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. + # Exception: deep_sleep wakeup pins are compatible with interrupts when + # the pin is only shared between this sensor and deep_sleep (count == 2). + if config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): + if not _pin_shared_only_with_deep_sleep(pin_num): + _LOGGER.info( + "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared " + "with other components. The sensor will use polling mode for " + "compatibility with other pin uses.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) + config[CONF_USE_INTERRUPT] = False + else: + _LOGGER.debug( + "GPIO binary_sensor '%s': Pin %s is shared with deep_sleep, " + "keeping interrupts enabled.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) - if use_interrupt: + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + + if config[CONF_USE_INTERRUPT]: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: - # Only generate call when disabling interrupts (default is true) - cg.add(var.set_use_interrupt(use_interrupt)) + cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 39b1a2f7132..1f0154c70b7 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -46,11 +46,6 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { } void GPIOBinarySensor::setup() { - if (this->store_.use_interrupt_ && !this->pin_->is_internal()) { - ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode"); - this->store_.use_interrupt_ = false; - } - if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 1f20b21a0e8..b1d332c1e59 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -283,7 +283,7 @@ async def to_code(config): try: return Image.open(path) except Exception as e: - raise core.EsphomeError(f"Could not load image file {path}: {e}") + raise core.EsphomeError(f"Could not load image file {path}: {e}") from e # make a wide horizontal combined image. images = [load_image(x) for x in config[CONF_COLOR_PALETTE_IMAGES]] diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad957c..70e0937f790 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d30..11acb8a73a4 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385e8..7db50597e6f 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2e5..5b4ed6968c6 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b6421dc43d7..ac0363ca696 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -44,6 +44,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets @@ -451,7 +452,8 @@ async def to_code(configs): df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) - write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): + clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") # handle windows paths in a way that doesn't break the generated C++ lv_conf_h_path = Path(lv_conf_h_file).as_posix() diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a2b..3ec1d247d8e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,17 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec22249..364ada90463 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2242be6c17b..f2923458937 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index cc86101f5ee..ee8bd067004 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -15,7 +15,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from .amoled import CO5300 -from .ili import ILI9488_A +from .ili import ILI9488_A, ST7789V from .jc import AXS15231 DriverChip( @@ -243,3 +243,15 @@ ST7789P.extend( ), ), ) + +ST7789V.extend( + "WAVESHARE-ESP32-C6-LCD-1.47", + width=172, + height=320, + offset_width=34, + invert_colors=True, + data_rate="40MHz", + reset_pin=21, + cs_pin=14, + dc_pin={"number": 15, "ignore_strapping_warning": True}, +) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 40ddb88a795..284339e57f9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; static constexpr std::array MODE_MAP{ - std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, @@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, - climate::CLIMATE_MODE_COOL, - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_DRY, - climate::CLIMATE_MODE_FAN_ONLY, - climate::CLIMATE_MODE_AUTO, - }); + for (const auto &p : MODE_MAP) { + traits.add_supported_mode(p.second); + } - traits.set_supported_fan_modes({ - climate::CLIMATE_FAN_AUTO, - climate::CLIMATE_FAN_QUIET, - climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_HIGH, - }); + for (const auto &p : FAN_MODE_MAP) { + traits.add_supported_fan_mode(p.second); + } traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 1cf28a49557..000de1433cb 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -44,7 +44,7 @@ void PCF85063Component::read_time() { .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index b748f0156aa..50003ca378b 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -44,7 +44,7 @@ void PCF8563Component::read_time() { .year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cfe5..d0488d0c9f7 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -100,7 +100,7 @@ void QMC5883LComponent::update() { // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +165,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 3b704d2551f..0aa6e86d31e 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -81,7 +81,7 @@ void RX8130Component::read_time() { .year = static_cast(bcd2dec(date[6]) + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 1688f9d6a60..97538e13c9b 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -84,7 +84,7 @@ def get_firmware(value): req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") + raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e h = hashlib.new("sha256") h.update(req.content) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 320e96c8979..9b496637da1 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -173,7 +173,7 @@ def _read_audio_file_and_type(file_config): raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type in ("wav"): diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8acacc3b492..8e801876628 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -35,8 +35,9 @@ def validate_acceleration(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected acceleration as floating point number, got {value}") + raise cv.Invalid( + f"Expected acceleration as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Acceleration must be larger than 0 steps/s^2!") @@ -55,8 +56,9 @@ def validate_speed(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected speed as floating point number, got {value}") + raise cv.Invalid( + f"Expected speed as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Speed must be larger than 0 steps/s!") diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index c0bd9d7be97..94aebbbfe35 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -188,7 +188,7 @@ def _expand_substitutions( f"\nRelevant context:\n{err.context_trace_str()}" f"\nSee {'->'.join(str(x) for x in path)}", path, - ) + ) from err else: if isinstance(orig_value, ESPHomeDataBase): value = _restore_data_base(value, orig_value) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 4f7a6f8717a..41b0b69c292 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -109,8 +109,7 @@ def _parse_cron_int(value, special_mapping, message): try: return int(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(message.format(value)) + raise cv.Invalid(message.format(value)) from None def _parse_cron_part(part, min_value, max_value, special_mapping): @@ -134,10 +133,9 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): try: repeat_n = int(repeat) except ValueError: - # pylint: disable=raise-missing-from raise cv.Invalid( f"Repeat for '/' time expression must be an integer, got {repeat}" - ) + ) from None return set(range(offset_n, max_value + 1, repeat_n)) if "-" in part: data = part.split("-") diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index da9adb59a4f..4814d5b1c49 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -347,6 +347,13 @@ uint8_t TM1637Display::print(uint8_t start_pos, const char *str) { } return pos - start_pos; } + +void TM1637Display::set_brightness(float brightness) { + auto intensity = clamp(brightness, 0.f, 1.f) * 7; + this->set_on(intensity > 0); + this->set_intensity(intensity); +} + uint8_t TM1637Display::print(const char *str) { return this->print(0, str); } void TM1637Display::set_buffer(const uint8_t *data, uint8_t length) { diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index c1fbabb21b5..1738d37107a 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -50,6 +50,9 @@ class TM1637Display : public PollingComponent { /// Set raw buffer bytes from data array up to length bytes. void set_buffer(const uint8_t *data, uint8_t length); + /// Set the display brightness. Accepts a value between 0.0 and 1.0; 0 will turn off + /// the display and 1.0 will set it to the maximum brightness. + void set_brightness(float brightness); void set_intensity(uint8_t intensity) { this->intensity_ = intensity; } void set_inverted(bool inverted) { this->inverted_ = inverted; } void set_length(uint8_t length) { this->length_ = length; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 77610412135..481846085c2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -308,6 +308,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -326,6 +327,7 @@ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { return LOG_STR("RESTARTING"); return LOG_STR("UNKNOWN"); } +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO bool WiFiComponent::went_through_explicit_hidden_phase_() const { // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 9da3494329a..51971a12207 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -67,7 +67,7 @@ def _validate_load_certificate(value): contents = read_relative_config_path(value) return wrapped_load_pem_x509_certificate(contents) except ValueError as err: - raise cv.Invalid(f"Invalid certificate: {err}") + raise cv.Invalid(f"Invalid certificate: {err}") from err def validate_certificate(value): @@ -86,9 +86,9 @@ def _validate_load_private_key(key, cert_pw): except ValueError as e: raise cv.Invalid( f"There was an error with the EAP 'password:' provided for 'key' {e}" - ) + ) from e except TypeError as e: - raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") + raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") from e def _check_private_key_cert_match(key, cert): diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 1b543913766..e128b8476dd 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -53,7 +53,7 @@ def _cidr_network(value): try: ipaddress.ip_network(value, strict=False) except ValueError as err: - raise cv.Invalid(f"Invalid network in CIDR notation: {err}") + raise cv.Invalid(f"Invalid network in CIDR notation: {err}") from err return value @@ -137,7 +137,7 @@ async def to_code(config): # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.4") + cg.add_library("droscy/esp_wireguard", "0.4.5") await cg.register_component(var, config) diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 211941e9846..658137d1a22 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,28 +1,35 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, CONF_ID, CONF_NAME, Framework -import esphome.final_validate as fv +from esphome.const import CONF_ID, Framework +from esphome.core import CORE zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) +CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request" +CONF_ACCEPT = "accept" + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BLEServer), + cv.Optional( + CONF_ON_NUMERIC_COMPARISON_REQUEST + ): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), cv.only_with_framework(Framework.ZEPHYR), ) - -def _final_validate(_): - full_config = fv.full_config.get() - zephyr_add_prj_conf("BT_DEVICE_NAME", full_config[CONF_ESPHOME][CONF_NAME]) - - -FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_NUMERIC_COMPARISON_REQUEST, + "add_passkey_callback", + [(cg.uint32, "passkey")], + ), +) async def to_code(config): @@ -30,5 +37,39 @@ async def to_code(config): zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) - # zephyr_add_prj_conf("BT_LL_SW_SPLIT", True) + zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) await cg.register_component(var, config) + if config.get(CONF_ON_NUMERIC_COMPARISON_REQUEST): + zephyr_add_prj_conf("BT_SMP", True) + zephyr_add_prj_conf("BT_SETTINGS", True) + zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) + zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( + "BLENumericComparisonReplyAction", automation.Action +) + +BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEServer), + cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "ble_server.numeric_comparison_reply", + BLENumericComparisonReplyAction, + BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, + synchronous=True, +) +async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) + cg.add(var.set_accept(templ)) + + return var diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index 9f7e606a90c..15993abcce4 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -3,32 +3,34 @@ #include "esphome/core/defines.h" #include "esphome/core/log.h" #include -#include +#include namespace esphome::zephyr_ble_server { static const char *const TAG = "zephyr_ble_server"; -static struct k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #define DEVICE_NAME CONFIG_BT_DEVICE_NAME #define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) -static const struct bt_data AD[] = { +static const bt_data AD[] = { BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), }; -static const struct bt_data SD[] = { +static const bt_data SD[] = { #ifdef USE_OTA BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c, 0xb7, 0x1d, 0x1d, 0xdc, 0x53, 0x8d), #endif }; -const struct bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; +const bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; -static void advertise(struct k_work *work) { +static void advertise(k_work *work) { int rc = bt_le_adv_stop(); if (rc) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); @@ -42,57 +44,276 @@ static void advertise(struct k_work *work) { ESP_LOGI(TAG, "Advertising successfully started"); } -static void connected(struct bt_conn *conn, uint8_t err) { +void BLEServer::connected(bt_conn *conn, uint8_t err) { + char addr[BT_ADDR_LE_STR_LEN]; + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); if (err) { - ESP_LOGE(TAG, "Connection failed (err 0x%02x)", err); - } else { - ESP_LOGI(TAG, "Connected"); + ESP_LOGE(TAG, "Failed to connect to %s (%u)", addr, err); + return; } + ESP_LOGI(TAG, "Connected %s", addr); +#ifdef CONFIG_BT_SMP + if (bt_conn_set_security(conn, BT_SECURITY_L4)) { + ESP_LOGE(TAG, "Failed to set security"); + } +#endif + conn = bt_conn_ref(conn); + global_ble_server->defer([conn]() { global_ble_server->conn_ = conn; }); } -static void disconnected(struct bt_conn *conn, uint8_t reason) { - ESP_LOGI(TAG, "Disconnected (reason 0x%02x)", reason); +void BLEServer::disconnected(bt_conn *conn, uint8_t reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Disconnected from %s (reason 0x%02x)", addr, reason); + global_ble_server->defer([]() { + if (global_ble_server->conn_) { + bt_conn_unref(global_ble_server->conn_); + global_ble_server->conn_ = nullptr; + } + }); k_work_submit(&advertise_work); } -static void bt_ready(int err) { - if (err != 0) { - ESP_LOGE(TAG, "Bluetooth failed to initialise: %d", err); +#ifdef CONFIG_BT_SMP +static void identity_resolved(bt_conn *conn, const bt_addr_le_t *rpa, const bt_addr_le_t *identity) { + char addr_identity[BT_ADDR_LE_STR_LEN]; + char addr_rpa[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(identity, addr_identity, sizeof(addr_identity)); + bt_addr_le_to_str(rpa, addr_rpa, sizeof(addr_rpa)); + + ESP_LOGD(TAG, "Identity resolved %s -> %s", addr_rpa, addr_identity); +} + +static void security_changed(bt_conn *conn, bt_security_t level, bt_security_err err) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + if (!err) { + ESP_LOGD(TAG, "Security changed: %s level %u", addr, level); } else { - k_work_submit(&advertise_work); + ESP_LOGE(TAG, "Security failed: %s level %u err %d", addr, level, err); } } -BT_CONN_CB_DEFINE(conn_callbacks) = { - .connected = connected, - .disconnected = disconnected, -}; +static void pairing_complete(bt_conn *conn, bool bonded) { + char addr[BT_ADDR_LE_STR_LEN]; -void BLEServer::setup() { - k_work_init(&advertise_work, advertise); - resume_(); + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGD(TAG, "Pairing completed: %s, bonded: %d", addr, bonded); } -void BLEServer::loop() { - if (this->suspended_) { - resume_(); - this->suspended_ = false; - } +static void pairing_failed(bt_conn *conn, bt_security_err reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGE(TAG, "Pairing failed conn: %s, reason %d", addr, reason); + + bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); } -void BLEServer::resume_() { - int rc = bt_enable(bt_ready); - if (rc != 0) { - ESP_LOGE(TAG, "Bluetooth enable failed: %d", rc); +static void bond_deleted(uint8_t id, const bt_addr_le_t *peer) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(peer, addr, sizeof(addr)); + ESP_LOGD(TAG, "Bond deleted for %s, id %u", addr, id); +} + +static void auth_passkey_display(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Passkey for %s: %s", addr, passkey_str); +} + +static void conn_addr_str(bt_conn *conn, char *addr, size_t len) { + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + addr[0] = '\0'; return; } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, len); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + addr[0] = '\0'; + break; + } } -void BLEServer::on_shutdown() { - struct k_work_sync sync; - k_work_cancel_sync(&advertise_work, &sync); - bt_disable(); - this->suspended_ = true; +static void auth_cancel(bt_conn *conn) { + char addr[BT_ADDR_LE_STR_LEN]; + + conn_addr_str(conn, addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing cancelled: %s", addr); +} + +void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str); + global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); }); +} + +static void auth_pairing_confirm(bt_conn *conn) { + /* Automatically confirm pairing request from the device side. */ + auto err = bt_conn_auth_pairing_confirm(conn); + if (err) { + ESP_LOGE(TAG, "Can't confirm pairing (err: %d)", err); + return; + } + + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing confirmed: %s", addr); +} + +#endif + +void BLEServer::setup() { + global_ble_server = this; + int err = 0; + k_work_init(&advertise_work, advertise); + + static bt_conn_cb conn_callbacks = { + .connected = connected, + .disconnected = disconnected, +#ifdef CONFIG_BT_SMP + .identity_resolved = identity_resolved, + .security_changed = security_changed, +#endif + }; + + bt_conn_cb_register(&conn_callbacks); +#ifdef CONFIG_BT_SMP + static struct bt_conn_auth_info_cb conn_auth_info_callbacks = { + .pairing_complete = pairing_complete, .pairing_failed = pairing_failed, .bond_deleted = bond_deleted}; + err = bt_conn_auth_info_cb_register(&conn_auth_info_callbacks); + if (err) { + ESP_LOGE(TAG, "Failed to register authorization info callbacks."); + } + static struct bt_conn_auth_cb auth_cb = { + .passkey_display = auth_passkey_display, + .passkey_confirm = auth_passkey_confirm, + .cancel = auth_cancel, + .pairing_confirm = auth_pairing_confirm, + }; + err = bt_conn_auth_cb_register(&auth_cb); + if (err) { + ESP_LOGE(TAG, "Failed to set auth handlers (%d)", err); + } +#endif + // callback cannot be used to start scanning due to race conditions with BT_SETTINGS + err = bt_enable(nullptr); + if (err) { + ESP_LOGE(TAG, "Bluetooth enable failed: %d", err); + return; + } +#ifdef CONFIG_BT_SETTINGS + err = settings_load(); + if (err) { + ESP_LOGE(TAG, "Cannot load settings, err: %d", err); + } +#endif + k_work_submit(&advertise_work); +} + +#ifdef ESPHOME_LOG_HAS_DEBUG +static const char *role_str(uint8_t role) { + switch (role) { + case BT_CONN_ROLE_CENTRAL: + return "Central"; + case BT_CONN_ROLE_PERIPHERAL: + return "Peripheral"; + } + + return "Unknown"; +} + +static void connection_info(bt_conn *conn, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + ESP_LOGE(TAG, "Unable to get info: conn %p", conn); + return; + } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, sizeof(addr)); + ESP_LOGD(TAG, " %u [LE][%s] %s: Interval %u latency %u timeout %u security L%u", info.id, role_str(info.role), + addr, info.le.interval, info.le.latency, info.le.timeout, info.security.level); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + break; + } +} +#ifdef CONFIG_BT_BONDABLE +static void bond_info(const struct bt_bond_info *info, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(&info->addr, addr, sizeof(addr)); + ESP_LOGD(TAG, " Bond remote identity: %s", addr); +} +#endif +#endif + +void BLEServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ble server:\n" + " connected: %s\n" + " name: %s\n" + " appearance: %u\n" + " ready: %s\n" +#ifdef CONFIG_BT_SMP + " security manager: YES", +#else + " security manager: NO", +#endif + YESNO(this->conn_), bt_get_name(), bt_get_appearance(), YESNO(bt_is_ready())); + +#ifdef ESPHOME_LOG_HAS_DEBUG + bt_conn_foreach(BT_CONN_TYPE_ALL, connection_info, nullptr); +#ifdef CONFIG_BT_BONDABLE + bt_foreach_bond(BT_ID_DEFAULT, bond_info, nullptr); +#endif +#endif +} + +void BLEServer::numeric_comparison_reply(bool accept) { + if (this->conn_ == nullptr) { + ESP_LOGE(TAG, "Not connected"); + return; + } + ESP_LOGD(TAG, "Numeric comparison %s", accept ? "accepted" : "rejected"); + if (accept) { + bt_conn_auth_passkey_confirm(this->conn_); + } else { + bt_conn_auth_cancel(this->conn_); + } } } // namespace esphome::zephyr_ble_server diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 1b32e9b58ca..bf69c52b126 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -1,18 +1,36 @@ #pragma once #ifdef USE_ZEPHYR #include "esphome/core/component.h" +#include +#include "esphome/core/automation.h" namespace esphome::zephyr_ble_server { class BLEServer : public Component { public: void setup() override; - void loop() override; - void on_shutdown() override; + void dump_config() override; + template void add_passkey_callback(F &&callback) { this->passkey_cb_.add(std::forward(callback)); } + void numeric_comparison_reply(bool accept); protected: - void resume_(); - bool suspended_ = false; + static void connected(bt_conn *conn, uint8_t err); + static void disconnected(bt_conn *conn, uint8_t reason); + static void auth_passkey_confirm(bt_conn *conn, unsigned int passkey); + bt_conn *conn_{}; + CallbackManager passkey_cb_; +}; + +template class BLENumericComparisonReplyAction : public Action { + public: + explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(bool, accept) + + void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } + + protected: + BLEServer *parent_; }; } // namespace esphome::zephyr_ble_server diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 31cfb41a6d9..fbafc5cb07b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -544,8 +544,9 @@ def int_(value): try: return int(value, base) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"Expected integer, but cannot parse {value} as an integer") + raise Invalid( + f"Expected integer, but cannot parse {value} as an integer" + ) from None def int_range(min=None, max=None, min_included=True, max_included=True): @@ -844,8 +845,7 @@ def time_period_str_colon(value): try: parsed = [int(x) for x in value.split(":")] except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(TIME_PERIOD_ERROR.format(value)) + raise Invalid(TIME_PERIOD_ERROR.format(value)) from None if len(parsed) == 2: hour, minute = parsed @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) @@ -1047,8 +1066,7 @@ def date_time(date: bool, time: bool): try: date_obj = datetime.strptime(value, format) except ValueError as err: - # pylint: disable=raise-missing-from - raise Invalid(f"Invalid {exc_message}: {err}") + raise Invalid(f"Invalid {exc_message}: {err}") from err return_value = {} if date: @@ -1078,8 +1096,9 @@ def mac_address(value): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("MAC Address parts must be hexadecimal values from 00 to FF") + raise Invalid( + "MAC Address parts must be hexadecimal values from 00 to FF" + ) from None return core.MACAddress(*parts_int) @@ -1096,8 +1115,7 @@ def bind_key(value, *, name="Bind key"): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"{name} must be hex values from 00 to FF") + raise Invalid(f"{name} must be hex values from 00 to FF") from None return "".join(f"{part:02X}" for part in parts_int) @@ -1425,8 +1443,7 @@ def mqtt_qos(value): try: value = int(value) except (TypeError, ValueError): - # pylint: disable=raise-missing-from - raise Invalid(f"MQTT Quality of Service must be integer, got {value}") + raise Invalid(f"MQTT Quality of Service must be integer, got {value}") from None return one_of(0, 1, 2)(value) @@ -1518,8 +1535,7 @@ def _parse_percentage(value: object) -> float: else: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("invalid number") + raise Invalid("invalid number") from None try: if not has_percent_sign and (value > 1 or value < -1): raise Invalid( @@ -1527,9 +1543,7 @@ def _parse_percentage(value: object) -> float: "outside -1.0 to 1.0. Please put a percent sign after the number!" ) except TypeError: - raise Invalid( # pylint: disable=raise-missing-from - "Expected percentage or float" - ) + raise Invalid("Expected percentage or float") from None return float(value) @@ -1702,8 +1716,7 @@ def dimensions(value): try: width, height = int(value[0]), int(value[1]) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("Width and height dimensions must be integers") + raise Invalid("Width and height dimensions must be integers") from None if width <= 0 or height <= 0: raise Invalid("Width and height must at least be 1") return [width, height] diff --git a/esphome/core/application.h b/esphome/core/application.h index 22299c1033e..2f723daec18 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -334,9 +334,12 @@ class Application { /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } -#ifdef USE_ESP32 - /// Wake from ISR (ESP32 only). +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } +#elif defined(USE_ESP8266) + /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. + static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eb270bfee26..468ea3b382c 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,6 +62,18 @@ template class TemplatableFn { !std::convertible_to, T> || !std::default_initializable) = delete; + // Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix. + // TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a + // stateless lambda by codegen. External components hitting this error should use + // `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter. + template TemplatableFn(V) requires(!std::invocable) && (!std::convertible_to) { + static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE " + "field. The wrapper was always required; it worked by accident because the old " + "TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See " + "https://developers.esphome.io/blog/2026/04/09/" + "templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/"); + } + bool has_value() const { return this->f_ != nullptr; } T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d39739..17f937d10d9 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3e75a680643..7e6ad19ac70 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; diff --git a/esphome/core/time.h b/esphome/core/time.h index ed474320381..0b67b7b3fcd 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -76,8 +76,12 @@ struct ESPTime { /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); - /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) - bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } + /// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range). + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const { + return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year); + } /// Check if time fields are in range. /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5733ee65f6c..77a38d429e5 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -77,6 +77,9 @@ void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + namespace internal { inline void wakeable_delay(uint32_t ms) { if (ms == 0) { diff --git a/esphome/external_files.py b/esphome/external_files.py index 18b68fba088..55711e1b790 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -107,7 +107,7 @@ def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: e, ) return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") + raise cv.Invalid(f"Could not download from {url}: {e}") from e path.parent.mkdir(parents=True, exist_ok=True) data = req.content diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 0703c54a7a8..904963ba4e8 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -39,8 +39,7 @@ class _Schema(vol.Schema): try: res = extra(res) except vol.Invalid as err: - # pylint: disable=raise-missing-from - raise ensure_multiple_invalid(err) + raise ensure_multiple_invalid(err) from err return res def _compile_mapping(self, schema, invalid_msg=None): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 59d851c02e7..e15adff935c 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -338,10 +338,9 @@ class ESPHomeLoaderMixin: try: hash(key) except TypeError: - # pylint: disable=raise-missing-from raise yaml.constructor.ConstructorError( f'Invalid key "{key}" (not hashable)', key_node.start_mark - ) + ) from None key = make_data_base(str(key)) key.from_node(key_node) diff --git a/platformio.ini b/platformio.ini index 3897db83e13..e2c7e2b097d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = @@ -154,7 +154,7 @@ lib_deps = DNSServer ; captive_portal (Arduino built-in) makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = @@ -176,7 +176,7 @@ platform_packages = framework = espidf lib_deps = ${common:idf.lib_deps} - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = @@ -221,7 +221,7 @@ lib_compat_mode = soft lib_deps = bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 00000000000..8c45b47752c --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) diff --git a/tests/components/time/is_valid.cpp b/tests/components/time/is_valid.cpp new file mode 100644 index 00000000000..9148c0e8d6b --- /dev/null +++ b/tests/components/time/is_valid.cpp @@ -0,0 +1,72 @@ +// Regression tests for ESPTime::is_valid() optional checks. +// +// The RTC components (ds1307, bm8563, pcf85063, pcf8563, rx8130) read date/time +// fields from hardware but do not populate day_of_year. They call +// recalc_timestamp_utc(false) -- which skips day_of_year -- and then is_valid(). +// These tests ensure the is_valid() overload can skip day_of_year validation so +// RTCs don't log "Invalid RTC time, not syncing to system clock." for valid times. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +// Build an ESPTime that mirrors what the RTC components construct: all fields +// populated from hardware except day_of_year (left zero-initialized). +static ESPTime make_rtc_like_time() { + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_week = 4; // thursday + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + // day_of_year intentionally left at 0 -- RTCs don't compute it. + return t; +} + +TEST(ESPTimeIsValid, DefaultRejectsZeroDayOfYear) { + // Default is_valid() checks day_of_year; zero-init is out of range. + ESPTime t = make_rtc_like_time(); + EXPECT_FALSE(t.is_valid()); +} + +TEST(ESPTimeIsValid, SkipDayOfYearAcceptsRTCLikeTime) { + // RTC code path: skip day_of_year validation. + ESPTime t = make_rtc_like_time(); + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsOutOfRangeFields) { + ESPTime t = make_rtc_like_time(); + t.hour = 25; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsYearBefore2019) { + ESPTime t = make_rtc_like_time(); + t.year = 2000; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipBothDayChecksAcceptsGPSLikeTime) { + // GPS path (gps_time.cpp) populates neither day_of_week nor day_of_year. + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false)); + EXPECT_FALSE(t.is_valid()); // default still rejects +} + +TEST(ESPTimeIsValid, FullyPopulatedAcceptsWithDefaults) { + ESPTime t = make_rtc_like_time(); + t.day_of_year = 105; + EXPECT_TRUE(t.is_valid()); +} + +} // namespace esphome::testing diff --git a/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..2b440102dbc --- /dev/null +++ b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml @@ -0,0 +1,10 @@ +zephyr_ble_server: + on_numeric_comparison_request: + then: + - logger.log: + format: "Compare this passkey with the one on your BLE device: %06d" + args: [passkey] + - ble_server.numeric_comparison_reply: + accept: True + - ble_server.numeric_comparison_reply: + accept: !lambda "return true;" diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 00000000000..13be55d6176 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 00000000000..f71c0f7281f --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d867f..247f5cc8bb1 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 00000000000..d7e7b3cd450 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd1a..89bf1a33b3e 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc89..f038272d8b0 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN