diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index d8abc8bafbc..bf1bcbfa050 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -43,6 +43,7 @@ _READELF_SECTION_PATTERN = re.compile( # Component category prefixes _COMPONENT_PREFIX_ESPHOME = "[esphome]" _COMPONENT_PREFIX_EXTERNAL = "[external]" +_COMPONENT_PREFIX_LIB = "[lib]" _COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core" _COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api" @@ -56,6 +57,16 @@ SymbolInfoType = tuple[str, int, str] # RAM sections - symbols in these sections consume RAM RAM_SECTIONS = frozenset([".data", ".bss"]) +# nm symbol types for global/weak defined symbols (used for library symbol mapping) +# Only global (uppercase) and weak symbols are safe to use - local symbols (lowercase) +# can have name collisions across compilation units +_NM_DEFINED_GLOBAL_TYPES = frozenset({"T", "D", "B", "R", "W", "V"}) + +# Pattern matching compiler-generated local names that can collide across compilation +# units (e.g., packet$19, buf$20, flag$5261). These are unsafe for name-based lookup. +# Does NOT match mangled C++ names with optimization suffixes (e.g., func$isra$0). +_COMPILER_LOCAL_PATTERN = re.compile(r"^[a-zA-Z_]\w*\$\d+$") + @dataclass class MemorySection: @@ -179,11 +190,19 @@ class MemoryAnalyzer: self._sdk_symbols: list[SDKSymbol] = [] # CSWTCH symbols: list of (name, size, source_file, component) self._cswtch_symbols: list[tuple[str, int, str, str]] = [] + # Library symbol mapping: symbol_name -> library_name + self._lib_symbol_map: dict[str, str] = {} + # Library dir to name mapping: "lib641" -> "espsoftwareserial", + # "espressif__mdns" -> "mdns" + self._lib_hash_to_name: dict[str, str] = {} + # Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns" + self._heuristic_to_lib: dict[str, str] = {} def analyze(self) -> dict[str, ComponentMemory]: """Analyze the ELF file and return component memory usage.""" self._parse_sections() self._parse_symbols() + self._scan_libraries() self._categorize_symbols() self._analyze_cswtch_symbols() self._analyze_sdk_libraries() @@ -328,15 +347,19 @@ class MemoryAnalyzer: # If no component match found, it's core return _COMPONENT_CORE + # Check library symbol map (more accurate than heuristic patterns) + if lib_name := self._lib_symbol_map.get(symbol_name): + return f"{_COMPONENT_PREFIX_LIB}{lib_name}" + # Check against symbol patterns for component, patterns in SYMBOL_PATTERNS.items(): if any(pattern in symbol_name for pattern in patterns): - return component + return self._heuristic_to_lib.get(component, component) # Check against demangled patterns for component, patterns in DEMANGLED_PATTERNS.items(): if any(pattern in demangled for pattern in patterns): - return component + return self._heuristic_to_lib.get(component, component) # Special cases that need more complex logic @@ -384,6 +407,327 @@ class MemoryAnalyzer: return "Other Core" + def _discover_pio_libraries( + self, + libraries: dict[str, list[Path]], + hash_to_name: dict[str, str], + ) -> None: + """Discover PlatformIO third-party libraries from the build directory. + + Scans ``lib/`` directories under ``.pioenvs//`` to find + library names and their ``.a`` archive or ``.o`` file paths. + + Args: + libraries: Dict to populate with library name -> file path list mappings. + Prefers ``.a`` archives when available, falls back to ``.o`` files + (e.g., pioarduino ESP32 Arduino builds only produce ``.o`` files). + hash_to_name: Dict to populate with dir name -> library name mappings + for CSWTCH attribution (e.g., ``lib641`` -> ``espsoftwareserial``). + """ + build_dir = self.elf_path.parent + + for entry in build_dir.iterdir(): + if not entry.is_dir() or not entry.name.startswith("lib"): + continue + # Validate that the suffix after "lib" is a hex hash + hex_part = entry.name[3:] + if not hex_part: + continue + try: + int(hex_part, 16) + except ValueError: + continue + + # Each lib/ directory contains a subdirectory named after the library + for lib_subdir in entry.iterdir(): + if not lib_subdir.is_dir(): + continue + lib_name = lib_subdir.name.lower() + + # Prefer .a archive (lib.a), fall back to .o files + # e.g., lib72a/ESPAsyncTCP/... has lib72a/libESPAsyncTCP.a + archive = entry / f"lib{lib_subdir.name}.a" + if archive.exists(): + file_paths = [archive] + elif archives := list(entry.glob("*.a")): + # Case-insensitive fallback + file_paths = [archives[0]] + else: + # No .a archive (e.g., pioarduino CMake builds) - use .o files + file_paths = sorted(lib_subdir.rglob("*.o")) + + if file_paths: + libraries[lib_name] = file_paths + hash_to_name[entry.name] = lib_name + _LOGGER.debug( + "Discovered PlatformIO library: %s -> %s", + lib_subdir.name, + file_paths[0], + ) + + def _discover_idf_managed_components( + self, + libraries: dict[str, list[Path]], + hash_to_name: dict[str, str], + ) -> None: + """Discover ESP-IDF managed component libraries from the build directory. + + ESP-IDF managed components (from the IDF component registry) use a + ``__`` naming convention. Source files live under + ``managed_components/__/`` and the compiled archives are at + ``esp-idf/__/lib__.a``. + + Args: + libraries: Dict to populate with library name -> file path list mappings. + hash_to_name: Dict to populate with dir name -> library name mappings + for CSWTCH attribution (e.g., ``espressif__mdns`` -> ``mdns``). + """ + build_dir = self.elf_path.parent + + managed_dir = build_dir / "managed_components" + if not managed_dir.is_dir(): + return + + espidf_dir = build_dir / "esp-idf" + + for entry in managed_dir.iterdir(): + if not entry.is_dir() or "__" not in entry.name: + continue + + # Extract the short name: espressif__mdns -> mdns + full_name = entry.name # e.g., espressif__mdns + short_name = full_name.split("__", 1)[1].lower() + + # Find the .a archive under esp-idf/__/ + archive = espidf_dir / full_name / f"lib{full_name}.a" + if archive.exists(): + libraries[short_name] = [archive] + hash_to_name[full_name] = short_name + _LOGGER.debug( + "Discovered IDF managed component: %s -> %s", + short_name, + archive, + ) + + def _build_library_symbol_map( + self, libraries: dict[str, list[Path]] + ) -> dict[str, str]: + """Build a symbol-to-library mapping from library archives or object files. + + Runs ``nm --defined-only`` on each ``.a`` or ``.o`` file to collect + global and weak defined symbols. + + Args: + libraries: Dictionary mapping library name to list of file paths + (``.a`` archives or ``.o`` object files). + + Returns: + Dictionary mapping symbol name to library name. + """ + symbol_map: dict[str, str] = {} + + if not self.nm_path: + return symbol_map + + for lib_name, file_paths in libraries.items(): + result = run_tool( + [self.nm_path, "--defined-only", *(str(p) for p in file_paths)], + timeout=10, + ) + if result is None or result.returncode != 0: + continue + + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 3: + continue + + sym_type = parts[-2] + sym_name = parts[-1] + + # Include global defined symbols (uppercase) and weak symbols (W/V) + if sym_type in _NM_DEFINED_GLOBAL_TYPES: + symbol_map[sym_name] = lib_name + + return symbol_map + + @staticmethod + def _build_heuristic_to_lib_mapping( + library_names: set[str], + ) -> dict[str, str]: + """Build mapping from heuristic pattern categories to discovered libraries. + + Heuristic categories like ``mdns_lib``, ``web_server_lib``, ``async_tcp`` + exist as approximations for library attribution. When we discover the + actual library, symbols matching those heuristics should be redirected + to the ``[lib]`` category instead. + + The mapping is built by checking if the normalized category name + (stripped of ``_lib`` suffix and underscores) appears as a substring + of any discovered library name. + + Examples:: + + mdns_lib -> mdns -> in "mdns" or "esp8266mdns" -> [lib]mdns + web_server_lib -> webserver -> in "espasyncwebserver" -> [lib]espasyncwebserver + async_tcp -> asynctcp -> in "espasynctcp" -> [lib]espasynctcp + + Args: + library_names: Set of discovered library names (lowercase). + + Returns: + Dictionary mapping heuristic category to ``[lib]`` string. + """ + mapping: dict[str, str] = {} + all_categories = set(SYMBOL_PATTERNS) | set(DEMANGLED_PATTERNS) + + for category in all_categories: + base = category.removesuffix("_lib").replace("_", "") + # Collect all libraries whose name contains the base string + candidates = [lib_name for lib_name in library_names if base in lib_name] + if not candidates: + continue + + # Choose a deterministic "best" match: + # 1. Prefer exact name matches over substring matches. + # 2. Among non-exact matches, prefer the shortest library name. + # 3. Break remaining ties lexicographically. + best_lib = min( + candidates, + key=lambda lib_name, _base=base: ( + lib_name != _base, + len(lib_name), + lib_name, + ), + ) + mapping[category] = f"{_COMPONENT_PREFIX_LIB}{best_lib}" + + if mapping: + _LOGGER.debug( + "Heuristic-to-library redirects: %s", + ", ".join(f"{k} -> {v}" for k, v in sorted(mapping.items())), + ) + + return mapping + + def _parse_map_file(self) -> dict[str, str] | None: + """Parse linker map file to build authoritative symbol-to-library mapping. + + The linker map file contains the definitive source attribution for every + symbol, including local/static ones that ``nm`` cannot safely export. + + Map file format (GNU ld):: + + .text._mdns_service_task + 0x400e9fdc 0x65c .pioenvs/env/esp-idf/espressif__mdns/libespressif__mdns.a(mdns.c.o) + + Each section entry has a ``.section.symbol_name`` line followed by an + indented line with address, size, and source path. + + Returns: + Symbol-to-library dict, or ``None`` if no usable map file exists. + """ + map_path = self.elf_path.with_suffix(".map") + if not map_path.exists() or map_path.stat().st_size < 10000: + return None + + _LOGGER.info("Parsing linker map file: %s", map_path.name) + + try: + map_text = map_path.read_text(encoding="utf-8", errors="replace") + except OSError as err: + _LOGGER.warning("Failed to read map file: %s", err) + return None + + symbol_map: dict[str, str] = {} + current_symbol: str | None = None + section_prefixes = (".text.", ".rodata.", ".data.", ".bss.", ".literal.") + + for line in map_text.splitlines(): + # Match section.symbol line: " .text.symbol_name" + # Single space indent, starts with dot + if len(line) > 2 and line[0] == " " and line[1] == ".": + stripped = line.strip() + for prefix in section_prefixes: + if stripped.startswith(prefix): + current_symbol = stripped[len(prefix) :] + break + else: + current_symbol = None + continue + + # Match source attribution line: " 0xADDR 0xSIZE source_path" + if current_symbol is None: + continue + + fields = line.split() + # Skip compiler-generated local names (e.g., packet$19, buf$20) + # that can collide across compilation units + if ( + len(fields) >= 3 + and fields[0].startswith("0x") + and fields[1].startswith("0x") + and not _COMPILER_LOCAL_PATTERN.match(current_symbol) + ): + source_path = fields[2] + # Check if source path contains a known library directory + for dir_key, lib_name in self._lib_hash_to_name.items(): + if dir_key in source_path: + symbol_map[current_symbol] = lib_name + break + + current_symbol = None + + return symbol_map or None + + def _scan_libraries(self) -> None: + """Discover third-party libraries and build symbol mapping. + + Scans both PlatformIO ``lib/`` directories (Arduino builds) and + ESP-IDF ``managed_components/`` (IDF builds) to find library archives. + + Uses the linker map file for authoritative symbol attribution when + available, falling back to ``nm`` scanning with heuristic redirects. + """ + libraries: dict[str, list[Path]] = {} + self._discover_pio_libraries(libraries, self._lib_hash_to_name) + self._discover_idf_managed_components(libraries, self._lib_hash_to_name) + + if not libraries: + _LOGGER.debug("No third-party libraries found") + return + + _LOGGER.info( + "Scanning %d libraries: %s", + len(libraries), + ", ".join(sorted(libraries)), + ) + + # Heuristic redirect catches local symbols (e.g., mdns_task_buffer$14) + # that can't be safely added to the symbol map due to name collisions + self._heuristic_to_lib = self._build_heuristic_to_lib_mapping( + set(libraries.keys()) + ) + + # Try linker map file first (authoritative, includes local symbols) + map_symbols = self._parse_map_file() + if map_symbols is not None: + self._lib_symbol_map = map_symbols + _LOGGER.info( + "Built library symbol map from linker map: %d symbols", + len(self._lib_symbol_map), + ) + return + + # Fall back to nm scanning (global symbols only) + self._lib_symbol_map = self._build_library_symbol_map(libraries) + + _LOGGER.info( + "Built library symbol map from nm: %d symbols from %d libraries", + len(self._lib_symbol_map), + len(libraries), + ) + def _find_object_files_dir(self) -> Path | None: """Find the directory containing object files for this build. @@ -559,9 +903,21 @@ class MemoryAnalyzer: if "esphome" in parts and "components" not in parts: return _COMPONENT_CORE - # Framework/library files - return the first path component - # e.g., lib65b/ESPAsyncTCP/... -> lib65b - # FrameworkArduino/... -> FrameworkArduino + # Framework/library files - check for PlatformIO library hash dirs + # e.g., lib65b/ESPAsyncTCP/... -> [lib]espasynctcp + if parts and parts[0] in self._lib_hash_to_name: + return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[0]]}" + + # ESP-IDF managed components: managed_components/espressif__mdns/... -> [lib]mdns + if ( + len(parts) >= 2 + and parts[0] == "managed_components" + and parts[1] in self._lib_hash_to_name + ): + return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[1]]}" + + # Other framework/library files - return the first path component + # e.g., FrameworkArduino/... -> FrameworkArduino return parts[0] if parts else source_file def _analyze_cswtch_symbols(self) -> None: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index bb0eb7723ee..dbc19c6b89d 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -14,6 +14,7 @@ from . import ( _COMPONENT_CORE, _COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL, + _COMPONENT_PREFIX_LIB, RAM_SECTIONS, MemoryAnalyzer, ) @@ -407,6 +408,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): for name, mem in components if name.startswith(_COMPONENT_PREFIX_EXTERNAL) ] + library_components = [ + (name, mem) + for name, mem in components + if name.startswith(_COMPONENT_PREFIX_LIB) + ] top_esphome_components = sorted( esphome_components, key=lambda x: x[1].flash_total, reverse=True @@ -417,6 +423,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): external_components, key=lambda x: x[1].flash_total, reverse=True ) + # Include all library components + top_library_components = sorted( + library_components, key=lambda x: x[1].flash_total, reverse=True + ) + # Check if API component exists and ensure it's included api_component = None for name, mem in components: @@ -435,10 +446,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): if name in system_components_to_include ] - # Combine all components to analyze: top ESPHome + all external + API if not already included + system components + # Combine all components to analyze: top ESPHome + all external + libraries + API if not already included + system components components_to_analyze = ( list(top_esphome_components) + list(top_external_components) + + list(top_library_components) + system_components ) if api_component and api_component not in components_to_analyze: diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 92978f765f0..41ea0043f94 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -112,8 +112,12 @@ class ProtoVarInt { uint64_t result = buffer[0] & 0x7F; uint8_t bitpos = 7; + // A 64-bit varint is at most 10 bytes (ceil(64/7)). Reject overlong encodings + // to avoid undefined behavior from shifting uint64_t by >= 64 bits. + uint32_t max_len = std::min(len, uint32_t(10)); + // Start from the second byte since we've already processed the first - for (uint32_t i = 1; i < len; i++) { + for (uint32_t i = 1; i < max_len; i++) { uint8_t val = buffer[i]; result |= uint64_t(val & 0x7F) << uint64_t(bitpos); bitpos += 7; diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index f11e7f4fe3a..41878579014 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -55,7 +55,6 @@ void E131Component::setup() { } void E131Component::loop() { - std::vector payload; E131Packet packet; int universe = 0; uint8_t buf[1460]; @@ -64,11 +63,9 @@ void E131Component::loop() { if (len == -1) { return; } - payload.resize(len); - memmove(&payload[0], buf, len); - if (!this->packet_(payload, universe, packet)) { - ESP_LOGV(TAG, "Invalid packet received of size %zu.", payload.size()); + if (!this->packet_(buf, (size_t) len, universe, packet)) { + ESP_LOGV(TAG, "Invalid packet received of size %zd.", len); return; } diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 831138a545f..d4b272eae2e 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -38,7 +38,7 @@ class E131Component : public esphome::Component { void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; } protected: - bool packet_(const std::vector &data, int &universe, E131Packet &packet); + bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); bool process_(int universe, const E131Packet &packet); bool join_igmp_groups_(); void join_(int universe); diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index e663a3d0fc7..ed081e57581 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -116,11 +116,11 @@ void E131Component::leave_(int universe) { ESP_LOGD(TAG, "Left %d universe for E1.31.", universe); } -bool E131Component::packet_(const std::vector &data, int &universe, E131Packet &packet) { - if (data.size() < E131_MIN_PACKET_SIZE) +bool E131Component::packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet) { + if (len < E131_MIN_PACKET_SIZE) return false; - auto *sbuff = reinterpret_cast(&data[0]); + auto *sbuff = reinterpret_cast(data); if (memcmp(sbuff->acn_id, ACN_ID, sizeof(sbuff->acn_id)) != 0) return false; diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index a7d5f7e3d51..c8e2e879d46 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -27,6 +27,11 @@ static const char *const TAG = "esp32_hosted.update"; // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +// Interval/timeout IDs (uint32_t to avoid string comparison) +constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; +#endif + // Compile-time version string from esp_hosted_host_fw_ver.h macros #define STRINGIFY_(x) #x #define STRINGIFY(x) STRINGIFY_(x) @@ -127,15 +132,18 @@ void Esp32HostedUpdate::setup() { this->status_clear_error(); this->publish_state(); #else - // HTTP mode: retry initial check every 10s until network is ready (max 6 attempts) + // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks if (this->get_update_interval() > 60000) { - this->set_retry("initial_check", 10000, 6, [this](uint8_t) { - if (!network::is_connected()) { - return RetryResult::RETRY; + this->initial_check_remaining_ = 6; + this->set_interval(INITIAL_CHECK_INTERVAL_ID, 10000, [this]() { + bool connected = network::is_connected(); + if (--this->initial_check_remaining_ == 0 || connected) { + this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); + if (connected) { + this->check(); + } } - this->check(); - return RetryResult::DONE; }); } #endif diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 7c9645c12a3..005e6a6f211 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -44,6 +44,7 @@ class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { // HTTP mode helpers bool fetch_manifest_(); bool stream_firmware_to_coprocessor_(); + uint8_t initial_check_remaining_{0}; #else // Embedded mode members const uint8_t *firmware_data_{nullptr}; diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index 215131eb8eb..88a3e6d7dc9 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -72,53 +72,55 @@ void MS8607Component::setup() { // I do not know why the device sometimes NACKs the reset command, but // try 3 times in case it's a transitory issue on this boot - this->set_retry( - "reset", 5, 3, - [this](const uint8_t remaining_setup_attempts) { - ESP_LOGD(TAG, "Resetting both I2C addresses: 0x%02X, 0x%02X", this->address_, - this->humidity_device_->get_address()); - // I believe sending the reset command to both addresses is preferable to - // skipping humidity if PT fails for some reason. - // However, only consider the reset successful if they both ACK - bool const pt_successful = this->write_bytes(MS8607_PT_CMD_RESET, nullptr, 0); - bool const h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); + // Backoff: executes at now, +5ms, +30ms + this->reset_attempts_remaining_ = 3; + this->reset_interval_ = 5; + this->try_reset_(); +} - if (!(pt_successful && h_successful)) { - ESP_LOGE(TAG, "Resetting I2C devices failed"); - if (!pt_successful && !h_successful) { - this->error_code_ = ErrorCode::PTH_RESET_FAILED; - } else if (!pt_successful) { - this->error_code_ = ErrorCode::PT_RESET_FAILED; - } else { - this->error_code_ = ErrorCode::H_RESET_FAILED; - } +void MS8607Component::try_reset_() { + ESP_LOGD(TAG, "Resetting both I2C addresses: 0x%02X, 0x%02X", this->address_, this->humidity_device_->get_address()); + // I believe sending the reset command to both addresses is preferable to + // skipping humidity if PT fails for some reason. + // However, only consider the reset successful if they both ACK + bool const pt_successful = this->write_bytes(MS8607_PT_CMD_RESET, nullptr, 0); + bool const h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); - if (remaining_setup_attempts > 0) { - this->status_set_error(); - } else { - this->mark_failed(); - } - return RetryResult::RETRY; - } + if (!(pt_successful && h_successful)) { + ESP_LOGE(TAG, "Resetting I2C devices failed"); + if (!pt_successful && !h_successful) { + this->error_code_ = ErrorCode::PTH_RESET_FAILED; + } else if (!pt_successful) { + this->error_code_ = ErrorCode::PT_RESET_FAILED; + } else { + this->error_code_ = ErrorCode::H_RESET_FAILED; + } - this->setup_status_ = SetupStatus::NEEDS_PROM_READ; - this->error_code_ = ErrorCode::NONE; - this->status_clear_error(); + if (--this->reset_attempts_remaining_ > 0) { + uint32_t delay = this->reset_interval_; + this->reset_interval_ *= 5; + this->set_timeout("reset", delay, [this]() { this->try_reset_(); }); + this->status_set_error(); + } else { + this->mark_failed(); + } + return; + } - // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library - this->set_timeout("prom-read", 15, [this]() { - if (this->read_calibration_values_from_prom_()) { - this->setup_status_ = SetupStatus::SUCCESSFUL; - this->status_clear_error(); - } else { - this->mark_failed(); - return; - } - }); + this->setup_status_ = SetupStatus::NEEDS_PROM_READ; + this->error_code_ = ErrorCode::NONE; + this->status_clear_error(); - return RetryResult::DONE; - }, - 5.0f); // executes at now, +5ms, +25ms + // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library + this->set_timeout("prom-read", 15, [this]() { + if (this->read_calibration_values_from_prom_()) { + this->setup_status_ = SetupStatus::SUCCESSFUL; + this->status_clear_error(); + } else { + this->mark_failed(); + return; + } + }); } void MS8607Component::update() { diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 67ce2817fa5..ceb3dd22c8b 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -44,6 +44,8 @@ class MS8607Component : public PollingComponent, public i2c::I2CDevice { void set_humidity_device(MS8607HumidityDevice *humidity_device) { humidity_device_ = humidity_device; } protected: + /// Attempt to reset both I2C devices, retrying with backoff on failure + void try_reset_(); /** Read and store the Pressure & Temperature calibration settings from the PROM. Intended to be called during setup(), this will set the `failure_reason_` @@ -102,6 +104,8 @@ class MS8607Component : public PollingComponent, public i2c::I2CDevice { enum class SetupStatus; /// Current step in the multi-step & possibly delayed setup() process SetupStatus setup_status_; + uint32_t reset_interval_{5}; + uint8_t reset_attempts_remaining_{0}; }; } // namespace ms8607 diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 94f555c26e0..fdf6bf66cd5 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -103,6 +103,20 @@ void SpeakerMediaPlayer::set_playlist_delay_ms(AudioPipelineType pipeline_type, } } +void SpeakerMediaPlayer::stop_and_unpause_media_() { + this->media_pipeline_->stop(); + this->unpause_media_remaining_ = 3; + this->set_interval("unpause_med", 50, [this]() { + if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { + this->cancel_interval("unpause_med"); + this->media_pipeline_->set_pause_state(false); + this->is_paused_ = false; + } else if (--this->unpause_media_remaining_ == 0) { + this->cancel_interval("unpause_med"); + } + }); +} + void SpeakerMediaPlayer::watch_media_commands_() { if (!this->is_ready()) { return; @@ -144,15 +158,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->is_paused_) { // If paused, stop the media pipeline and unpause it after confirming its stopped. This avoids playing a // short segment of the paused file before starting the new one. - this->media_pipeline_->stop(); - this->set_retry("unpause_med", 50, 3, [this](const uint8_t remaining_attempts) { - if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->media_pipeline_->set_pause_state(false); - this->is_paused_ = false; - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); + this->stop_and_unpause_media_(); } else { // Not paused, just directly start the file if (media_command.file.has_value()) { @@ -197,27 +203,21 @@ void SpeakerMediaPlayer::watch_media_commands_() { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); this->announcement_pipeline_->stop(); - this->set_retry("unpause_ann", 50, 3, [this](const uint8_t remaining_attempts) { + this->unpause_announcement_remaining_ = 3; + this->set_interval("unpause_ann", 50, [this]() { if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED) { + this->cancel_interval("unpause_ann"); this->announcement_pipeline_->set_pause_state(false); - return RetryResult::DONE; + } else if (--this->unpause_announcement_remaining_ == 0) { + this->cancel_interval("unpause_ann"); } - return RetryResult::RETRY; }); } } else { if (this->media_pipeline_ != nullptr) { this->cancel_timeout("next_media"); this->media_playlist_.clear(); - this->media_pipeline_->stop(); - this->set_retry("unpause_med", 50, 3, [this](const uint8_t remaining_attempts) { - if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->media_pipeline_->set_pause_state(false); - this->is_paused_ = false; - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); + this->stop_and_unpause_media_(); } } diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 722f98ceeaa..6796fc9c003 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -112,6 +112,9 @@ class SpeakerMediaPlayer : public Component, /// media pipelines are defined. inline bool single_pipeline_() { return (this->media_speaker_ == nullptr); } + /// Stops the media pipeline and polls until stopped to unpause it, avoiding an audible glitch. + void stop_and_unpause_media_(); + // Processes commands from media_control_command_queue_. void watch_media_commands_(); @@ -141,6 +144,8 @@ class SpeakerMediaPlayer : public Component, bool is_paused_{false}; bool is_muted_{false}; + uint8_t unpause_media_remaining_{0}; + uint8_t unpause_announcement_remaining_{0}; // The amount to change the volume on volume up/down commands float volume_increment_; diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index e6d15623526..9d7ae0cbc0d 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -65,6 +65,7 @@ WaterHeaterCall &WaterHeaterCall::set_away(bool away) { } else { this->state_ &= ~WATER_HEATER_STATE_AWAY; } + this->state_mask_ |= WATER_HEATER_STATE_AWAY; return *this; } @@ -74,6 +75,7 @@ WaterHeaterCall &WaterHeaterCall::set_on(bool on) { } else { this->state_ &= ~WATER_HEATER_STATE_ON; } + this->state_mask_ |= WATER_HEATER_STATE_ON; return *this; } @@ -92,11 +94,11 @@ void WaterHeaterCall::perform() { if (!std::isnan(this->target_temperature_high_)) { ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); } - if (this->state_ & WATER_HEATER_STATE_AWAY) { - ESP_LOGD(TAG, " Away: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: %s", (this->state_ & WATER_HEATER_STATE_AWAY) ? "YES" : "NO"); } - if (this->state_ & WATER_HEATER_STATE_ON) { - ESP_LOGD(TAG, " On: YES"); + if (this->state_mask_ & WATER_HEATER_STATE_ON) { + ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO"); } this->parent_->control(*this); } @@ -137,13 +139,17 @@ void WaterHeaterCall::validate_() { this->target_temperature_high_ = NAN; } } - if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { - ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + if (!traits.get_supports_away_mode()) { + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + } this->state_ &= ~WATER_HEATER_STATE_AWAY; + this->state_mask_ &= ~WATER_HEATER_STATE_AWAY; } // If ON/OFF not supported, device is always on - clear the flag silently if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { this->state_ &= ~WATER_HEATER_STATE_ON; + this->state_mask_ &= ~WATER_HEATER_STATE_ON; } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 7bd05ba7f5d..93fcf5f401f 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -91,6 +91,8 @@ class WaterHeaterCall { float get_target_temperature_high() const { return this->target_temperature_high_; } /// Get state flags value uint32_t get_state() const { return this->state_; } + /// Get mask of state flags that are being changed + uint32_t get_state_mask() const { return this->state_mask_; } protected: void validate_(); @@ -100,6 +102,7 @@ class WaterHeaterCall { float target_temperature_low_{NAN}; float target_temperature_high_{NAN}; uint32_t state_{0}; + uint32_t state_mask_{0}; }; struct WaterHeaterCallInternal : public WaterHeaterCall { @@ -111,6 +114,7 @@ struct WaterHeaterCallInternal : public WaterHeaterCall { this->target_temperature_low_ = restore.target_temperature_low_; this->target_temperature_high_ = restore.target_temperature_high_; this->state_ = restore.state_; + this->state_mask_ = restore.state_mask_; return *this; } }; diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 2092a41a8e8..52dafeb9972 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -6,8 +6,7 @@ #include #include "multipart_parser.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { static const char *const TAG = "multipart"; @@ -249,6 +248,5 @@ std::string str_trim(const std::string &str) { return str.substr(start, end - start + 1); } -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index 8fbe90c4a04..9008be64598 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { // Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads class MultipartReader { @@ -81,6 +80,5 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st // Trim whitespace from both ends of a string std::string str_trim(const std::string &str); -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index d3c3c3dc55a..a1a2793b4ae 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -8,8 +8,7 @@ #include "utils.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf_utils"; @@ -119,6 +118,5 @@ const char *stristr(const char *haystack, const char *needle) { return nullptr; } -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // USE_ESP32 diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index ae58f82398c..bb0610dac00 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -5,8 +5,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { /// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space) /// Returns the new length of the decoded string @@ -29,6 +28,5 @@ bool str_ncmp_ci(const char *s1, const char *s2, size_t n); // Case-insensitive string search (like strstr but case-insensitive) const char *stristr(const char *haystack, const char *needle); -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // USE_ESP32 diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 39e6b7a790c..ac7f99bef7c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -30,8 +30,7 @@ #include #include -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" @@ -895,7 +894,6 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } #endif // USE_WEBSERVER_OTA -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // !defined(USE_ESP32) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 047bf4ef171..6797640f54c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -390,20 +390,19 @@ void Scheduler::full_cleanup_removed_items_() { // 4. No operations inside can block or take other locks, so no deadlock risk LockGuard guard{this->lock_}; - std::vector> valid_items; - - // Move all non-removed items to valid_items, recycle removed ones - for (auto &item : this->items_) { - if (!is_item_removed_(item.get())) { - valid_items.push_back(std::move(item)); + // Compact in-place: move valid items forward, recycle removed ones + size_t write = 0; + for (size_t read = 0; read < this->items_.size(); ++read) { + if (!is_item_removed_(this->items_[read].get())) { + if (write != read) { + this->items_[write] = std::move(this->items_[read]); + } + ++write; } else { - // Recycle removed items - this->recycle_item_main_loop_(std::move(item)); + this->recycle_item_main_loop_(std::move(this->items_[read])); } } - - // Replace items_ with the filtered list - this->items_ = std::move(valid_items); + this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); this->to_remove_ = 0; diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index 6035b4a1d68..3b22180b1db 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -32,7 +32,7 @@ class DashboardSettings: def __init__(self) -> None: """Initialize the dashboard settings.""" self.config_dir: Path = None - self.password_hash: str = "" + self.password_hash: bytes = b"" self.username: str = "" self.using_password: bool = False self.on_ha_addon: bool = False @@ -84,11 +84,14 @@ class DashboardSettings: def check_password(self, username: str, password: str) -> bool: if not self.using_auth: return True - if username != self.username: - return False - - # Compare password in constant running time (to prevent timing attacks) - return hmac.compare_digest(self.password_hash, password_hash(password)) + # Compare in constant running time (to prevent timing attacks) + username_matches = hmac.compare_digest( + username.encode("utf-8"), self.username.encode("utf-8") + ) + password_matches = hmac.compare_digest( + self.password_hash, password_hash(password) + ) + return username_matches and password_matches def rel_path(self, *args: Any) -> Path: """Return a path relative to the ESPHome config folder.""" diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 00974bf4608..92cab929ef4 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -120,8 +120,11 @@ def is_authenticated(handler: BaseHandler) -> bool: if auth_header := handler.request.headers.get("Authorization"): assert isinstance(auth_header, str) if auth_header.startswith("Basic "): - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) + try: + auth_decoded = base64.b64decode(auth_header[6:]).decode() + username, password = auth_decoded.split(":", 1) + except (binascii.Error, ValueError, UnicodeDecodeError): + return False return settings.check_password(username, password) return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES diff --git a/esphome/espota2.py b/esphome/espota2.py index 2d90251b386..c342eb4463c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -6,7 +6,7 @@ import hashlib import io import logging from pathlib import Path -import random +import secrets import socket import sys import time @@ -300,8 +300,8 @@ def perform_ota( nonce = nonce_bytes.decode() _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) - # Generate cnonce - cnonce = hash_func(str(random.random()).encode()).hexdigest() + # Generate cnonce matching the hash algorithm's digest size + cnonce = secrets.token_hex(nonce_size // 2) _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index 91a8ec70c31..55776ac7c4d 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -1,4 +1,4 @@ -"""Tests for dashboard settings Path-related functionality.""" +"""Tests for DashboardSettings (path resolution and authentication).""" from __future__ import annotations @@ -10,6 +10,7 @@ import pytest from esphome.core import CORE from esphome.dashboard.settings import DashboardSettings +from esphome.dashboard.util.password import password_hash @pytest.fixture @@ -221,3 +222,66 @@ def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: # Verify that CORE.config_path itself uses the sentinel file assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" assert not CORE.config_path.exists() # Sentinel file doesn't actually exist + + +@pytest.fixture +def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: + """Create DashboardSettings with auth configured, based on dashboard_settings.""" + dashboard_settings.username = "admin" + dashboard_settings.using_password = True + dashboard_settings.password_hash = password_hash("correctpassword") + return dashboard_settings + + +def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: + """Test check_password returns True for correct username and password.""" + assert auth_settings.check_password("admin", "correctpassword") is True + + +def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: + """Test check_password returns False for wrong password.""" + assert auth_settings.check_password("admin", "wrongpassword") is False + + +def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: + """Test check_password returns False for wrong username.""" + assert auth_settings.check_password("notadmin", "correctpassword") is False + + +def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: + """Test check_password returns False when both are wrong.""" + assert auth_settings.check_password("notadmin", "wrongpassword") is False + + +def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: + """Test check_password returns True when auth is not configured.""" + assert dashboard_settings.check_password("anyone", "anything") is True + + +def test_check_password_non_ascii_username( + dashboard_settings: DashboardSettings, +) -> None: + """Test check_password handles non-ASCII usernames without TypeError.""" + dashboard_settings.username = "\u00e9l\u00e8ve" + dashboard_settings.using_password = True + dashboard_settings.password_hash = password_hash("pass") + assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True + assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False + assert dashboard_settings.check_password("other", "pass") is False + + +def test_check_password_ha_addon_no_password( + dashboard_settings: DashboardSettings, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_password doesn't crash in HA add-on mode without a password. + + In HA add-on mode, using_ha_addon_auth can be True while using_password + is False, leaving password_hash as b"". This must not raise TypeError + in hmac.compare_digest. + """ + monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) + dashboard_settings.on_ha_addon = True + dashboard_settings.using_password = False + # password_hash stays as default b"" + assert dashboard_settings.check_password("anyone", "anything") is False diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 9ea7a5164b7..daff3845158 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -2,6 +2,7 @@ from __future__ import annotations from argparse import Namespace import asyncio +import base64 from collections.abc import Generator from contextlib import asynccontextmanager import gzip @@ -1741,3 +1742,85 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.write_message.assert_not_called() handler.close.assert_not_called() + + +def _make_auth_handler(auth_header: str | None = None) -> Mock: + """Create a mock handler with the given Authorization header.""" + handler = Mock() + handler.request = Mock() + if auth_header is not None: + handler.request.headers = {"Authorization": auth_header} + else: + handler.request.headers = {} + handler.get_secure_cookie = Mock(return_value=None) + return handler + + +@pytest.fixture +def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: + """Fixture to configure mock dashboard settings with auth enabled.""" + mock_dashboard_settings.using_auth = True + mock_dashboard_settings.on_ha_addon = False + return mock_dashboard_settings + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_malformed_base64() -> None: + """Test that invalid base64 in Authorization header returns False.""" + handler = _make_auth_handler("Basic !!!not-valid-base64!!!") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_bad_base64_padding() -> None: + """Test that incorrect base64 padding (binascii.Error) returns False.""" + handler = _make_auth_handler("Basic abc") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_invalid_utf8() -> None: + """Test that base64 decoding to invalid UTF-8 returns False.""" + # \xff\xfe is invalid UTF-8 + bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") + handler = _make_auth_handler(f"Basic {bad_payload}") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_no_colon() -> None: + """Test that base64 payload without ':' separator returns False.""" + no_colon = base64.b64encode(b"nocolonhere").decode("ascii") + handler = _make_auth_handler(f"Basic {no_colon}") + assert web_server.is_authenticated(handler) is False + + +def test_is_authenticated_valid_credentials( + mock_auth_settings: MagicMock, +) -> None: + """Test that valid Basic auth credentials are checked.""" + creds = base64.b64encode(b"admin:secret").decode("ascii") + mock_auth_settings.check_password.return_value = True + handler = _make_auth_handler(f"Basic {creds}") + assert web_server.is_authenticated(handler) is True + mock_auth_settings.check_password.assert_called_once_with("admin", "secret") + + +def test_is_authenticated_wrong_credentials( + mock_auth_settings: MagicMock, +) -> None: + """Test that valid Basic auth with wrong credentials returns False.""" + creds = base64.b64encode(b"admin:wrong").decode("ascii") + mock_auth_settings.check_password.return_value = False + handler = _make_auth_handler(f"Basic {creds}") + assert web_server.is_authenticated(handler) is False + + +def test_is_authenticated_no_auth_configured( + mock_dashboard_settings: MagicMock, +) -> None: + """Test that requests pass when auth is not configured.""" + mock_dashboard_settings.using_auth = False + mock_dashboard_settings.on_ha_addon = False + handler = _make_auth_handler() + assert web_server.is_authenticated(handler) is True diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 1885b769f18..20ba4b1f760 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -18,8 +18,8 @@ from esphome import espota2 from esphome.core import EsphomeError # Test constants -MOCK_RANDOM_VALUE = 0.123456 -MOCK_RANDOM_BYTES = b"0.123456" +MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16) +MOCK_SHA256_CNONCE = "b" * 64 # Mock 64-char hex string from secrets.token_hex(32) MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5 MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 @@ -55,10 +55,18 @@ def mock_time() -> Generator[None]: @pytest.fixture -def mock_random() -> Generator[Mock]: - """Mock random for predictable test values.""" - with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand: - yield mock_rand +def mock_token_hex() -> Generator[Mock]: + """Mock secrets.token_hex for predictable test values.""" + + def _token_hex(nbytes: int) -> str: + if nbytes == 16: + return MOCK_MD5_CNONCE + if nbytes == 32: + return MOCK_SHA256_CNONCE + raise ValueError(f"Unexpected nbytes for token_hex mock: {nbytes}") + + with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock: + yield mock @pytest.fixture @@ -236,7 +244,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None: @pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_md5_auth( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test successful OTA with MD5 authentication.""" # Setup socket responses for recv calls @@ -272,8 +280,11 @@ def test_perform_ota_successful_md5_auth( ) ) - # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + # Verify token_hex was called with MD5 digest size + mock_token_hex.assert_called_once_with(16) + + # Verify cnonce was sent + cnonce = MOCK_MD5_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly @@ -366,7 +377,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: @pytest.mark.usefixtures("mock_time") def test_perform_ota_md5_auth_wrong_password( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test OTA fails when MD5 authentication is rejected due to wrong password.""" # Setup socket responses for recv calls @@ -390,7 +401,7 @@ def test_perform_ota_md5_auth_wrong_password( @pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_auth_wrong_password( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test OTA fails when SHA256 authentication is rejected due to wrong password.""" # Setup socket responses for recv calls @@ -603,7 +614,7 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication @pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_sha256_auth( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test successful OTA with SHA256 authentication.""" # Setup socket responses for recv calls @@ -639,8 +650,11 @@ def test_perform_ota_successful_sha256_auth( ) ) - # Verify cnonce was sent (SHA256 of random.random()) - cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() + # Verify token_hex was called with SHA256 digest size + mock_token_hex.assert_called_once_with(32) + + # Verify cnonce was sent + cnonce = MOCK_SHA256_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly with SHA256 @@ -654,7 +668,7 @@ def test_perform_ota_successful_sha256_auth( @pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_fallback_to_md5( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility @@ -692,7 +706,8 @@ def test_perform_ota_sha256_fallback_to_md5( ) # But authentication was done with MD5 - cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + mock_token_hex.assert_called_once_with(16) + cnonce = MOCK_MD5_CNONCE expected_hash = hashlib.md5() expected_hash.update(b"testpass") expected_hash.update(MOCK_MD5_NONCE)