1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-09 09:11:52 +00:00

Compare commits

..

7 Commits

Author SHA1 Message Date
J. Nick Koston
13c84bf36a Merge remote-tracking branch 'upstream/dev' into no_new_to_string
# Conflicts:
#	script/ci-custom.py
2026-02-06 10:19:17 +01:00
J. Nick Koston
6ca1b90752 Address Copilot review feedback
- Fix regex to actually match std::to_string() by using alternation
  (the : in the lookbehind was preventing matches)
- Update error message to mention both std::to_string() and
  unqualified to_string() forms
- Correct buffer sizes for signed integer types:
  - int8_t: 5 chars (not 4) for "-128\0"
  - int16_t: 7 chars (not 6) for "-32768\0"
  - int32_t: 12 chars (not 11) for "-2147483648\0"
2026-01-28 18:14:16 -10:00
J. Nick Koston
fe1aa7e9ba Merge branch 'dev' into no_new_to_string 2026-01-28 17:42:57 -10:00
J. Nick Koston
8d51e2f580 Merge remote-tracking branch 'upstream/dev' into no_new_to_string
# Conflicts:
#	script/ci-custom.py
2026-01-21 19:50:39 -10:00
J. Nick Koston
11fb46ad11 Apply suggestions from code review 2026-01-19 17:44:25 -10:00
J. Nick Koston
9245c691d0 Merge branch 'dev' into no_new_to_string 2026-01-19 17:43:46 -10:00
J. Nick Koston
971a1a3e00 [ci] Block new std::to_string() usage, suggest snprintf alternatives 2026-01-19 08:49:31 -10:00
62 changed files with 971 additions and 1278 deletions

View File

@@ -1 +1 @@
37ec8d5a343c8d0a485fd2118cbdabcbccd7b9bca197e4a392be75087974dced
069fa9526c52f7c580a9ec17c7678d12f142221387e9b561c18f95394d4629a3

View File

@@ -12,6 +12,7 @@ from .const import (
CORE_SUBCATEGORY_PATTERNS,
DEMANGLED_PATTERNS,
ESPHOME_COMPONENT_PATTERN,
SECTION_TO_ATTR,
SYMBOL_PATTERNS,
)
from .demangle import batch_demangle
@@ -90,17 +91,6 @@ class ComponentMemory:
bss_size: int = 0 # Uninitialized data (ram only)
symbol_count: int = 0
def add_section_size(self, section_name: str, size: int) -> None:
"""Add size to the appropriate attribute for a section."""
if section_name == ".text":
self.text_size += size
elif section_name == ".rodata":
self.rodata_size += size
elif section_name == ".data":
self.data_size += size
elif section_name == ".bss":
self.bss_size += size
@property
def flash_total(self) -> int:
"""Total flash usage (text + rodata + data)."""
@@ -177,15 +167,12 @@ class MemoryAnalyzer:
self._elf_symbol_names: set[str] = set()
# SDK symbols not in ELF (static/local symbols from closed-source libs)
self._sdk_symbols: list[SDKSymbol] = []
# CSWTCH symbols: list of (name, size, source_file, component)
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_sdk_libraries()
return dict(self.components)
@@ -268,7 +255,8 @@ class MemoryAnalyzer:
comp_mem.symbol_count += 1
# Update the appropriate size attribute based on section
comp_mem.add_section_size(section_name, size)
if attr_name := SECTION_TO_ATTR.get(section_name):
setattr(comp_mem, attr_name, getattr(comp_mem, attr_name) + size)
# Track uncategorized symbols
if component == "other" and size > 0:
@@ -384,205 +372,6 @@ class MemoryAnalyzer:
return "Other Core"
def _find_object_files_dir(self) -> Path | None:
"""Find the directory containing object files for this build.
Returns:
Path to the directory containing .o files, or None if not found.
"""
# The ELF is typically at .pioenvs/<env>/firmware.elf
# Object files are in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/
pioenvs_dir = self.elf_path.parent
if pioenvs_dir.exists() and any(pioenvs_dir.glob("src/*.o")):
return pioenvs_dir
return None
def _scan_cswtch_in_objects(
self, obj_dir: Path
) -> dict[str, list[tuple[str, int]]]:
"""Scan object files for CSWTCH symbols using a single nm invocation.
Uses ``nm --print-file-name -S`` on all ``.o`` files at once.
Output format: ``/path/to/file.o:address size type name``
Args:
obj_dir: Directory containing object files (.pioenvs/<env>/)
Returns:
Dict mapping "CSWTCH$NNN:size" to list of (source_file, size) tuples.
"""
cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list)
if not self.nm_path:
return cswtch_map
# Find all .o files recursively, sorted for deterministic output
obj_files = sorted(obj_dir.rglob("*.o"))
if not obj_files:
return cswtch_map
_LOGGER.debug("Scanning %d object files for CSWTCH symbols", len(obj_files))
# Single nm call with --print-file-name for all object files
result = run_tool(
[self.nm_path, "--print-file-name", "-S"] + [str(f) for f in obj_files],
timeout=30,
)
if result is None or result.returncode != 0:
return cswtch_map
for line in result.stdout.splitlines():
if "CSWTCH$" not in line:
continue
# Split on last ":" that precedes a hex address.
# nm --print-file-name format: filepath:hex_addr hex_size type name
# We split from the right: find the last colon followed by hex digits.
parts_after_colon = line.rsplit(":", 1)
if len(parts_after_colon) != 2:
continue
file_path = parts_after_colon[0]
fields = parts_after_colon[1].split()
# fields: [address, size, type, name]
if len(fields) < 4:
continue
sym_name = fields[3]
if not sym_name.startswith("CSWTCH$"):
continue
try:
size = int(fields[1], 16)
except ValueError:
continue
# Get relative path from obj_dir for readability
try:
rel_path = str(Path(file_path).relative_to(obj_dir))
except ValueError:
rel_path = file_path
key = f"{sym_name}:{size}"
cswtch_map[key].append((rel_path, size))
return cswtch_map
def _source_file_to_component(self, source_file: str) -> str:
"""Map a source object file path to its component name.
Args:
source_file: Relative path like 'src/esphome/components/wifi/wifi_component.cpp.o'
Returns:
Component name like '[esphome]wifi' or the source file if unknown.
"""
parts = Path(source_file).parts
# ESPHome component: src/esphome/components/<name>/...
if "components" in parts:
idx = parts.index("components")
if idx + 1 < len(parts):
component_name = parts[idx + 1]
if component_name in get_esphome_components():
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
if component_name in self.external_components:
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
# ESPHome core: src/esphome/core/... or src/esphome/...
if "core" in parts and "esphome" in parts:
return _COMPONENT_CORE
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
return parts[0] if parts else source_file
def _analyze_cswtch_symbols(self) -> None:
"""Analyze CSWTCH (GCC switch table) symbols by tracing to source objects.
CSWTCH symbols are compiler-generated lookup tables for switch statements.
They are local symbols, so the same name can appear in different object files.
This method scans .o files to attribute them to their source components.
"""
obj_dir = self._find_object_files_dir()
if obj_dir is None:
_LOGGER.debug("No object files directory found, skipping CSWTCH analysis")
return
# Scan object files for CSWTCH symbols
cswtch_map = self._scan_cswtch_in_objects(obj_dir)
if not cswtch_map:
_LOGGER.debug("No CSWTCH symbols found in object files")
return
# Collect CSWTCH symbols from the ELF (already parsed in sections)
# Include section_name for re-attribution of component totals
elf_cswtch = [
(symbol_name, size, section_name)
for section_name, section in self.sections.items()
for symbol_name, size, _ in section.symbols
if symbol_name.startswith("CSWTCH$")
]
_LOGGER.debug(
"Found %d CSWTCH symbols in ELF, %d unique in object files",
len(elf_cswtch),
len(cswtch_map),
)
# Match ELF CSWTCH symbols to source files and re-attribute component totals.
# _categorize_symbols() already ran and put these into "other" since CSWTCH$
# names don't match any component pattern. We move the bytes to the correct
# component based on the object file mapping.
other_mem = self.components.get("other")
for sym_name, size, section_name in elf_cswtch:
key = f"{sym_name}:{size}"
sources = cswtch_map.get(key, [])
if len(sources) == 1:
source_file = sources[0][0]
component = self._source_file_to_component(source_file)
elif len(sources) > 1:
# Ambiguous - multiple object files have same CSWTCH name+size
source_file = "ambiguous"
component = "ambiguous"
_LOGGER.debug(
"Ambiguous CSWTCH %s (%d B) found in %d files: %s",
sym_name,
size,
len(sources),
", ".join(src for src, _ in sources),
)
else:
source_file = "unknown"
component = "unknown"
self._cswtch_symbols.append((sym_name, size, source_file, component))
# Re-attribute from "other" to the correct component
if (
component not in ("other", "unknown", "ambiguous")
and other_mem is not None
):
other_mem.add_section_size(section_name, -size)
if component not in self.components:
self.components[component] = ComponentMemory(component)
self.components[component].add_section_size(section_name, size)
# Sort by size descending
self._cswtch_symbols.sort(key=lambda x: x[1], reverse=True)
total_size = sum(size for _, size, _, _ in self._cswtch_symbols)
_LOGGER.debug(
"CSWTCH analysis: %d symbols, %d bytes total",
len(self._cswtch_symbols),
total_size,
)
def get_unattributed_ram(self) -> tuple[int, int, int]:
"""Get unattributed RAM sizes (SDK/framework overhead).

View File

@@ -184,52 +184,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
)
def _add_cswtch_analysis(self, lines: list[str]) -> None:
"""Add CSWTCH (GCC switch table lookup) analysis section."""
self._add_section_header(lines, "CSWTCH Analysis (GCC Switch Table Lookups)")
total_size = sum(size for _, size, _, _ in self._cswtch_symbols)
lines.append(
f"Total: {len(self._cswtch_symbols)} switch table(s), {total_size:,} B"
)
lines.append("")
# Group by component
by_component: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
for sym_name, size, source_file, component in self._cswtch_symbols:
by_component[component].append((sym_name, size, source_file))
# Sort components by total size descending
sorted_components = sorted(
by_component.items(),
key=lambda x: sum(s[1] for s in x[1]),
reverse=True,
)
for component, symbols in sorted_components:
comp_total = sum(s[1] for s in symbols)
lines.append(f"{component} ({comp_total:,} B, {len(symbols)} tables):")
# Group by source file within component
by_file: dict[str, list[tuple[str, int]]] = defaultdict(list)
for sym_name, size, source_file in symbols:
by_file[source_file].append((sym_name, size))
for source_file, file_symbols in sorted(
by_file.items(),
key=lambda x: sum(s[1] for s in x[1]),
reverse=True,
):
file_total = sum(s[1] for s in file_symbols)
lines.append(
f" {source_file} ({file_total:,} B, {len(file_symbols)} tables)"
)
for sym_name, size in sorted(
file_symbols, key=lambda x: x[1], reverse=True
):
lines.append(f" {size:>6,} B {sym_name}")
lines.append("")
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
@@ -517,10 +471,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f" ... and {len(large_ram_syms) - 10} more")
lines.append("")
# CSWTCH (GCC switch table) analysis
if self._cswtch_symbols:
self._add_cswtch_analysis(lines)
lines.append(
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
)

View File

@@ -66,6 +66,15 @@ SECTION_MAPPING = {
),
}
# Section to ComponentMemory attribute mapping
# Maps section names to the attribute name in ComponentMemory dataclass
SECTION_TO_ATTR = {
".text": "text_size",
".rodata": "rodata_size",
".data": "data_size",
".bss": "bss_size",
}
# Component identification rules
# Symbol patterns: patterns found in raw symbol names
SYMBOL_PATTERNS = {
@@ -504,9 +513,7 @@ SYMBOL_PATTERNS = {
"__FUNCTION__$",
"DAYS_IN_MONTH",
"_DAYS_BEFORE_MONTH",
# Note: CSWTCH$ symbols are GCC switch table lookup tables.
# They are attributed to their source object files via _analyze_cswtch_symbols()
# rather than being lumped into libc.
"CSWTCH$",
"dst$",
"sulp",
"_strtol_l", # String to long with locale

View File

@@ -1,15 +1,32 @@
#include "alarm_control_panel_state.h"
#include "esphome/core/progmem.h"
namespace esphome::alarm_control_panel {
// Alarm control panel state strings indexed by AlarmControlPanelState enum (0-9)
PROGMEM_STRING_TABLE(AlarmControlPanelStateStrings, "DISARMED", "ARMED_HOME", "ARMED_AWAY", "ARMED_NIGHT",
"ARMED_VACATION", "ARMED_CUSTOM_BYPASS", "PENDING", "ARMING", "DISARMING", "TRIGGERED", "UNKNOWN");
const LogString *alarm_control_panel_state_to_string(AlarmControlPanelState state) {
return AlarmControlPanelStateStrings::get_log_str(static_cast<uint8_t>(state),
AlarmControlPanelStateStrings::LAST_INDEX);
switch (state) {
case ACP_STATE_DISARMED:
return LOG_STR("DISARMED");
case ACP_STATE_ARMED_HOME:
return LOG_STR("ARMED_HOME");
case ACP_STATE_ARMED_AWAY:
return LOG_STR("ARMED_AWAY");
case ACP_STATE_ARMED_NIGHT:
return LOG_STR("ARMED_NIGHT");
case ACP_STATE_ARMED_VACATION:
return LOG_STR("ARMED_VACATION");
case ACP_STATE_ARMED_CUSTOM_BYPASS:
return LOG_STR("ARMED_CUSTOM_BYPASS");
case ACP_STATE_PENDING:
return LOG_STR("PENDING");
case ACP_STATE_ARMING:
return LOG_STR("ARMING");
case ACP_STATE_DISARMING:
return LOG_STR("DISARMING");
case ACP_STATE_TRIGGERED:
return LOG_STR("TRIGGERED");
default:
return LOG_STR("UNKNOWN");
}
}
} // namespace esphome::alarm_control_panel

View File

@@ -25,7 +25,9 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
private:
// Helper to convert value to string - handles the case where value is already a string
template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
template<typename T> static std::string value_to_string(T &&val) {
return to_string(std::forward<T>(val)); // NOLINT
}
// Overloads for string types - needed because std::to_string doesn't support them
static std::string value_to_string(char *val) {

View File

@@ -1,44 +1,109 @@
#include "climate_mode.h"
#include "esphome/core/progmem.h"
namespace esphome::climate {
// Climate mode strings indexed by ClimateMode enum (0-6): OFF, HEAT_COOL, COOL, HEAT, FAN_ONLY, DRY, AUTO
PROGMEM_STRING_TABLE(ClimateModeStrings, "OFF", "HEAT_COOL", "COOL", "HEAT", "FAN_ONLY", "DRY", "AUTO", "UNKNOWN");
const LogString *climate_mode_to_string(ClimateMode mode) {
return ClimateModeStrings::get_log_str(static_cast<uint8_t>(mode), ClimateModeStrings::LAST_INDEX);
switch (mode) {
case CLIMATE_MODE_OFF:
return LOG_STR("OFF");
case CLIMATE_MODE_HEAT_COOL:
return LOG_STR("HEAT_COOL");
case CLIMATE_MODE_AUTO:
return LOG_STR("AUTO");
case CLIMATE_MODE_COOL:
return LOG_STR("COOL");
case CLIMATE_MODE_HEAT:
return LOG_STR("HEAT");
case CLIMATE_MODE_FAN_ONLY:
return LOG_STR("FAN_ONLY");
case CLIMATE_MODE_DRY:
return LOG_STR("DRY");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate action strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", "UNKNOWN");
const LogString *climate_action_to_string(ClimateAction action) {
return ClimateActionStrings::get_log_str(static_cast<uint8_t>(action), ClimateActionStrings::LAST_INDEX);
switch (action) {
case CLIMATE_ACTION_OFF:
return LOG_STR("OFF");
case CLIMATE_ACTION_COOLING:
return LOG_STR("COOLING");
case CLIMATE_ACTION_HEATING:
return LOG_STR("HEATING");
case CLIMATE_ACTION_IDLE:
return LOG_STR("IDLE");
case CLIMATE_ACTION_DRYING:
return LOG_STR("DRYING");
case CLIMATE_ACTION_FAN:
return LOG_STR("FAN");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate fan mode strings indexed by ClimateFanMode enum (0-9): ON, OFF, AUTO, LOW, MEDIUM, HIGH, MIDDLE, FOCUS,
// DIFFUSE, QUIET
PROGMEM_STRING_TABLE(ClimateFanModeStrings, "ON", "OFF", "AUTO", "LOW", "MEDIUM", "HIGH", "MIDDLE", "FOCUS", "DIFFUSE",
"QUIET", "UNKNOWN");
const LogString *climate_fan_mode_to_string(ClimateFanMode fan_mode) {
return ClimateFanModeStrings::get_log_str(static_cast<uint8_t>(fan_mode), ClimateFanModeStrings::LAST_INDEX);
switch (fan_mode) {
case climate::CLIMATE_FAN_ON:
return LOG_STR("ON");
case climate::CLIMATE_FAN_OFF:
return LOG_STR("OFF");
case climate::CLIMATE_FAN_AUTO:
return LOG_STR("AUTO");
case climate::CLIMATE_FAN_LOW:
return LOG_STR("LOW");
case climate::CLIMATE_FAN_MEDIUM:
return LOG_STR("MEDIUM");
case climate::CLIMATE_FAN_HIGH:
return LOG_STR("HIGH");
case climate::CLIMATE_FAN_MIDDLE:
return LOG_STR("MIDDLE");
case climate::CLIMATE_FAN_FOCUS:
return LOG_STR("FOCUS");
case climate::CLIMATE_FAN_DIFFUSE:
return LOG_STR("DIFFUSE");
case climate::CLIMATE_FAN_QUIET:
return LOG_STR("QUIET");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate swing mode strings indexed by ClimateSwingMode enum (0-3): OFF, BOTH, VERTICAL, HORIZONTAL
PROGMEM_STRING_TABLE(ClimateSwingModeStrings, "OFF", "BOTH", "VERTICAL", "HORIZONTAL", "UNKNOWN");
const LogString *climate_swing_mode_to_string(ClimateSwingMode swing_mode) {
return ClimateSwingModeStrings::get_log_str(static_cast<uint8_t>(swing_mode), ClimateSwingModeStrings::LAST_INDEX);
switch (swing_mode) {
case climate::CLIMATE_SWING_OFF:
return LOG_STR("OFF");
case climate::CLIMATE_SWING_BOTH:
return LOG_STR("BOTH");
case climate::CLIMATE_SWING_VERTICAL:
return LOG_STR("VERTICAL");
case climate::CLIMATE_SWING_HORIZONTAL:
return LOG_STR("HORIZONTAL");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate preset strings indexed by ClimatePreset enum (0-7): NONE, HOME, AWAY, BOOST, COMFORT, ECO, SLEEP, ACTIVITY
PROGMEM_STRING_TABLE(ClimatePresetStrings, "NONE", "HOME", "AWAY", "BOOST", "COMFORT", "ECO", "SLEEP", "ACTIVITY",
"UNKNOWN");
const LogString *climate_preset_to_string(ClimatePreset preset) {
return ClimatePresetStrings::get_log_str(static_cast<uint8_t>(preset), ClimatePresetStrings::LAST_INDEX);
switch (preset) {
case climate::CLIMATE_PRESET_NONE:
return LOG_STR("NONE");
case climate::CLIMATE_PRESET_HOME:
return LOG_STR("HOME");
case climate::CLIMATE_PRESET_ECO:
return LOG_STR("ECO");
case climate::CLIMATE_PRESET_AWAY:
return LOG_STR("AWAY");
case climate::CLIMATE_PRESET_BOOST:
return LOG_STR("BOOST");
case climate::CLIMATE_PRESET_COMFORT:
return LOG_STR("COMFORT");
case climate::CLIMATE_PRESET_SLEEP:
return LOG_STR("SLEEP");
case climate::CLIMATE_PRESET_ACTIVITY:
return LOG_STR("ACTIVITY");
default:
return LOG_STR("UNKNOWN");
}
}
} // namespace esphome::climate

View File

@@ -19,11 +19,17 @@ const LogString *cover_command_to_str(float pos) {
return LOG_STR("UNKNOWN");
}
}
// Cover operation strings indexed by CoverOperation enum (0-2): IDLE, OPENING, CLOSING, plus UNKNOWN
PROGMEM_STRING_TABLE(CoverOperationStrings, "IDLE", "OPENING", "CLOSING", "UNKNOWN");
const LogString *cover_operation_to_str(CoverOperation op) {
return CoverOperationStrings::get_log_str(static_cast<uint8_t>(op), CoverOperationStrings::LAST_INDEX);
switch (op) {
case COVER_OPERATION_IDLE:
return LOG_STR("IDLE");
case COVER_OPERATION_OPENING:
return LOG_STR("OPENING");
case COVER_OPERATION_CLOSING:
return LOG_STR("CLOSING");
default:
return LOG_STR("UNKNOWN");
}
}
Cover::Cover() : position{COVER_OPEN} {}

View File

@@ -1,5 +1,4 @@
#include "dfplayer.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -133,151 +132,139 @@ void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) {
void DFPlayer::loop() {
// Read message
int avail = this->available();
if (avail <= 0)
return;
while (this->available()) {
uint8_t byte;
this->read_byte(&byte);
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}
avail -= to_read;
for (size_t bi = 0; bi < to_read; bi++) {
uint8_t byte = buf[bi];
if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH)
this->read_pos_ = 0;
if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH)
this->read_pos_ = 0;
switch (this->read_pos_) {
case 0: // Start mark
if (byte != 0x7E)
continue;
break;
case 1: // Version
if (byte != 0xFF) {
ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
break;
case 2: // Buffer length
if (byte != 0x06) {
ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
break;
case 9: // End byte
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
char byte_sequence[100];
byte_sequence[0] = '\0';
for (size_t i = 0; i < this->read_pos_ + 1; ++i) {
snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ",
this->read_buffer_[i]);
}
ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence);
#endif
if (byte != 0xEF) {
ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
// Parse valid received command
uint8_t cmd = this->read_buffer_[3];
uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6];
ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument);
switch (cmd) {
case 0x3A:
if (argument == 1) {
ESP_LOGI(TAG, "USB loaded");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card loaded");
}
break;
case 0x3B:
if (argument == 1) {
ESP_LOGI(TAG, "USB unloaded");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card unloaded");
}
break;
case 0x3F:
if (argument == 1) {
ESP_LOGI(TAG, "USB available");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card available");
} else if (argument == 3) {
ESP_LOGI(TAG, "USB, TF Card available");
}
break;
case 0x40:
ESP_LOGV(TAG, "Nack");
this->ack_set_is_playing_ = false;
this->ack_reset_is_playing_ = false;
switch (argument) {
case 0x01:
ESP_LOGE(TAG, "Module is busy or uninitialized");
break;
case 0x02:
ESP_LOGE(TAG, "Module is in sleep mode");
break;
case 0x03:
ESP_LOGE(TAG, "Serial receive error");
break;
case 0x04:
ESP_LOGE(TAG, "Checksum incorrect");
break;
case 0x05:
ESP_LOGE(TAG, "Specified track is out of current track scope");
this->is_playing_ = false;
break;
case 0x06:
ESP_LOGE(TAG, "Specified track is not found");
this->is_playing_ = false;
break;
case 0x07:
ESP_LOGE(TAG,
"Insertion error (an inserting operation only can be done when a track is being played)");
break;
case 0x08:
ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)");
break;
case 0x09:
ESP_LOGE(TAG, "Entered into sleep mode");
this->is_playing_ = false;
break;
}
break;
case 0x41:
ESP_LOGV(TAG, "Ack ok");
this->is_playing_ |= this->ack_set_is_playing_;
this->is_playing_ &= !this->ack_reset_is_playing_;
this->ack_set_is_playing_ = false;
this->ack_reset_is_playing_ = false;
break;
case 0x3C:
ESP_LOGV(TAG, "Playback finished (USB drive)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
case 0x3D:
ESP_LOGV(TAG, "Playback finished (SD card)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
break;
default:
ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument);
}
this->sent_cmd_ = 0;
switch (this->read_pos_) {
case 0: // Start mark
if (byte != 0x7E)
continue;
break;
case 1: // Version
if (byte != 0xFF) {
ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
this->read_buffer_[this->read_pos_] = byte;
this->read_pos_++;
}
break;
case 2: // Buffer length
if (byte != 0x06) {
ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
break;
case 9: // End byte
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
char byte_sequence[100];
byte_sequence[0] = '\0';
for (size_t i = 0; i < this->read_pos_ + 1; ++i) {
snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ",
this->read_buffer_[i]);
}
ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence);
#endif
if (byte != 0xEF) {
ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte);
this->read_pos_ = 0;
continue;
}
// Parse valid received command
uint8_t cmd = this->read_buffer_[3];
uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6];
ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument);
switch (cmd) {
case 0x3A:
if (argument == 1) {
ESP_LOGI(TAG, "USB loaded");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card loaded");
}
break;
case 0x3B:
if (argument == 1) {
ESP_LOGI(TAG, "USB unloaded");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card unloaded");
}
break;
case 0x3F:
if (argument == 1) {
ESP_LOGI(TAG, "USB available");
} else if (argument == 2) {
ESP_LOGI(TAG, "TF Card available");
} else if (argument == 3) {
ESP_LOGI(TAG, "USB, TF Card available");
}
break;
case 0x40:
ESP_LOGV(TAG, "Nack");
this->ack_set_is_playing_ = false;
this->ack_reset_is_playing_ = false;
switch (argument) {
case 0x01:
ESP_LOGE(TAG, "Module is busy or uninitialized");
break;
case 0x02:
ESP_LOGE(TAG, "Module is in sleep mode");
break;
case 0x03:
ESP_LOGE(TAG, "Serial receive error");
break;
case 0x04:
ESP_LOGE(TAG, "Checksum incorrect");
break;
case 0x05:
ESP_LOGE(TAG, "Specified track is out of current track scope");
this->is_playing_ = false;
break;
case 0x06:
ESP_LOGE(TAG, "Specified track is not found");
this->is_playing_ = false;
break;
case 0x07:
ESP_LOGE(TAG, "Insertion error (an inserting operation only can be done when a track is being played)");
break;
case 0x08:
ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)");
break;
case 0x09:
ESP_LOGE(TAG, "Entered into sleep mode");
this->is_playing_ = false;
break;
}
break;
case 0x41:
ESP_LOGV(TAG, "Ack ok");
this->is_playing_ |= this->ack_set_is_playing_;
this->is_playing_ &= !this->ack_reset_is_playing_;
this->ack_set_is_playing_ = false;
this->ack_reset_is_playing_ = false;
break;
case 0x3C:
ESP_LOGV(TAG, "Playback finished (USB drive)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
case 0x3D:
ESP_LOGV(TAG, "Playback finished (SD card)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
break;
default:
ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument);
}
this->sent_cmd_ = 0;
this->read_pos_ = 0;
continue;
}
this->read_buffer_[this->read_pos_] = byte;
this->read_pos_++;
}
}
void DFPlayer::dump_config() {

View File

@@ -66,7 +66,7 @@ async def to_code(config):
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
# DSMR Parser
cg.add_library("esphome/dsmr_parser", "1.1.0")
cg.add_library("esphome/dsmr_parser", "1.0.0")
# Crypto
cg.add_library("polargoose/Crypto-no-arduino", "0.4.0")

View File

@@ -718,6 +718,14 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("fw_core_version"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("fw_module_version"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend(cv.COMPONENT_SCHEMA)

View File

@@ -26,9 +26,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("sub_equipment_id"): text_sensor.text_sensor_schema(),
cv.Optional("gas_delivered_text"): text_sensor.text_sensor_schema(),
cv.Optional("fw_core_checksum"): text_sensor.text_sensor_schema(),
cv.Optional("fw_core_version"): text_sensor.text_sensor_schema(),
cv.Optional("fw_module_checksum"): text_sensor.text_sensor_schema(),
cv.Optional("fw_module_version"): text_sensor.text_sensor_schema(),
cv.Optional("telegram"): text_sensor.text_sensor_schema().extend(
{cv.Optional(CONF_INTERNAL, default=True): cv.boolean}
),

View File

@@ -10,11 +10,20 @@
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_ADVERTISING
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#endif
#include <esp_gap_ble_api.h>
#include <esp_gatts_api.h>
namespace esphome::esp32_ble {
using raw_adv_data_t = struct {
uint8_t *data;
size_t length;
esp_power_level_t power_level;
};
class ESPBTUUID;
class BLEAdvertising {

View File

@@ -48,7 +48,7 @@ class ESPBTUUID {
// Remove before 2026.8.0
ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0")
std::string to_string() const;
std::string to_string() const; // NOLINT
const char *to_str(std::span<char, UUID_STR_LEN> output) const;
protected:

View File

@@ -53,10 +53,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MEASURED_POWER, default=-59): cv.int_range(
min=-128, max=0
),
cv.OnlyWithout(CONF_TX_POWER, "esp32_hosted", default="3dBm"): cv.All(
cv.conflicts_with_component("esp32_hosted"),
cv.decibel,
cv.enum(esp32_ble.TX_POWER_LEVELS, int=True),
cv.Optional(CONF_TX_POWER, default="3dBm"): cv.All(
cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True)
),
}
).extend(cv.COMPONENT_SCHEMA),
@@ -84,10 +82,7 @@ async def to_code(config):
cg.add(var.set_min_interval(config[CONF_MIN_INTERVAL]))
cg.add(var.set_max_interval(config[CONF_MAX_INTERVAL]))
cg.add(var.set_measured_power(config[CONF_MEASURED_POWER]))
# TX power control only available on native Bluetooth (not ESP-Hosted)
if CONF_TX_POWER in config:
cg.add(var.set_tx_power(config[CONF_TX_POWER]))
cg.add(var.set_tx_power(config[CONF_TX_POWER]))
cg.add_define("USE_ESP32_BLE_ADVERTISING")

View File

@@ -36,16 +36,11 @@ void ESP32BLEBeacon::dump_config() {
}
}
*bpos = '\0';
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
ESP_LOGCONFIG(TAG,
" UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d"
", TX Power: %ddBm",
uuid, this->major_, this->minor_, this->min_interval_, this->max_interval_, this->measured_power_,
(this->tx_power_ * 3) - 12);
#else
ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d",
uuid, this->major_, this->minor_, this->min_interval_, this->max_interval_, this->measured_power_);
#endif
}
float ESP32BLEBeacon::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
@@ -79,14 +74,11 @@ void ESP32BLEBeacon::on_advertise_() {
ibeacon_adv_data.ibeacon_vendor.major = byteswap(this->major_);
ibeacon_adv_data.ibeacon_vendor.measured_power = static_cast<uint8_t>(this->measured_power_);
esp_err_t err;
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
ESP_LOGD(TAG, "Setting BLE TX power");
err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
esp_err_t err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_ble_tx_power_set failed: %s", esp_err_to_name(err));
}
#endif
err = esp_ble_gap_config_adv_data_raw((uint8_t *) &ibeacon_adv_data, sizeof(ibeacon_adv_data));
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_config_adv_data_raw failed: %s", esp_err_to_name(err));

View File

@@ -48,9 +48,7 @@ class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented
void set_min_interval(uint16_t val) { this->min_interval_ = val; }
void set_max_interval(uint16_t val) { this->max_interval_ = val; }
void set_measured_power(int8_t val) { this->measured_power_ = val; }
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; }
#endif
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
protected:
@@ -62,9 +60,7 @@ class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented
uint16_t min_interval_{};
uint16_t max_interval_{};
int8_t measured_power_{};
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
esp_power_level_t tx_power_{};
#endif
esp_ble_adv_params_t ble_adv_params_;
bool advertising_{false};
};

View File

@@ -2,18 +2,21 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace fan {
static const char *const TAG = "fan";
// Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN
PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN");
const LogString *fan_direction_to_string(FanDirection direction) {
return FanDirectionStrings::get_log_str(static_cast<uint8_t>(direction), FanDirectionStrings::LAST_INDEX);
switch (direction) {
case FanDirection::FORWARD:
return LOG_STR("FORWARD");
case FanDirection::REVERSE:
return LOG_STR("REVERSE");
default:
return LOG_STR("UNKNOWN");
}
}
FanCall &FanCall::set_preset_mode(const std::string &preset_mode) {

View File

@@ -1,6 +1,5 @@
#include "gpio_binary_sensor.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace gpio {
@@ -8,12 +7,17 @@ namespace gpio {
static const char *const TAG = "gpio.binary_sensor";
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
// Interrupt type strings indexed by edge-triggered InterruptType values:
// indices 1-3: RISING_EDGE, FALLING_EDGE, ANY_EDGE; other values (e.g. level-triggered) map to UNKNOWN (index 0).
PROGMEM_STRING_TABLE(InterruptTypeStrings, "UNKNOWN", "RISING_EDGE", "FALLING_EDGE", "ANY_EDGE");
static const LogString *interrupt_type_to_string(gpio::InterruptType type) {
return InterruptTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
switch (type) {
case gpio::INTERRUPT_RISING_EDGE:
return LOG_STR("RISING_EDGE");
case gpio::INTERRUPT_FALLING_EDGE:
return LOG_STR("FALLING_EDGE");
case gpio::INTERRUPT_ANY_EDGE:
return LOG_STR("ANY_EDGE");
default:
return LOG_STR("UNKNOWN");
}
}
static const LogString *gpio_mode_to_string(bool use_interrupt) {

View File

@@ -133,10 +133,20 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
// HTTPClient::getSize() returns -1 for chunked transfer encoding (no Content-Length).
// When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit).
// The read() method uses a chunked transfer encoding decoder (read_chunked_) to strip
// chunk framing and deliver only decoded content. When the final 0-size chunk is received,
// is_chunked_ is cleared and content_length is set to the actual decoded size, so
// is_read_complete() returns true and callers exit their read loops correctly.
// The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the
// early return check (bytes_read_ >= content_length) will never trigger.
//
// TODO: Chunked transfer encoding is NOT properly supported on Arduino.
// The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where
// esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr()
// returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead
// of decoded content. This wasn't noticed because requests would complete and payloads
// were only examined on IDF. The long transfer times were also masked by the misleading
// "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues:
// 1. Response body is corrupted - contains chunk size headers mixed with data
// 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout
// The proper fix would be to use getString() for chunked responses, which decodes chunks
// internally, but this buffers the entire response in memory.
int content_length = container->client_.getSize();
ESP_LOGD(TAG, "Content-Length: %d", content_length);
container->content_length = (size_t) content_length;
@@ -164,10 +174,6 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
// > 0: bytes read
// 0: no data yet, retry <-- NOTE: 0 means retry, NOT EOF!
// < 0: error/connection closed <-- connection closed returns -1, not 0
//
// For chunked transfer encoding, read_chunked_() decodes chunk framing and delivers
// only the payload data. When the final 0-size chunk is received, it clears is_chunked_
// and sets content_length = bytes_read_ so is_read_complete() returns true.
int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
@@ -178,42 +184,24 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
return HTTP_ERROR_CONNECTION_CLOSED;
}
if (this->is_chunked_) {
int result = this->read_chunked_(buf, max_len, stream_ptr);
this->duration_ms += (millis() - start);
if (result > 0) {
return result;
}
// result <= 0: check for completion or errors
if (this->is_read_complete()) {
return 0; // Chunked transfer complete (final 0-size chunk received)
}
if (result < 0) {
return result; // Stream error during chunk decoding
}
// read_chunked_ returned 0: no data was available (available() was 0).
// This happens when the TCP buffer is empty - either more data is in flight,
// or the connection dropped. Arduino's connected() returns false only when
// both the remote has closed AND the receive buffer is empty, so any buffered
// data is fully drained before we report the drop.
if (!stream_ptr->connected()) {
return HTTP_ERROR_CONNECTION_CLOSED;
}
return 0; // No data yet, caller should retry
}
// Non-chunked path
int available_data = stream_ptr->available();
// For chunked transfer encoding, HTTPClient::getSize() returns -1, which becomes SIZE_MAX when
// cast to size_t. SIZE_MAX - bytes_read_ is still huge, so it won't limit the read.
size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len;
int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data));
if (bufsize == 0) {
this->duration_ms += (millis() - start);
// Check if we've read all expected content (non-chunked only)
// For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
if (this->is_read_complete()) {
return 0; // All content read successfully
}
// No data available - check if connection is still open
// For chunked encoding, !connected() after reading means EOF (all chunks received)
// For known content_length with bytes_read_ < content_length, it means connection dropped
if (!stream_ptr->connected()) {
return HTTP_ERROR_CONNECTION_CLOSED;
return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed or EOF for chunked
}
return 0; // No data yet, caller should retry
}
@@ -227,143 +215,6 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
return read_len;
}
void HttpContainerArduino::chunk_header_complete_() {
if (this->chunk_remaining_ == 0) {
this->chunk_state_ = ChunkedState::CHUNK_TRAILER;
this->chunk_remaining_ = 1; // repurpose as at-start-of-line flag
} else {
this->chunk_state_ = ChunkedState::CHUNK_DATA;
}
}
// Chunked transfer encoding decoder
//
// On Arduino, getStreamPtr() returns raw TCP data. For chunked responses, this includes
// chunk framing (size headers, CRLF delimiters) mixed with payload data. This decoder
// strips the framing and delivers only decoded content to the caller.
//
// Chunk format (RFC 9112 Section 7.1):
// <hex-size>[;extension]\r\n
// <data bytes>\r\n
// ...
// 0\r\n
// [trailer-field\r\n]*
// \r\n
//
// Non-blocking: only processes bytes already in the TCP receive buffer.
// State (chunk_state_, chunk_remaining_) is preserved between calls, so partial
// chunk headers or split \r\n sequences resume correctly on the next call.
// Framing bytes (hex sizes, \r\n) may be consumed without producing output;
// the caller sees 0 and retries via the normal read timeout logic.
//
// WiFiClient::read() returns -1 on error despite available() > 0 (connection reset
// between check and read). On any stream error (c < 0 or readBytes <= 0), we return
// already-decoded data if any; otherwise HTTP_ERROR_CONNECTION_CLOSED. The error
// will surface again on the next call since the stream stays broken.
//
// Returns: > 0 decoded bytes, 0 no data available, < 0 error
int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream) {
int total_decoded = 0;
while (total_decoded < (int) max_len && this->chunk_state_ != ChunkedState::COMPLETE) {
// Non-blocking: only process what's already buffered
if (stream->available() == 0)
break;
// CHUNK_DATA reads multiple bytes; handle before the single-byte switch
if (this->chunk_state_ == ChunkedState::CHUNK_DATA) {
// Only read what's available, what fits in buf, and what remains in this chunk
size_t to_read =
std::min({max_len - (size_t) total_decoded, this->chunk_remaining_, (size_t) stream->available()});
if (to_read == 0)
break;
App.feed_wdt();
int read_len = stream->readBytes(buf + total_decoded, to_read);
if (read_len <= 0)
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
total_decoded += read_len;
this->chunk_remaining_ -= read_len;
this->bytes_read_ += read_len;
if (this->chunk_remaining_ == 0)
this->chunk_state_ = ChunkedState::CHUNK_DATA_TRAIL;
continue;
}
// All other states consume a single byte
int c = stream->read();
if (c < 0)
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
switch (this->chunk_state_) {
// Parse hex chunk size, one byte at a time: "<hex>[;ext]\r\n"
// Note: if no hex digits are parsed (e.g., bare \r\n), chunk_remaining_ stays 0
// and is treated as the final chunk. This is intentionally lenient — on embedded
// devices, rejecting malformed framing is less useful than terminating cleanly.
// Overflow of chunk_remaining_ from extremely long hex strings (>8 digits on
// 32-bit) is not checked; >4GB chunks are unrealistic on embedded targets and
// would simply cause fewer bytes to be read from that chunk.
case ChunkedState::CHUNK_HEADER:
if (c == '\n') {
// \n terminates the size line; chunk_remaining_ == 0 means last chunk
this->chunk_header_complete_();
} else {
uint8_t hex = parse_hex_char(c);
if (hex != INVALID_HEX_CHAR) {
this->chunk_remaining_ = (this->chunk_remaining_ << 4) | hex;
} else if (c != '\r') {
this->chunk_state_ = ChunkedState::CHUNK_HEADER_EXT; // ';' starts extension, skip to \n
}
}
break;
// Skip chunk extension bytes until \n (e.g., ";name=value\r\n")
case ChunkedState::CHUNK_HEADER_EXT:
if (c == '\n') {
this->chunk_header_complete_();
}
break;
// Consume \r\n trailing each chunk's data
case ChunkedState::CHUNK_DATA_TRAIL:
if (c == '\n') {
this->chunk_state_ = ChunkedState::CHUNK_HEADER;
this->chunk_remaining_ = 0; // reset for next chunk's hex accumulation
}
// else: \r is consumed silently, next iteration gets \n
break;
// Consume optional trailer headers and terminating empty line after final chunk.
// Per RFC 9112 Section 7.1: "0\r\n" is followed by optional "field\r\n" lines
// and a final "\r\n". chunk_remaining_ is repurposed as a flag: 1 = at start
// of line (may be the empty terminator), 0 = mid-line (reading a trailer field).
case ChunkedState::CHUNK_TRAILER:
if (c == '\n') {
if (this->chunk_remaining_ != 0) {
this->chunk_state_ = ChunkedState::COMPLETE; // Empty line terminates trailers
} else {
this->chunk_remaining_ = 1; // End of trailer field, at start of next line
}
} else if (c != '\r') {
this->chunk_remaining_ = 0; // Non-CRLF char: reading a trailer field
}
// \r doesn't change the flag — it's part of \r\n line endings
break;
default:
break;
}
if (this->chunk_state_ == ChunkedState::COMPLETE) {
// Clear chunked flag and set content_length to actual decoded size so
// is_read_complete() returns true and callers exit their read loops
this->is_chunked_ = false;
this->content_length = this->bytes_read_;
}
}
return total_decoded;
}
void HttpContainerArduino::end() {
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
this->client_.end();

View File

@@ -18,17 +18,6 @@
namespace esphome::http_request {
class HttpRequestArduino;
/// State machine for decoding chunked transfer encoding on Arduino
enum class ChunkedState : uint8_t {
CHUNK_HEADER, ///< Reading hex digits of chunk size
CHUNK_HEADER_EXT, ///< Skipping chunk extensions until \n
CHUNK_DATA, ///< Reading chunk data bytes
CHUNK_DATA_TRAIL, ///< Skipping \r\n after chunk data
CHUNK_TRAILER, ///< Consuming trailer headers after final 0-size chunk
COMPLETE, ///< Finished: final chunk and trailers consumed
};
class HttpContainerArduino : public HttpContainer {
public:
int read(uint8_t *buf, size_t max_len) override;
@@ -37,13 +26,6 @@ class HttpContainerArduino : public HttpContainer {
protected:
friend class HttpRequestArduino;
HTTPClient client_{};
/// Decode chunked transfer encoding from the raw stream
int read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream);
/// Transition from chunk header to data or trailer based on parsed size
void chunk_header_complete_();
ChunkedState chunk_state_{ChunkedState::CHUNK_HEADER};
size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk
};
class HttpRequestArduino : public HttpRequestComponent {

View File

@@ -133,10 +133,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY)
continue;
// For non-chunked responses, COMPLETE is unreachable (loop condition checks bytes_read < content_length).
// For chunked responses, the decoder sets content_length = bytes_read when the final chunk arrives,
// which causes the loop condition to terminate. But COMPLETE can still be returned if the decoder
// finishes mid-read, so this is needed for correctness.
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
// but this is defensive code in case chunked transfer encoding support is added for OTA in the future.
if (result == HttpReadLoopResult::COMPLETE)
break;
if (result != HttpReadLoopResult::DATA) {

View File

@@ -4,7 +4,6 @@
#include "light_state.h"
#include "esphome/core/log.h"
#include "esphome/core/optional.h"
#include "esphome/core/progmem.h"
namespace esphome::light {
@@ -52,13 +51,26 @@ static void log_invalid_parameter(const char *name, const LogString *message) {
return *this; \
}
// Color mode human-readable strings indexed by ColorModeBitPolicy::to_bit() (0-9)
// Index 0 is Unknown (for ColorMode::UNKNOWN), also used as fallback for out-of-range
PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature",
"Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white");
static const LogString *color_mode_to_human(ColorMode color_mode) {
return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0);
if (color_mode == ColorMode::ON_OFF)
return LOG_STR("On/Off");
if (color_mode == ColorMode::BRIGHTNESS)
return LOG_STR("Brightness");
if (color_mode == ColorMode::WHITE)
return LOG_STR("White");
if (color_mode == ColorMode::COLOR_TEMPERATURE)
return LOG_STR("Color temperature");
if (color_mode == ColorMode::COLD_WARM_WHITE)
return LOG_STR("Cold/warm white");
if (color_mode == ColorMode::RGB)
return LOG_STR("RGB");
if (color_mode == ColorMode::RGB_WHITE)
return LOG_STR("RGBW");
if (color_mode == ColorMode::RGB_COLD_WARM_WHITE)
return LOG_STR("RGB + cold/warm white");
if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE)
return LOG_STR("RGB + color temperature");
return LOG_STR("Unknown");
}
// Helper to log percentage values
@@ -445,52 +457,6 @@ ColorMode LightCall::compute_color_mode_() {
LOG_STR_ARG(color_mode_to_human(color_mode)));
return color_mode;
}
// PROGMEM lookup table for get_suitable_color_modes_mask_().
// Maps 4-bit key (white | ct<<1 | cwww<<2 | rgb<<3) to color mode bitmask.
// Packed into uint8_t by right-shifting by PACK_SHIFT since the lower bits
// (UNKNOWN, ON_OFF, BRIGHTNESS) are never present in suitable mode masks.
static constexpr unsigned PACK_SHIFT = ColorModeBitPolicy::to_bit(ColorMode::WHITE);
// clang-format off
static constexpr uint8_t SUITABLE_COLOR_MODES[] PROGMEM = {
// [0] none - all modes with brightness
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [1] white only
static_cast<uint8_t>(ColorModeMask({ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [2] ct only
static_cast<uint8_t>(ColorModeMask({ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [3] white + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [4] cwww only
static_cast<uint8_t>(ColorModeMask({ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
0, // [5] white + cwww (conflicting)
0, // [6] ct + cwww (conflicting)
0, // [7] white + ct + cwww (conflicting)
// [8] rgb only
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [9] rgb + white
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [10] rgb + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [11] rgb + white + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [12] rgb + cwww
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
0, // [13] rgb + white + cwww (conflicting)
0, // [14] rgb + ct + cwww (conflicting)
0, // [15] all (conflicting)
};
// clang-format on
color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
bool has_white = this->has_white() && this->white_ > 0.0f;
bool has_ct = this->has_color_temperature();
@@ -500,8 +466,46 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
(this->has_red() || this->has_green() || this->has_blue());
// Build key from flags: [rgb][cwww][ct][white]
uint8_t key = has_white | (has_ct << 1) | (has_cwww << 2) | (has_rgb << 3);
return static_cast<color_mode_bitmask_t>(progmem_read_byte(&SUITABLE_COLOR_MODES[key])) << PACK_SHIFT;
#define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3)
uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb);
switch (key) {
case KEY(true, false, false, false): // white only
return ColorModeMask({ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, false): // ct only
return ColorModeMask({ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(true, true, false, false): // white + ct
return ColorModeMask(
{ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, false, true, false): // cwww only
return ColorModeMask({ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, false): // none
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE,
ColorMode::RGB, ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE})
.get_mask();
case KEY(true, false, false, true): // rgb + white
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, true): // rgb + ct
case KEY(true, true, false, true): // rgb + white + ct
return ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, true, true): // rgb + cwww
return ColorModeMask({ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, true): // rgb only
return ColorModeMask({ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
default:
return 0; // conflicting flags
}
#undef KEY
}
LightCall &LightCall::set_effect(const char *effect, size_t len) {

View File

@@ -9,19 +9,32 @@ namespace esphome::light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
// Color mode JSON strings - packed into flash with compile-time generated offsets.
// Indexed by ColorModeBitPolicy bit index (1-9), so index 0 maps to bit 1 ("onoff").
PROGMEM_STRING_TABLE(ColorModeStrings, "onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct",
"rgbww");
// Get JSON string for color mode. Returns nullptr for UNKNOWN (bit 0).
// Returns ProgmemStr so ArduinoJson knows to handle PROGMEM strings on ESP8266.
// Get JSON string for color mode.
// ColorMode enum values are sparse bitmasks (0, 1, 3, 7, 11, 19, 35, 39, 47, 51) which would
// generate a large jump table. Converting to bit index (0-9) allows a compact switch.
static ProgmemStr get_color_mode_json_str(ColorMode mode) {
unsigned bit = ColorModeBitPolicy::to_bit(mode);
if (bit == 0)
return nullptr;
// bit is 1-9 for valid modes, so bit-1 is always valid (0-8). LAST_INDEX fallback never used.
return ColorModeStrings::get_progmem_str(bit - 1, ColorModeStrings::LAST_INDEX);
switch (ColorModeBitPolicy::to_bit(mode)) {
case 1:
return ESPHOME_F("onoff");
case 2:
return ESPHOME_F("brightness");
case 3:
return ESPHOME_F("white");
case 4:
return ESPHOME_F("color_temp");
case 5:
return ESPHOME_F("cwww");
case 6:
return ESPHOME_F("rgb");
case 7:
return ESPHOME_F("rgbw");
case 8:
return ESPHOME_F("rgbct");
case 9:
return ESPHOME_F("rgbww");
default:
return nullptr;
}
}
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {

View File

@@ -8,12 +8,22 @@ namespace esphome::lock {
static const char *const TAG = "lock";
// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING
// Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING");
const LogString *lock_state_to_string(LockState state) {
return LockStateStrings::get_log_str(static_cast<uint8_t>(state), 0);
switch (state) {
case LOCK_STATE_LOCKED:
return LOG_STR("LOCKED");
case LOCK_STATE_UNLOCKED:
return LOG_STR("UNLOCKED");
case LOCK_STATE_JAMMED:
return LOG_STR("JAMMED");
case LOCK_STATE_LOCKING:
return LOG_STR("LOCKING");
case LOCK_STATE_UNLOCKING:
return LOG_STR("UNLOCKING");
case LOCK_STATE_NONE:
default:
return LOG_STR("UNKNOWN");
}
}
Lock::Lock() : state(LOCK_STATE_NONE) {}

View File

@@ -4,7 +4,6 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::logger {
@@ -242,20 +241,34 @@ UARTSelection Logger::get_uart() const { return this->uart_; }
float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; }
// Log level strings - packed into flash on ESP8266, indexed by log level (0-7)
PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE");
#ifdef USE_STORE_LOG_STR_IN_FLASH
// ESP8266: PSTR() cannot be used in array initializers, so we need to declare
// each string separately as a global constant first
static const char LOG_LEVEL_NONE[] PROGMEM = "NONE";
static const char LOG_LEVEL_ERROR[] PROGMEM = "ERROR";
static const char LOG_LEVEL_WARN[] PROGMEM = "WARN";
static const char LOG_LEVEL_INFO[] PROGMEM = "INFO";
static const char LOG_LEVEL_CONFIG[] PROGMEM = "CONFIG";
static const char LOG_LEVEL_DEBUG[] PROGMEM = "DEBUG";
static const char LOG_LEVEL_VERBOSE[] PROGMEM = "VERBOSE";
static const char LOG_LEVEL_VERY_VERBOSE[] PROGMEM = "VERY_VERBOSE";
static const LogString *get_log_level_str(uint8_t level) {
return LogLevelStrings::get_log_str(level, LogLevelStrings::LAST_INDEX);
}
static const LogString *const LOG_LEVELS[] = {
reinterpret_cast<const LogString *>(LOG_LEVEL_NONE), reinterpret_cast<const LogString *>(LOG_LEVEL_ERROR),
reinterpret_cast<const LogString *>(LOG_LEVEL_WARN), reinterpret_cast<const LogString *>(LOG_LEVEL_INFO),
reinterpret_cast<const LogString *>(LOG_LEVEL_CONFIG), reinterpret_cast<const LogString *>(LOG_LEVEL_DEBUG),
reinterpret_cast<const LogString *>(LOG_LEVEL_VERBOSE), reinterpret_cast<const LogString *>(LOG_LEVEL_VERY_VERBOSE),
};
#else
static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"};
#endif
void Logger::dump_config() {
ESP_LOGCONFIG(TAG,
"Logger:\n"
" Max Level: %s\n"
" Initial Level: %s",
LOG_STR_ARG(get_log_level_str(ESPHOME_LOG_LEVEL)),
LOG_STR_ARG(get_log_level_str(this->current_level_)));
LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]), LOG_STR_ARG(LOG_LEVELS[this->current_level_]));
#ifndef USE_HOST
ESP_LOGCONFIG(TAG,
" Log Baud Rate: %" PRIu32 "\n"
@@ -274,7 +287,7 @@ void Logger::dump_config() {
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
for (auto &it : this->log_levels_) {
ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first, LOG_STR_ARG(get_log_level_str(it.second)));
ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first, LOG_STR_ARG(LOG_LEVELS[it.second]));
}
#endif
}
@@ -282,8 +295,7 @@ void Logger::dump_config() {
void Logger::set_log_level(uint8_t level) {
if (level > ESPHOME_LOG_LEVEL) {
level = ESPHOME_LOG_LEVEL;
ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s",
LOG_STR_ARG(get_log_level_str(ESPHOME_LOG_LEVEL)));
ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]));
}
this->current_level_ = level;
#ifdef USE_LOGGER_LEVEL_LISTENERS

View File

@@ -13,12 +13,31 @@ static const char *const TAG = "mqtt.alarm_control_panel";
using namespace esphome::alarm_control_panel;
// Alarm state MQTT strings indexed by AlarmControlPanelState enum (0-9)
PROGMEM_STRING_TABLE(AlarmMqttStateStrings, "disarmed", "armed_home", "armed_away", "armed_night", "armed_vacation",
"armed_custom_bypass", "pending", "arming", "disarming", "triggered", "unknown");
static ProgmemStr alarm_state_to_mqtt_str(AlarmControlPanelState state) {
return AlarmMqttStateStrings::get_progmem_str(static_cast<uint8_t>(state), AlarmMqttStateStrings::LAST_INDEX);
switch (state) {
case ACP_STATE_DISARMED:
return ESPHOME_F("disarmed");
case ACP_STATE_ARMED_HOME:
return ESPHOME_F("armed_home");
case ACP_STATE_ARMED_AWAY:
return ESPHOME_F("armed_away");
case ACP_STATE_ARMED_NIGHT:
return ESPHOME_F("armed_night");
case ACP_STATE_ARMED_VACATION:
return ESPHOME_F("armed_vacation");
case ACP_STATE_ARMED_CUSTOM_BYPASS:
return ESPHOME_F("armed_custom_bypass");
case ACP_STATE_PENDING:
return ESPHOME_F("pending");
case ACP_STATE_ARMING:
return ESPHOME_F("arming");
case ACP_STATE_DISARMING:
return ESPHOME_F("disarming");
case ACP_STATE_TRIGGERED:
return ESPHOME_F("triggered");
default:
return ESPHOME_F("unknown");
}
}
MQTTAlarmControlPanelComponent::MQTTAlarmControlPanelComponent(AlarmControlPanel *alarm_control_panel)

View File

@@ -8,7 +8,6 @@
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/version.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -28,11 +27,6 @@ namespace esphome::mqtt {
static const char *const TAG = "mqtt";
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
"Identifier Rejected", "Server Unavailable", "Malformed Credentials", "Not Authorized",
"Not Enough Space", "TLS Bad Fingerprint", "DNS Resolve Error", "Unknown");
MQTTClientComponent::MQTTClientComponent() {
global_mqtt_client = this;
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
@@ -354,8 +348,36 @@ void MQTTClientComponent::loop() {
mqtt_backend_.loop();
if (this->disconnect_reason_.has_value()) {
const LogString *reason_s = MQTTDisconnectReasonStrings::get_log_str(
static_cast<uint8_t>(*this->disconnect_reason_), MQTTDisconnectReasonStrings::LAST_INDEX);
const LogString *reason_s;
switch (*this->disconnect_reason_) {
case MQTTClientDisconnectReason::TCP_DISCONNECTED:
reason_s = LOG_STR("TCP disconnected");
break;
case MQTTClientDisconnectReason::MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
reason_s = LOG_STR("Unacceptable Protocol Version");
break;
case MQTTClientDisconnectReason::MQTT_IDENTIFIER_REJECTED:
reason_s = LOG_STR("Identifier Rejected");
break;
case MQTTClientDisconnectReason::MQTT_SERVER_UNAVAILABLE:
reason_s = LOG_STR("Server Unavailable");
break;
case MQTTClientDisconnectReason::MQTT_MALFORMED_CREDENTIALS:
reason_s = LOG_STR("Malformed Credentials");
break;
case MQTTClientDisconnectReason::MQTT_NOT_AUTHORIZED:
reason_s = LOG_STR("Not Authorized");
break;
case MQTTClientDisconnectReason::ESP8266_NOT_ENOUGH_SPACE:
reason_s = LOG_STR("Not Enough Space");
break;
case MQTTClientDisconnectReason::TLS_BAD_FINGERPRINT:
reason_s = LOG_STR("TLS Bad Fingerprint");
break;
default:
reason_s = LOG_STR("Unknown");
break;
}
if (!network::is_connected()) {
reason_s = LOG_STR("WiFi disconnected");
}

View File

@@ -13,44 +13,109 @@ static const char *const TAG = "mqtt.climate";
using namespace esphome::climate;
// Climate mode MQTT strings indexed by ClimateMode enum (0-6): OFF, HEAT_COOL, COOL, HEAT, FAN_ONLY, DRY, AUTO
PROGMEM_STRING_TABLE(ClimateMqttModeStrings, "off", "heat_cool", "cool", "heat", "fan_only", "dry", "auto", "unknown");
static ProgmemStr climate_mode_to_mqtt_str(ClimateMode mode) {
return ClimateMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), ClimateMqttModeStrings::LAST_INDEX);
switch (mode) {
case CLIMATE_MODE_OFF:
return ESPHOME_F("off");
case CLIMATE_MODE_HEAT_COOL:
return ESPHOME_F("heat_cool");
case CLIMATE_MODE_AUTO:
return ESPHOME_F("auto");
case CLIMATE_MODE_COOL:
return ESPHOME_F("cool");
case CLIMATE_MODE_HEAT:
return ESPHOME_F("heat");
case CLIMATE_MODE_FAN_ONLY:
return ESPHOME_F("fan_only");
case CLIMATE_MODE_DRY:
return ESPHOME_F("dry");
default:
return ESPHOME_F("unknown");
}
}
// Climate action MQTT strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
PROGMEM_STRING_TABLE(ClimateMqttActionStrings, "off", "unknown", "cooling", "heating", "idle", "drying", "fan",
"unknown");
static ProgmemStr climate_action_to_mqtt_str(ClimateAction action) {
return ClimateMqttActionStrings::get_progmem_str(static_cast<uint8_t>(action), ClimateMqttActionStrings::LAST_INDEX);
switch (action) {
case CLIMATE_ACTION_OFF:
return ESPHOME_F("off");
case CLIMATE_ACTION_COOLING:
return ESPHOME_F("cooling");
case CLIMATE_ACTION_HEATING:
return ESPHOME_F("heating");
case CLIMATE_ACTION_IDLE:
return ESPHOME_F("idle");
case CLIMATE_ACTION_DRYING:
return ESPHOME_F("drying");
case CLIMATE_ACTION_FAN:
return ESPHOME_F("fan");
default:
return ESPHOME_F("unknown");
}
}
// Climate fan mode MQTT strings indexed by ClimateFanMode enum (0-9)
PROGMEM_STRING_TABLE(ClimateMqttFanModeStrings, "on", "off", "auto", "low", "medium", "high", "middle", "focus",
"diffuse", "quiet", "unknown");
static ProgmemStr climate_fan_mode_to_mqtt_str(ClimateFanMode fan_mode) {
return ClimateMqttFanModeStrings::get_progmem_str(static_cast<uint8_t>(fan_mode),
ClimateMqttFanModeStrings::LAST_INDEX);
switch (fan_mode) {
case CLIMATE_FAN_ON:
return ESPHOME_F("on");
case CLIMATE_FAN_OFF:
return ESPHOME_F("off");
case CLIMATE_FAN_AUTO:
return ESPHOME_F("auto");
case CLIMATE_FAN_LOW:
return ESPHOME_F("low");
case CLIMATE_FAN_MEDIUM:
return ESPHOME_F("medium");
case CLIMATE_FAN_HIGH:
return ESPHOME_F("high");
case CLIMATE_FAN_MIDDLE:
return ESPHOME_F("middle");
case CLIMATE_FAN_FOCUS:
return ESPHOME_F("focus");
case CLIMATE_FAN_DIFFUSE:
return ESPHOME_F("diffuse");
case CLIMATE_FAN_QUIET:
return ESPHOME_F("quiet");
default:
return ESPHOME_F("unknown");
}
}
// Climate swing mode MQTT strings indexed by ClimateSwingMode enum (0-3): OFF, BOTH, VERTICAL, HORIZONTAL
PROGMEM_STRING_TABLE(ClimateMqttSwingModeStrings, "off", "both", "vertical", "horizontal", "unknown");
static ProgmemStr climate_swing_mode_to_mqtt_str(ClimateSwingMode swing_mode) {
return ClimateMqttSwingModeStrings::get_progmem_str(static_cast<uint8_t>(swing_mode),
ClimateMqttSwingModeStrings::LAST_INDEX);
switch (swing_mode) {
case CLIMATE_SWING_OFF:
return ESPHOME_F("off");
case CLIMATE_SWING_BOTH:
return ESPHOME_F("both");
case CLIMATE_SWING_VERTICAL:
return ESPHOME_F("vertical");
case CLIMATE_SWING_HORIZONTAL:
return ESPHOME_F("horizontal");
default:
return ESPHOME_F("unknown");
}
}
// Climate preset MQTT strings indexed by ClimatePreset enum (0-7)
PROGMEM_STRING_TABLE(ClimateMqttPresetStrings, "none", "home", "away", "boost", "comfort", "eco", "sleep", "activity",
"unknown");
static ProgmemStr climate_preset_to_mqtt_str(ClimatePreset preset) {
return ClimateMqttPresetStrings::get_progmem_str(static_cast<uint8_t>(preset), ClimateMqttPresetStrings::LAST_INDEX);
switch (preset) {
case CLIMATE_PRESET_NONE:
return ESPHOME_F("none");
case CLIMATE_PRESET_HOME:
return ESPHOME_F("home");
case CLIMATE_PRESET_ECO:
return ESPHOME_F("eco");
case CLIMATE_PRESET_AWAY:
return ESPHOME_F("away");
case CLIMATE_PRESET_BOOST:
return ESPHOME_F("boost");
case CLIMATE_PRESET_COMFORT:
return ESPHOME_F("comfort");
case CLIMATE_PRESET_SLEEP:
return ESPHOME_F("sleep");
case CLIMATE_PRESET_ACTIVITY:
return ESPHOME_F("activity");
default:
return ESPHOME_F("unknown");
}
}
void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {

View File

@@ -14,9 +14,6 @@ namespace esphome::mqtt {
static const char *const TAG = "mqtt.component";
// Entity category MQTT strings indexed by EntityCategory enum: NONE(0) is skipped, CONFIG(1), DIAGNOSTIC(2)
PROGMEM_STRING_TABLE(EntityCategoryMqttStrings, "", "config", "diagnostic");
// Helper functions for building topic strings on stack
inline char *append_str(char *p, const char *s, size_t len) {
memcpy(p, s, len);
@@ -216,9 +213,13 @@ bool MQTTComponent::send_discovery_() {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
const auto entity_category = this->get_entity()->get_entity_category();
if (entity_category != ENTITY_CATEGORY_NONE) {
root[MQTT_ENTITY_CATEGORY] = EntityCategoryMqttStrings::get_progmem_str(
static_cast<uint8_t>(entity_category), static_cast<uint8_t>(ENTITY_CATEGORY_CONFIG));
switch (entity_category) {
case ENTITY_CATEGORY_NONE:
break;
case ENTITY_CATEGORY_CONFIG:
case ENTITY_CATEGORY_DIAGNOSTIC:
root[MQTT_ENTITY_CATEGORY] = entity_category == ENTITY_CATEGORY_CONFIG ? "config" : "diagnostic";
break;
}
if (config.state_topic) {

View File

@@ -1,6 +1,5 @@
#include "mqtt_number.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -13,9 +12,6 @@ static const char *const TAG = "mqtt.number";
using namespace esphome::number;
// Number mode MQTT strings indexed by NumberMode enum: AUTO(0) is skipped, BOX(1), SLIDER(2)
PROGMEM_STRING_TABLE(NumberMqttModeStrings, "", "box", "slider");
MQTTNumberComponent::MQTTNumberComponent(Number *number) : number_(number) {}
void MQTTNumberComponent::setup() {
@@ -52,10 +48,15 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon
if (!unit_of_measurement.empty()) {
root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement;
}
const auto mode = this->number_->traits.get_mode();
if (mode != NUMBER_MODE_AUTO) {
root[MQTT_MODE] =
NumberMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), static_cast<uint8_t>(NUMBER_MODE_BOX));
switch (this->number_->traits.get_mode()) {
case NUMBER_MODE_AUTO:
break;
case NUMBER_MODE_BOX:
root[MQTT_MODE] = "box";
break;
case NUMBER_MODE_SLIDER:
root[MQTT_MODE] = "slider";
break;
}
const auto device_class = this->number_->traits.get_device_class_ref();
if (!device_class.empty()) {

View File

@@ -1,6 +1,5 @@
#include "mqtt_text.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -13,9 +12,6 @@ static const char *const TAG = "mqtt.text";
using namespace esphome::text;
// Text mode MQTT strings indexed by TextMode enum (0-1): TEXT, PASSWORD
PROGMEM_STRING_TABLE(TextMqttModeStrings, "text", "password");
MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {}
void MQTTTextComponent::setup() {
@@ -38,8 +34,14 @@ const EntityBase *MQTTTextComponent::get_entity() const { return this->text_; }
void MQTTTextComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root[MQTT_MODE] = TextMqttModeStrings::get_progmem_str(static_cast<uint8_t>(this->text_->traits.get_mode()),
static_cast<uint8_t>(TEXT_MODE_TEXT));
switch (this->text_->traits.get_mode()) {
case TEXT_MODE_TEXT:
root[MQTT_MODE] = "text";
break;
case TEXT_MODE_PASSWORD:
root[MQTT_MODE] = "password";
break;
}
config.command_topic = true;
}

View File

@@ -2,7 +2,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::sensor {
@@ -31,13 +30,20 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o
}
}
// State class strings indexed by StateClass enum (0-4): NONE, MEASUREMENT, TOTAL_INCREASING, TOTAL, MEASUREMENT_ANGLE
PROGMEM_STRING_TABLE(StateClassStrings, "", "measurement", "total_increasing", "total", "measurement_angle");
static_assert(StateClassStrings::COUNT == STATE_CLASS_LAST + 1, "StateClassStrings must match StateClass enum");
const LogString *state_class_to_string(StateClass state_class) {
// Fallback to index 0 (empty string for STATE_CLASS_NONE) if out of range
return StateClassStrings::get_log_str(static_cast<uint8_t>(state_class), 0);
switch (state_class) {
case STATE_CLASS_MEASUREMENT:
return LOG_STR("measurement");
case STATE_CLASS_TOTAL_INCREASING:
return LOG_STR("total_increasing");
case STATE_CLASS_TOTAL:
return LOG_STR("total");
case STATE_CLASS_MEASUREMENT_ANGLE:
return LOG_STR("measurement_angle");
case STATE_CLASS_NONE:
default:
return LOG_STR("");
}
}
Sensor::Sensor() : state(NAN), raw_state(NAN) {}

View File

@@ -32,7 +32,6 @@ enum StateClass : uint8_t {
STATE_CLASS_TOTAL = 3,
STATE_CLASS_MEASUREMENT_ANGLE = 4
};
constexpr uint8_t STATE_CLASS_LAST = static_cast<uint8_t>(STATE_CLASS_MEASUREMENT_ANGLE);
const LogString *state_class_to_string(StateClass state_class);

View File

@@ -5,7 +5,6 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::template_ {
@@ -29,11 +28,18 @@ void TemplateAlarmControlPanel::add_sensor(binary_sensor::BinarySensor *sensor,
this->sensor_data_.push_back(sd);
};
// Alarm sensor type strings indexed by AlarmSensorType enum (0-3): DELAYED, INSTANT, DELAYED_FOLLOWER, INSTANT_ALWAYS
PROGMEM_STRING_TABLE(AlarmSensorTypeStrings, "delayed", "instant", "delayed_follower", "instant_always");
static const LogString *sensor_type_to_string(AlarmSensorType type) {
return AlarmSensorTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
switch (type) {
case ALARM_SENSOR_TYPE_INSTANT:
return LOG_STR("instant");
case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER:
return LOG_STR("delayed_follower");
case ALARM_SENSOR_TYPE_INSTANT_ALWAYS:
return LOG_STR("instant_always");
case ALARM_SENSOR_TYPE_DELAYED:
default:
return LOG_STR("delayed");
}
}
#endif

View File

@@ -26,7 +26,7 @@ enum BinarySensorFlags : uint16_t {
BINARY_SENSOR_MODE_BYPASS_AUTO = 1 << 4,
};
enum AlarmSensorType : uint8_t {
enum AlarmSensorType : uint16_t {
ALARM_SENSOR_TYPE_DELAYED = 0,
ALARM_SENSOR_TYPE_INSTANT,
ALARM_SENSOR_TYPE_DELAYED_FOLLOWER,

View File

@@ -2,21 +2,12 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace update {
static const char *const TAG = "update";
// Update state strings indexed by UpdateState enum (0-3): UNKNOWN, NO UPDATE, UPDATE AVAILABLE, INSTALLING
PROGMEM_STRING_TABLE(UpdateStateStrings, "UNKNOWN", "NO UPDATE", "UPDATE AVAILABLE", "INSTALLING");
const LogString *update_state_to_string(UpdateState state) {
return UpdateStateStrings::get_log_str(static_cast<uint8_t>(state),
static_cast<uint8_t>(UpdateState::UPDATE_STATE_UNKNOWN));
}
void UpdateEntity::publish_state() {
ESP_LOGD(TAG,
"'%s' >>\n"

View File

@@ -27,8 +27,6 @@ enum UpdateState : uint8_t {
UPDATE_STATE_INSTALLING,
};
const LogString *update_state_to_string(UpdateState state);
class UpdateEntity : public EntityBase, public EntityBase_DeviceClass {
public:
void publish_state();

View File

@@ -23,11 +23,17 @@ const LogString *valve_command_to_str(float pos) {
return LOG_STR("UNKNOWN");
}
}
// Valve operation strings indexed by ValveOperation enum (0-2): IDLE, OPENING, CLOSING, plus UNKNOWN
PROGMEM_STRING_TABLE(ValveOperationStrings, "IDLE", "OPENING", "CLOSING", "UNKNOWN");
const LogString *valve_operation_to_str(ValveOperation op) {
return ValveOperationStrings::get_log_str(static_cast<uint8_t>(op), ValveOperationStrings::LAST_INDEX);
switch (op) {
case VALVE_OPERATION_IDLE:
return LOG_STR("IDLE");
case VALVE_OPERATION_OPENING:
return LOG_STR("OPENING");
case VALVE_OPERATION_CLOSING:
return LOG_STR("CLOSING");
default:
return LOG_STR("UNKNOWN");
}
}
Valve::Valve() : position{VALVE_OPEN} {}

View File

@@ -83,7 +83,7 @@ struct Timer {
}
// Remove before 2026.8.0
ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0")
std::string to_string() const {
std::string to_string() const { // NOLINT
char buffer[TO_STR_BUFFER_SIZE];
return this->to_str(buffer);
}

View File

@@ -233,13 +233,25 @@ void WaterHeater::set_visual_target_temperature_step_override(float visual_targe
}
#endif
// Water heater mode strings indexed by WaterHeaterMode enum (0-6): OFF, ECO, ELECTRIC, PERFORMANCE, HIGH_DEMAND,
// HEAT_PUMP, GAS
PROGMEM_STRING_TABLE(WaterHeaterModeStrings, "OFF", "ECO", "ELECTRIC", "PERFORMANCE", "HIGH_DEMAND", "HEAT_PUMP", "GAS",
"UNKNOWN");
const LogString *water_heater_mode_to_string(WaterHeaterMode mode) {
return WaterHeaterModeStrings::get_log_str(static_cast<uint8_t>(mode), WaterHeaterModeStrings::LAST_INDEX);
switch (mode) {
case WATER_HEATER_MODE_OFF:
return LOG_STR("OFF");
case WATER_HEATER_MODE_ECO:
return LOG_STR("ECO");
case WATER_HEATER_MODE_ELECTRIC:
return LOG_STR("ELECTRIC");
case WATER_HEATER_MODE_PERFORMANCE:
return LOG_STR("PERFORMANCE");
case WATER_HEATER_MODE_HIGH_DEMAND:
return LOG_STR("HIGH_DEMAND");
case WATER_HEATER_MODE_HEAT_PUMP:
return LOG_STR("HEAT_PUMP");
case WATER_HEATER_MODE_GAS:
return LOG_STR("GAS");
default:
return LOG_STR("UNKNOWN");
}
}
void WaterHeater::dump_traits_(const char *tag) {

View File

@@ -29,10 +29,6 @@
#include "esphome/components/climate/climate.h"
#endif
#ifdef USE_UPDATE
#include "esphome/components/update/update_entity.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -2108,6 +2104,19 @@ std::string WebServer::event_json_(event::Event *obj, StringRef event_type, Json
#endif
#ifdef USE_UPDATE
static const LogString *update_state_to_string(update::UpdateState state) {
switch (state) {
case update::UPDATE_STATE_NO_UPDATE:
return LOG_STR("NO UPDATE");
case update::UPDATE_STATE_AVAILABLE:
return LOG_STR("UPDATE AVAILABLE");
case update::UPDATE_STATE_INSTALLING:
return LOG_STR("INSTALLING");
default:
return LOG_STR("UNKNOWN");
}
}
void WebServer::on_update(update::UpdateEntity *obj) {
this->events_.deferrable_send_state(obj, "state", update_state_json_generator);
}
@@ -2149,7 +2158,7 @@ std::string WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version;

View File

@@ -236,23 +236,25 @@ static const char *const TAG = "wifi";
/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │
/// └──────────────────────────────────────────────────────────────────────┘
// 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)
return LOG_STR("INITIAL_CONNECT");
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
return LOG_STR("INITIAL_CONNECT");
#ifdef USE_WIFI_FAST_CONNECT
if (phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS)
return LOG_STR("FAST_CONNECT_CYCLING");
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
return LOG_STR("FAST_CONNECT_CYCLING");
#endif
if (phase == WiFiRetryPhase::EXPLICIT_HIDDEN)
return LOG_STR("EXPLICIT_HIDDEN");
if (phase == WiFiRetryPhase::SCAN_CONNECTING)
return LOG_STR("SCAN_CONNECTING");
if (phase == WiFiRetryPhase::RETRY_HIDDEN)
return LOG_STR("RETRY_HIDDEN");
if (phase == WiFiRetryPhase::RESTARTING_ADAPTER)
return LOG_STR("RESTARTING");
return LOG_STR("UNKNOWN");
case WiFiRetryPhase::EXPLICIT_HIDDEN:
return LOG_STR("EXPLICIT_HIDDEN");
case WiFiRetryPhase::SCAN_CONNECTING:
return LOG_STR("SCAN_CONNECTING");
case WiFiRetryPhase::RETRY_HIDDEN:
return LOG_STR("RETRY_HIDDEN");
case WiFiRetryPhase::RESTARTING_ADAPTER:
return LOG_STR("RESTARTING");
default:
return LOG_STR("UNKNOWN");
}
}
bool WiFiComponent::went_through_explicit_hidden_phase_() const {
@@ -1468,14 +1470,6 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->notify_connect_state_listeners_();
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
// On ESP8266, GOT_IP event may not fire for static IP configurations,
// so notify IP state listeners here as a fallback.
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
this->notify_ip_state_listeners_();
}
#endif
return;
}
@@ -1487,11 +1481,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
}
if (this->error_from_callback_) {
// ESP8266: logging done in callback, listeners deferred via pending_.disconnect
// Other platforms: just log generic failure message
#ifndef USE_ESP8266
ESP_LOGW(TAG, "Connecting to network failed (callback)");
#endif
this->retry_connect();
return;
}
@@ -2212,31 +2202,8 @@ void WiFiComponent::notify_connect_state_listeners_() {
listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
}
}
void WiFiComponent::notify_disconnect_state_listeners_() {
constexpr uint8_t empty_bssid[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), empty_bssid);
}
}
#endif // USE_WIFI_CONNECT_STATE_LISTENERS
#ifdef USE_WIFI_IP_STATE_LISTENERS
void WiFiComponent::notify_ip_state_listeners_() {
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif // USE_WIFI_IP_STATE_LISTENERS
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::notify_scan_results_listeners_() {
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
}
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_();

View File

@@ -594,9 +594,6 @@ class WiFiComponent : public Component {
void connect_soon_();
void wifi_loop_();
#ifdef USE_ESP8266
void process_pending_callbacks_();
#endif
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
bool wifi_sta_pre_setup_();
bool wifi_apply_output_power_(float output_power);
@@ -638,16 +635,6 @@ class WiFiComponent : public Component {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
/// Notify connect state listeners (called after state machine reaches STA_CONNECTED)
void notify_connect_state_listeners_();
/// Notify connect state listeners of disconnection
void notify_disconnect_state_listeners_();
#endif
#ifdef USE_WIFI_IP_STATE_LISTENERS
/// Notify IP state listeners with current addresses
void notify_ip_state_listeners_();
#endif
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
/// Notify scan results listeners with current scan results
void notify_scan_results_listeners_();
#endif
#ifdef USE_ESP8266
@@ -671,13 +658,13 @@ class WiFiComponent : public Component {
void wifi_scan_done_callback_();
#endif
// Large/pointer-aligned members first
FixedVector<WiFiAP> sta_;
std::vector<WiFiSTAPriority> sta_priorities_;
wifi_scan_vector_t<WiFiScanResult> scan_result_;
#ifdef USE_WIFI_AP
WiFiAP ap_;
#endif
float output_power_{NAN};
#ifdef USE_WIFI_IP_STATE_LISTENERS
StaticVector<WiFiIPStateListener *, ESPHOME_WIFI_IP_STATE_LISTENERS> ip_state_listeners_;
#endif
@@ -694,15 +681,6 @@ class WiFiComponent : public Component {
#ifdef USE_WIFI_FAST_CONNECT
ESPPreferenceObject fast_connect_pref_;
#endif
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> connect_trigger_;
#endif
#ifdef USE_WIFI_DISCONNECT_TRIGGER
Trigger<> disconnect_trigger_;
#endif
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif
// Post-connect roaming constants
static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -710,8 +688,7 @@ class WiFiComponent : public Component {
static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent
static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3;
// 4-byte members
float output_power_{NAN};
// Group all 32-bit integers together
uint32_t action_started_;
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
@@ -720,7 +697,7 @@ class WiFiComponent : public Component {
uint32_t ap_timeout_{};
#endif
// 1-byte enums and integers
// Group all 8-bit values together
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2};
@@ -731,39 +708,17 @@ class WiFiComponent : public Component {
// int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS)
int8_t selected_sta_index_{-1};
uint8_t roaming_attempts_{0};
#if USE_NETWORK_IPV6
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
bool error_from_callback_{false};
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
RoamingState roaming_state_{RoamingState::IDLE};
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
#endif
// Bools and bitfields
// Pending listener callbacks deferred from platform callbacks to main loop.
struct {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Deferred until state machine reaches STA_CONNECTED so wifi.connected
// condition returns true in listener automations.
bool connect_state : 1;
#ifdef USE_ESP8266
// ESP8266: also defer disconnect notification to main loop
bool disconnect : 1;
#endif
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS)
bool got_ip : 1;
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_SCAN_RESULTS_LISTENERS)
bool scan_complete : 1;
#endif
} pending_{};
// Group all boolean values together
bool has_ap_{false};
#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
bool handled_connected_state_{false};
#endif
bool error_from_callback_{false};
bool scan_done_{false};
bool ap_setup_{false};
bool ap_started_{false};
@@ -778,10 +733,32 @@ class WiFiComponent : public Component {
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{
false}; // Tracks if we've completed a scan after captive portal started
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
bool skip_cooldown_next_cycle_{false};
bool post_connect_roaming_{true}; // Enabled by default
RoamingState roaming_state_{RoamingState::IDLE};
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
bool is_high_performance_mode_{false};
SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Pending listener notifications deferred until state machine reaches appropriate state.
// Listeners are notified after state transitions complete so conditions like
// wifi.connected return correct values in automations.
// Uses bitfields to minimize memory; more flags may be added as needed.
struct {
bool connect_state : 1; // Notify connect state listeners after STA_CONNECTED
} pending_{};
#endif
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> connect_trigger_;
#endif
#ifdef USE_WIFI_DISCONNECT_TRIGGER
Trigger<> disconnect_trigger_;
#endif
private:

View File

@@ -36,7 +36,6 @@ extern "C" {
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/util.h"
namespace esphome::wifi {
@@ -399,82 +398,106 @@ class WiFiMockClass : public ESP8266WiFiGenericClass {
static void _event_callback(void *event) { ESP8266WiFiGenericClass::_eventCallback(event); } // NOLINT
};
// Auth mode strings indexed by AUTH_* constants (0-4), with UNKNOWN at last index
// Static asserts verify the SDK constants are contiguous as expected
static_assert(AUTH_OPEN == 0 && AUTH_WEP == 1 && AUTH_WPA_PSK == 2 && AUTH_WPA2_PSK == 3 && AUTH_WPA_WPA2_PSK == 4,
"AUTH_* constants are not contiguous");
PROGMEM_STRING_TABLE(AuthModeStrings, "OPEN", "WEP", "WPA PSK", "WPA2 PSK", "WPA/WPA2 PSK", "UNKNOWN");
const LogString *get_auth_mode_str(uint8_t mode) {
return AuthModeStrings::get_log_str(mode, AuthModeStrings::LAST_INDEX);
switch (mode) {
case AUTH_OPEN:
return LOG_STR("OPEN");
case AUTH_WEP:
return LOG_STR("WEP");
case AUTH_WPA_PSK:
return LOG_STR("WPA PSK");
case AUTH_WPA2_PSK:
return LOG_STR("WPA2 PSK");
case AUTH_WPA_WPA2_PSK:
return LOG_STR("WPA/WPA2 PSK");
default:
return LOG_STR("UNKNOWN");
}
}
const LogString *get_op_mode_str(uint8_t mode) {
switch (mode) {
case WIFI_OFF:
return LOG_STR("OFF");
case WIFI_STA:
return LOG_STR("STA");
case WIFI_AP:
return LOG_STR("AP");
case WIFI_AP_STA:
return LOG_STR("AP+STA");
default:
return LOG_STR("UNKNOWN");
}
}
// WiFi op mode strings indexed by WIFI_* constants (0-3), with UNKNOWN at last index
static_assert(WIFI_OFF == 0 && WIFI_STA == 1 && WIFI_AP == 2 && WIFI_AP_STA == 3,
"WIFI_* op mode constants are not contiguous");
PROGMEM_STRING_TABLE(OpModeStrings, "OFF", "STA", "AP", "AP+STA", "UNKNOWN");
const LogString *get_op_mode_str(uint8_t mode) { return OpModeStrings::get_log_str(mode, OpModeStrings::LAST_INDEX); }
// Use if-chain instead of switch to avoid jump tables in RODATA (wastes RAM on ESP8266).
// A single switch would generate a sparse lookup table with ~175 default entries, wasting 700 bytes of RAM.
// Even split switches still generate smaller jump tables in RODATA.
const LogString *get_disconnect_reason_str(uint8_t reason) {
if (reason == REASON_AUTH_EXPIRE)
return LOG_STR("Auth Expired");
if (reason == REASON_AUTH_LEAVE)
return LOG_STR("Auth Leave");
if (reason == REASON_ASSOC_EXPIRE)
return LOG_STR("Association Expired");
if (reason == REASON_ASSOC_TOOMANY)
return LOG_STR("Too Many Associations");
if (reason == REASON_NOT_AUTHED)
return LOG_STR("Not Authenticated");
if (reason == REASON_NOT_ASSOCED)
return LOG_STR("Not Associated");
if (reason == REASON_ASSOC_LEAVE)
return LOG_STR("Association Leave");
if (reason == REASON_ASSOC_NOT_AUTHED)
return LOG_STR("Association not Authenticated");
if (reason == REASON_DISASSOC_PWRCAP_BAD)
return LOG_STR("Disassociate Power Cap Bad");
if (reason == REASON_DISASSOC_SUPCHAN_BAD)
return LOG_STR("Disassociate Supported Channel Bad");
if (reason == REASON_IE_INVALID)
return LOG_STR("IE Invalid");
if (reason == REASON_MIC_FAILURE)
return LOG_STR("Mic Failure");
if (reason == REASON_4WAY_HANDSHAKE_TIMEOUT)
return LOG_STR("4-Way Handshake Timeout");
if (reason == REASON_GROUP_KEY_UPDATE_TIMEOUT)
return LOG_STR("Group Key Update Timeout");
if (reason == REASON_IE_IN_4WAY_DIFFERS)
return LOG_STR("IE In 4-Way Handshake Differs");
if (reason == REASON_GROUP_CIPHER_INVALID)
return LOG_STR("Group Cipher Invalid");
if (reason == REASON_PAIRWISE_CIPHER_INVALID)
return LOG_STR("Pairwise Cipher Invalid");
if (reason == REASON_AKMP_INVALID)
return LOG_STR("AKMP Invalid");
if (reason == REASON_UNSUPP_RSN_IE_VERSION)
return LOG_STR("Unsupported RSN IE version");
if (reason == REASON_INVALID_RSN_IE_CAP)
return LOG_STR("Invalid RSN IE Cap");
if (reason == REASON_802_1X_AUTH_FAILED)
return LOG_STR("802.1x Authentication Failed");
if (reason == REASON_CIPHER_SUITE_REJECTED)
return LOG_STR("Cipher Suite Rejected");
if (reason == REASON_BEACON_TIMEOUT)
return LOG_STR("Beacon Timeout");
if (reason == REASON_NO_AP_FOUND)
return LOG_STR("AP Not Found");
if (reason == REASON_AUTH_FAIL)
return LOG_STR("Authentication Failed");
if (reason == REASON_ASSOC_FAIL)
return LOG_STR("Association Failed");
if (reason == REASON_HANDSHAKE_TIMEOUT)
return LOG_STR("Handshake Failed");
return LOG_STR("Unspecified");
/* If this were one big switch statement, GCC would generate a lookup table for it. However, the values of the
* REASON_* constants aren't continuous, and GCC will fill in the gap with the default value -- wasting 4 bytes of RAM
* per entry. As there's ~175 default entries, this wastes 700 bytes of RAM.
*/
if (reason <= REASON_CIPHER_SUITE_REJECTED) { // This must be the last constant with a value <200
switch (reason) {
case REASON_AUTH_EXPIRE:
return LOG_STR("Auth Expired");
case REASON_AUTH_LEAVE:
return LOG_STR("Auth Leave");
case REASON_ASSOC_EXPIRE:
return LOG_STR("Association Expired");
case REASON_ASSOC_TOOMANY:
return LOG_STR("Too Many Associations");
case REASON_NOT_AUTHED:
return LOG_STR("Not Authenticated");
case REASON_NOT_ASSOCED:
return LOG_STR("Not Associated");
case REASON_ASSOC_LEAVE:
return LOG_STR("Association Leave");
case REASON_ASSOC_NOT_AUTHED:
return LOG_STR("Association not Authenticated");
case REASON_DISASSOC_PWRCAP_BAD:
return LOG_STR("Disassociate Power Cap Bad");
case REASON_DISASSOC_SUPCHAN_BAD:
return LOG_STR("Disassociate Supported Channel Bad");
case REASON_IE_INVALID:
return LOG_STR("IE Invalid");
case REASON_MIC_FAILURE:
return LOG_STR("Mic Failure");
case REASON_4WAY_HANDSHAKE_TIMEOUT:
return LOG_STR("4-Way Handshake Timeout");
case REASON_GROUP_KEY_UPDATE_TIMEOUT:
return LOG_STR("Group Key Update Timeout");
case REASON_IE_IN_4WAY_DIFFERS:
return LOG_STR("IE In 4-Way Handshake Differs");
case REASON_GROUP_CIPHER_INVALID:
return LOG_STR("Group Cipher Invalid");
case REASON_PAIRWISE_CIPHER_INVALID:
return LOG_STR("Pairwise Cipher Invalid");
case REASON_AKMP_INVALID:
return LOG_STR("AKMP Invalid");
case REASON_UNSUPP_RSN_IE_VERSION:
return LOG_STR("Unsupported RSN IE version");
case REASON_INVALID_RSN_IE_CAP:
return LOG_STR("Invalid RSN IE Cap");
case REASON_802_1X_AUTH_FAILED:
return LOG_STR("802.1x Authentication Failed");
case REASON_CIPHER_SUITE_REJECTED:
return LOG_STR("Cipher Suite Rejected");
}
}
switch (reason) {
case REASON_BEACON_TIMEOUT:
return LOG_STR("Beacon Timeout");
case REASON_NO_AP_FOUND:
return LOG_STR("AP Not Found");
case REASON_AUTH_FAIL:
return LOG_STR("Authentication Failed");
case REASON_ASSOC_FAIL:
return LOG_STR("Association Failed");
case REASON_HANDSHAKE_TIMEOUT:
return LOG_STR("Handshake Failed");
case REASON_UNSPECIFIED:
default:
return LOG_STR("Unspecified");
}
}
// TODO: This callback runs in ESP8266 system context with limited stack (~2KB).
@@ -496,6 +519,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// Defer listener notification until state machine reaches STA_CONNECTED
// This ensures wifi.connected condition returns true in listener automations
global_wifi_component->pending_.connect_state = true;
#endif
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = global_wifi_component->get_selected_sta_();
config && config->get_manual_ip().has_value()) {
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(),
global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1));
}
}
#endif
break;
}
@@ -514,9 +547,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
}
s_sta_connected = false;
s_sta_connecting = false;
// IMPORTANT: Set error flag BEFORE notifying listeners.
// This ensures is_connected() returns false during listener callbacks,
// which is critical for proper reconnection logic (e.g., roaming).
global_wifi_component->error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
global_wifi_component->pending_.disconnect = true;
// Notify listeners AFTER setting error flag so they see correct state
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
break;
}
@@ -528,6 +568,8 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors
if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) {
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
// we can't call retry_connect() from this context, so disconnect immediately
// and notify main thread with error_from_callback_
wifi_station_disconnect();
global_wifi_component->error_from_callback_ = true;
}
@@ -541,8 +583,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
network::IPAddress(&it.gw).str_to(gw_buf), network::IPAddress(&it.mask).str_to(mask_buf));
s_sta_got_ip = true;
#ifdef USE_WIFI_IP_STATE_LISTENERS
// Defer listener callbacks to main loop - system context has limited stack
global_wifi_component->pending_.got_ip = true;
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0),
global_wifi_component->get_dns_address(1));
}
#endif
break;
}
@@ -635,15 +679,21 @@ void WiFiComponent::wifi_pre_setup_() {
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
station_status_t status = wifi_station_get_connect_status();
if (status == STATION_GOT_IP)
return WiFiSTAConnectStatus::CONNECTED;
if (status == STATION_NO_AP_FOUND)
return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND;
if (status == STATION_CONNECT_FAIL || status == STATION_WRONG_PASSWORD)
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
if (status == STATION_CONNECTING)
return WiFiSTAConnectStatus::CONNECTING;
return WiFiSTAConnectStatus::IDLE;
switch (status) {
case STATION_GOT_IP:
return WiFiSTAConnectStatus::CONNECTED;
case STATION_NO_AP_FOUND:
return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND;
;
case STATION_CONNECT_FAIL:
case STATION_WRONG_PASSWORD:
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
case STATION_CONNECTING:
return WiFiSTAConnectStatus::CONNECTING;
case STATION_IDLE:
default:
return WiFiSTAConnectStatus::IDLE;
}
}
bool WiFiComponent::wifi_scan_start_(bool passive) {
static bool first_scan = false;
@@ -748,7 +798,9 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
needs_full ? "" : " (filtered)");
this->scan_done_ = true;
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->pending_.scan_complete = true; // Defer listener callbacks to main loop
for (auto *listener : global_wifi_component->scan_results_listeners_) {
listener->on_wifi_scan_results(global_wifi_component->scan_result_);
}
#endif
}
@@ -935,34 +987,7 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() {
return network::IPAddress(&ip.gw);
}
network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); }
void WiFiComponent::wifi_loop_() { this->process_pending_callbacks_(); }
void WiFiComponent::process_pending_callbacks_() {
// Process callbacks deferred from ESP8266 SDK system context (~2KB stack)
// to main loop context (full stack). Connect state listeners are handled
// by notify_connect_state_listeners_() in the shared state machine code.
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
if (this->pending_.disconnect) {
this->pending_.disconnect = false;
this->notify_disconnect_state_listeners_();
}
#endif
#ifdef USE_WIFI_IP_STATE_LISTENERS
if (this->pending_.got_ip) {
this->pending_.got_ip = false;
this->notify_ip_state_listeners_();
}
#endif
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
if (this->pending_.scan_complete) {
this->pending_.scan_complete = false;
this->notify_scan_results_listeners_();
}
#endif
}
void WiFiComponent::wifi_loop_() {}
} // namespace esphome::wifi
#endif

View File

@@ -753,7 +753,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
@@ -777,7 +779,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
s_sta_connecting = false;
error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) {
@@ -788,7 +793,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw));
this->got_ipv4_address_ = true;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#if USE_NETWORK_IPV6
@@ -797,7 +804,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip));
this->num_ipv6_addresses_++;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#endif /* USE_NETWORK_IPV6 */
@@ -874,7 +883,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {

View File

@@ -468,7 +468,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
s_sta_state = LTWiFiSTAState::CONNECTED;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
#endif
@@ -525,7 +527,10 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
}
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
break;
}
@@ -548,14 +553,18 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
network::IPAddress(WiFi.gatewayIP()).str_to(gw_buf));
s_sta_state = LTWiFiSTAState::CONNECTED;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: {
ESP_LOGV(TAG, "Got IPv6");
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
@@ -699,7 +708,9 @@ void WiFiComponent::wifi_scan_done_callback_() {
needs_full ? "" : " (filtered)");
WiFi.scanDelete();
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}

View File

@@ -264,7 +264,9 @@ void WiFiComponent::wifi_loop_() {
ESP_LOGV(TAG, "Scan complete: %zu found, %zu stored%s", s_scan_result_count, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}
@@ -288,7 +290,9 @@ void WiFiComponent::wifi_loop_() {
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
s_sta_had_ip = true;
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
} else if (!is_connected && s_sta_was_connected) {
@@ -297,7 +301,10 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = false;
ESP_LOGV(TAG, "Disconnected");
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
}
@@ -315,7 +322,9 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = true;
ESP_LOGV(TAG, "Got IP address");
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
}

View File

@@ -1403,17 +1403,6 @@ def requires_component(comp):
return validator
def conflicts_with_component(comp):
"""Validate that this option cannot be specified when the component `comp` is loaded."""
def validator(value):
if comp in CORE.loaded_integrations:
raise Invalid(f"This option is not compatible with component {comp}")
return value
return validator
uint8_t = int_range(min=0, max=255)
uint16_t = int_range(min=0, max=65535)
uint32_t = int_range(min=0, max=4294967295)

View File

@@ -295,7 +295,7 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {
size_t chars = std::min(length, 2 * count);
for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) {
uint8_t val = parse_hex_char(*str);
if (val == INVALID_HEX_CHAR)
if (val > 15)
return 0;
data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4;
}

View File

@@ -874,9 +874,6 @@ template<typename T, enable_if_t<std::is_unsigned<T>::value, int> = 0> optional<
}
/// Parse a hex character to its nibble value (0-15), returns 255 on invalid input
/// Returned by parse_hex_char() for non-hex characters.
static constexpr uint8_t INVALID_HEX_CHAR = 255;
constexpr uint8_t parse_hex_char(char c) {
if (c >= '0' && c <= '9')
return c - '0';
@@ -884,7 +881,7 @@ constexpr uint8_t parse_hex_char(char c) {
return c - 'A' + 10;
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
return INVALID_HEX_CHAR;
return 255;
}
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)

View File

@@ -1,11 +1,5 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include "esphome/core/hal.h" // For PROGMEM definition
// Platform-agnostic macros for PROGMEM string handling
// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings
// On other platforms: Use plain strings (no PROGMEM)
@@ -38,80 +32,3 @@ using ProgmemStr = const __FlashStringHelper *;
// Type for pointers to strings (no PROGMEM on non-ESP8266 platforms)
using ProgmemStr = const char *;
#endif
namespace esphome {
/// Helper for C++20 string literal template arguments
template<size_t N> struct FixedString {
char data[N]{};
constexpr FixedString(const char (&str)[N]) {
for (size_t i = 0; i < N; ++i)
data[i] = str[i];
}
constexpr size_t size() const { return N - 1; } // exclude null terminator
};
/// Compile-time string table that packs strings into a single blob with offset lookup.
/// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266.
///
/// Example:
/// PROGMEM_STRING_TABLE(MyStrings, "foo", "bar", "baz");
/// ProgmemStr str = MyStrings::get_progmem_str(idx, MyStrings::LAST_INDEX); // For ArduinoJson
/// const LogString *log_str = MyStrings::get_log_str(idx, MyStrings::LAST_INDEX); // For logging
///
template<FixedString... Strs> struct ProgmemStringTable {
static constexpr size_t COUNT = sizeof...(Strs);
static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1));
/// Generate packed string blob at compile time
static constexpr auto make_blob() {
std::array<char, BLOB_SIZE> result{};
size_t pos = 0;
auto copy = [&](const auto &str) {
for (size_t i = 0; i <= str.size(); ++i)
result[pos++] = str.data[i];
};
(copy(Strs), ...);
return result;
}
/// Generate offset table at compile time (uint8_t limits blob to 255 bytes)
static constexpr auto make_offsets() {
static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string");
static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices");
static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings");
std::array<uint8_t, COUNT> result{};
size_t pos = 0, idx = 0;
((result[idx++] = static_cast<uint8_t>(pos), pos += Strs.size() + 1), ...);
return result;
}
};
// Forward declaration for LogString (defined in log.h)
struct LogString;
/// Instantiate a ProgmemStringTable with PROGMEM storage.
/// Creates: Name::get_progmem_str(idx, fallback), Name::get_log_str(idx, fallback)
/// If idx >= COUNT, returns string at fallback. Use LAST_INDEX for common patterns.
#define PROGMEM_STRING_TABLE(Name, ...) \
struct Name { \
using Table = ::esphome::ProgmemStringTable<__VA_ARGS__>; \
static constexpr size_t COUNT = Table::COUNT; \
static constexpr uint8_t LAST_INDEX = COUNT - 1; \
static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \
static constexpr auto BLOB PROGMEM = Table::make_blob(); \
static constexpr auto OFFSETS PROGMEM = Table::make_offsets(); \
static const char *get_(uint8_t idx, uint8_t fallback) { \
if (idx >= COUNT) \
idx = fallback; \
return &BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]; \
} \
static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \
return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \
} \
static const ::esphome::LogString *get_log_str(uint8_t idx, uint8_t fallback) { \
return reinterpret_cast<const ::esphome::LogString *>(get_(idx, fallback)); \
} \
}
} // namespace esphome

View File

@@ -37,7 +37,7 @@ lib_deps_base =
wjtje/qr-code-generator-library@1.7.0 ; qr_code
functionpointer/arduino-MLX90393@1.0.2 ; mlx90393
pavlodn/HaierProtocol@0.9.31 ; haier
esphome/dsmr_parser@1.1.0 ; dsmr
esphome/dsmr_parser@1.0.0 ; dsmr
polargoose/Crypto-no-arduino@0.4.0 ; dsmr
https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps
; This is using the repository until a new release is published to PlatformIO

View File

@@ -23,7 +23,6 @@ resvg-py==0.2.6
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
requests==2.32.5
# esp-idf >= 5.0 requires this
pyparsing >= 3.0

View File

@@ -756,6 +756,53 @@ def lint_no_sprintf(fname, match):
)
@lint_re_check(
# Match std::to_string() or unqualified to_string() calls
# The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string
# Use negative lookbehind for unqualified calls to avoid matching:
# - Function definitions: "const char *to_string(" or "std::string to_string("
# - Method definitions: "Class::to_string("
# - Method calls: ".to_string(" or "->to_string("
# - Other identifiers: "_to_string("
# Also explicitly match std::to_string since : is in the lookbehind
r"(?:(?<![*&.\w>:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL,
include=cpp_include,
exclude=[
# Vendored library
"esphome/components/http_request/httplib.h",
# Deprecated helpers that return std::string
"esphome/core/helpers.cpp",
# The using declaration itself
"esphome/core/helpers.h",
# Test fixtures - not production embedded code
"tests/integration/fixtures/*",
],
)
def lint_no_std_to_string(fname, match):
return (
f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) "
f"allocates heap memory. On long-running embedded devices, repeated heap allocations "
f"fragment memory over time.\n"
f"Please use {highlight('snprintf()')} with a stack buffer instead.\n"
f"\n"
f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n"
f" uint8_t: 4 chars - %u (or PRIu8)\n"
f" int8_t: 5 chars - %d (or PRId8)\n"
f" uint16_t: 6 chars - %u (or PRIu16)\n"
f" int16_t: 7 chars - %d (or PRId16)\n"
f" uint32_t: 11 chars - %" + "PRIu32\n"
" int32_t: 12 chars - %" + "PRId32\n"
" uint64_t: 21 chars - %" + "PRIu64\n"
" int64_t: 21 chars - %" + "PRId64\n"
f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n"
f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n"
f"\n"
f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n"
f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n'
f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
)
@lint_re_check(
# Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf
# Also match std:: prefixed versions

View File

@@ -1,14 +1,10 @@
"""
Test config_validation functionality in esphome.config_validation.
Test schema.extend functionality in esphome.config_validation.
"""
from typing import Any
import pytest
from voluptuous import Invalid
import esphome.config_validation as cv
from esphome.core import CORE
def test_config_extend() -> None:
@@ -53,37 +49,3 @@ def test_config_extend() -> None:
assert validated["key2"] == "initial_value2"
assert validated["extra_1"] == "value1"
assert validated["extra_2"] == "value2"
def test_requires_component_passes_when_loaded() -> None:
"""Test requires_component passes when the required component is loaded."""
CORE.loaded_integrations.update({"wifi", "logger"})
validator = cv.requires_component("wifi")
result = validator("test_value")
assert result == "test_value"
def test_requires_component_fails_when_not_loaded() -> None:
"""Test requires_component raises Invalid when the required component is not loaded."""
CORE.loaded_integrations.add("logger")
validator = cv.requires_component("wifi")
with pytest.raises(Invalid) as exc_info:
validator("test_value")
assert "requires component wifi" in str(exc_info.value)
def test_conflicts_with_component_passes_when_not_loaded() -> None:
"""Test conflicts_with_component passes when the conflicting component is not loaded."""
CORE.loaded_integrations.update({"wifi", "logger"})
validator = cv.conflicts_with_component("esp32_hosted")
result = validator("test_value")
assert result == "test_value"
def test_conflicts_with_component_fails_when_loaded() -> None:
"""Test conflicts_with_component raises Invalid when the conflicting component is loaded."""
CORE.loaded_integrations.update({"wifi", "esp32_hosted"})
validator = cv.conflicts_with_component("esp32_hosted")
with pytest.raises(Invalid) as exc_info:
validator("test_value")
assert "not compatible with component esp32_hosted" in str(exc_info.value)

View File

@@ -1,8 +0,0 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml
esp32_ble:
io_capability: keyboard_only
disable_bt_logs: false

View File

@@ -1,7 +0,0 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
# tx_power is not supported on ESP-Hosted platforms
esp32_ble_beacon:
type: iBeacon
uuid: 'c29ce823-e67a-4e71-bff2-abaa32e77a98'

View File

@@ -1,6 +0,0 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
ble_client:
- mac_address: 01:02:03:04:05:06
id: blec

View File

@@ -1,4 +0,0 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml

View File

@@ -1,7 +0,0 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml
esp32_ble_tracker:
max_connections: 9

View File

@@ -1,21 +0,0 @@
# Common BLE tracker configuration for ESP32-P4 IDF tests
# ESP32-P4 requires ESP-Hosted for Bluetooth via external coprocessor
# BLE client components share this tracker infrastructure
# Each component defines its own ble_client with unique MAC address
esp32_hosted:
active_high: true
variant: ESP32C6
reset_pin: GPIO54
cmd_pin: GPIO19
clk_pin: GPIO18
d0_pin: GPIO14
d1_pin: GPIO15
d2_pin: GPIO16
d3_pin: GPIO17
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true