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

Compare commits

...

22 Commits

Author SHA1 Message Date
Clyde Stubbs
756f1c6b7e [lvgl] Fix crash with unconfigured top_layer (#13846) 2026-02-08 21:53:43 -05:00
tomaszduda23
28b9487b25 [nrf52,logger] fix printk (#13874) 2026-02-08 17:52:05 +00:00
J. Nick Koston
41fedaedb3 [udp] Eliminate per-loop heap allocation using std::span (#13838)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-02-08 08:26:47 -06:00
schrob
7b40e8afcb [epaper_spi] Declare leaf classes final (#13776) 2026-02-07 19:21:37 -06:00
J. Nick Koston
a43e3e5948 [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) 2026-02-07 15:19:20 -06:00
schrob
9de91539e6 [epaper_spi] Add Waveshare 1.54-G (#13758) 2026-02-08 06:24:57 +11:00
tronikos
eb7aa3420f Add target_temperature to the template water heater (#13661)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-02-06 21:23:42 +01:00
J. Nick Koston
86f91eed2f [mqtt] Move switch string tables to PROGMEM_STRING_TABLE (#13802)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-02-06 19:30:05 +01:00
J. Nick Koston
41cecbfb0f [template] Convert alarm sensor type to PROGMEM_STRING_TABLE and narrow enum to uint8_t (#13804) 2026-02-06 18:22:26 +00:00
Jonathan Swoboda
9315da79bc [core] Add missing requests dependency to requirements.txt (#13803)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:03:16 -05:00
PolarGoose
155447f541 [dsmr] Fix issue with parsing lines like 1-0:0.2.0((ER11)) (#13780) 2026-02-06 12:53:59 -05:00
J. Nick Koston
238e40966f [light] Move CSWTCH lookup table to PROGMEM in get_suitable_color_modes_mask_ (#13801) 2026-02-06 17:33:26 +00:00
J. Nick Koston
f9192b5f75 [wifi] Avoid jump tables in LOG_STR switch statements to save ESP8266 RAM (#13799) 2026-02-06 18:20:46 +01:00
J. Nick Koston
2917057da8 [analyze-memory] Trace CSWTCH switch table symbols to source components (#13798)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-02-06 18:08:30 +01:00
J. Nick Koston
c7c9ffe7e1 [light] Convert color_mode_to_human to PROGMEM_STRING_TABLE using to_bit() (#13797) 2026-02-06 17:38:03 +01:00
J. Nick Koston
368ef5687b [update] Move update_state_to_string to update component and convert to PROGMEM_STRING_TABLE (#13796)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 17:37:41 +01:00
J. Nick Koston
b7dc975331 [core] Convert entity string lookups to PROGMEM_STRING_TABLE (#13794) 2026-02-06 17:37:19 +01:00
J. Nick Koston
44f308502e [gpio] Convert interrupt_type_to_string to PROGMEM_STRING_TABLE (#13795)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 17:37:02 +01:00
J. Nick Koston
ec477801ca [wifi] Defer ESP8266 WiFi listener callbacks from system context to main loop (#13789) 2026-02-06 16:23:19 +00:00
J. Nick Koston
c3622ef7fb [http_request] Fix chunked transfer encoding on Arduino platforms (#13790) 2026-02-06 15:52:41 +01:00
J. Nick Koston
e4ad2082bc [core] Add PROGMEM_STRING_TABLE macro for flash-optimized string lookups (#13659)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 15:26:16 +01:00
Andrew Rankin
7afd0eb1aa [esp32_ble] include sdkconfig.h before ESP-Hosted preprocessor guards (#13787)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 11:36:55 +00:00
84 changed files with 1798 additions and 816 deletions

View File

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

View File

@@ -12,7 +12,6 @@ from .const import (
CORE_SUBCATEGORY_PATTERNS,
DEMANGLED_PATTERNS,
ESPHOME_COMPONENT_PATTERN,
SECTION_TO_ATTR,
SYMBOL_PATTERNS,
)
from .demangle import batch_demangle
@@ -91,6 +90,17 @@ 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)."""
@@ -167,12 +177,15 @@ 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)
@@ -255,8 +268,7 @@ class MemoryAnalyzer:
comp_mem.symbol_count += 1
# Update the appropriate size attribute based on section
if attr_name := SECTION_TO_ATTR.get(section_name):
setattr(comp_mem, attr_name, getattr(comp_mem, attr_name) + size)
comp_mem.add_section_size(section_name, size)
# Track uncategorized symbols
if component == "other" and size > 0:
@@ -372,6 +384,205 @@ 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,6 +184,52 @@ 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(
@@ -471,6 +517,10 @@ 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,15 +66,6 @@ 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 = {
@@ -513,7 +504,9 @@ SYMBOL_PATTERNS = {
"__FUNCTION__$",
"DAYS_IN_MONTH",
"_DAYS_BEFORE_MONTH",
"CSWTCH$",
# 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.
"dst$",
"sulp",
"_strtol_l", # String to long with locale

View File

@@ -87,6 +87,7 @@ from esphome.cpp_types import ( # noqa: F401
size_t,
std_ns,
std_shared_ptr,
std_span,
std_string,
std_string_ref,
std_vector,

View File

@@ -1,32 +1,15 @@
#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) {
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");
}
return AlarmControlPanelStateStrings::get_log_str(static_cast<uint8_t>(state),
AlarmControlPanelStateStrings::LAST_INDEX);
}
} // namespace esphome::alarm_control_panel

View File

@@ -1,109 +1,44 @@
#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) {
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");
}
return ClimateModeStrings::get_log_str(static_cast<uint8_t>(mode), ClimateModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateActionStrings::get_log_str(static_cast<uint8_t>(action), ClimateActionStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateFanModeStrings::get_log_str(static_cast<uint8_t>(fan_mode), ClimateFanModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateSwingModeStrings::get_log_str(static_cast<uint8_t>(swing_mode), ClimateSwingModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimatePresetStrings::get_log_str(static_cast<uint8_t>(preset), ClimatePresetStrings::LAST_INDEX);
}
} // namespace esphome::climate

View File

@@ -19,17 +19,11 @@ 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) {
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");
}
return CoverOperationStrings::get_log_str(static_cast<uint8_t>(op), CoverOperationStrings::LAST_INDEX);
}
Cover::Cover() : position{COVER_OPEN} {}

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.0.0")
cg.add_library("esphome/dsmr_parser", "1.1.0")
# Crypto
cg.add_library("polargoose/Crypto-no-arduino", "0.4.0")

View File

@@ -718,14 +718,6 @@ 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,7 +26,9 @@ 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

@@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <algorithm>
#include "esphome/core/color.h"
/* Utility for converting internal \a Color RGB representation to supported IC hardware color keys
*
* Focus in driver layer is on efficiency.
* For optimum output quality on RGB inputs consider offline color keying/dithering.
* Also see e.g. Image component.
*/
namespace esphome::epaper_spi {
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Map RGB color to discrete BWYR hex 4 color key
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow,
NATIVE_COLOR hw_red) {
// --- Step 1: Check for Grayscale (Black or White) ---
// We define "grayscale" as a color where the min and max components
// are close to each other.
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
// --- Step 2: Check for Primary/Secondary Colors ---
// If it's not gray, it's a color. We check which components are
// "on" (over 128) vs "off". This divides the RGB cube into 8 corners.
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on) {
if (!b_on) {
return g_on ? hw_yellow : hw_red;
}
// At least red+blue high (but not gray) -> White
return hw_white;
} else {
return (b_on && g_on) ? hw_white : hw_black;
}
}
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,227 @@
#include "epaper_spi_jd79660.h"
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.jd79660";
/** Pixel color as 2bpp. Must match IC LUT values. */
enum JD79660Color : uint8_t {
BLACK = 0b00,
WHITE = 0b01,
YELLOW = 0b10,
RED = 0b11,
};
/** Map RGB color to JD79660 BWYR hex color keys */
static JD79660Color HOT color_to_hex(Color color) {
return color_to_bwyr(color, JD79660Color::BLACK, JD79660Color::WHITE, JD79660Color::YELLOW, JD79660Color::RED);
}
void EPaperJD79660::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
const auto pixel_color = color_to_hex(color);
// We store 4 pixels per byte
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
}
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const auto pixel_bits = color_to_hex(color);
const uint32_t pixel_position = x + y * this->get_width_internal();
// We store 4 pixels per byte at LSB offsets 6, 4, 2, 0
const uint32_t byte_position = pixel_position / 4;
const uint32_t bit_offset = 6 - ((pixel_position % 4) * 2);
const auto original = this->buffer_[byte_position];
this->buffer_[byte_position] = (original & (~(0b11 << bit_offset))) | // mask old 2bpp
(pixel_bits << bit_offset); // add new 2bpp
}
bool EPaperJD79660::reset() {
// On entry state RESET set step, next state will be RESET_END
if (this->state_ == EPaperState::RESET) {
this->step_ = FSMState::RESET_STEP0_H;
}
switch (this->step_) {
case FSMState::RESET_STEP0_H:
// Step #0: Reset H for some settle time.
ESP_LOGVV(TAG, "reset #0");
this->reset_pin_->digital_write(true);
this->reset_duration_ = SLEEP_MS_RESET0;
this->step_ = FSMState::RESET_STEP1_L;
return false; // another loop: step #1 below
case FSMState::RESET_STEP1_L:
// Step #1: Reset L pulse for slightly >1.5ms.
// This is actual reset trigger.
ESP_LOGVV(TAG, "reset #1");
// As commented on SLEEP_MS_RESET1: Reset pulse must happen within time window.
// So do not use FSM loop, and avoid other calls/logs during pulse below.
this->reset_pin_->digital_write(false);
delay(SLEEP_MS_RESET1);
this->reset_pin_->digital_write(true);
this->reset_duration_ = SLEEP_MS_RESET2;
this->step_ = FSMState::RESET_STEP2_IDLECHECK;
return false; // another loop: step #2 below
case FSMState::RESET_STEP2_IDLECHECK:
// Step #2: Basically finished. Check sanity, and move FSM to INITIALISE state
ESP_LOGVV(TAG, "reset #2");
if (!this->is_idle_()) {
// Expectation: Idle after reset + settle time.
// Improperly connected/unexpected hardware?
// Error path reproducable e.g. with disconnected VDD/... pins
// (optimally while busy_pin configured with local pulldown).
// -> Mark failed to avoid followup problems.
this->mark_failed(LOG_STR("Busy after reset"));
}
break; // End state loop below
default:
// Unexpected step = bug?
this->mark_failed();
}
this->step_ = FSMState::INIT_STEP0_REGULARINIT; // reset for initialize state
return true;
}
bool EPaperJD79660::initialise(bool partial) {
switch (this->step_) {
case FSMState::INIT_STEP0_REGULARINIT:
// Step #0: Regular init sequence
ESP_LOGVV(TAG, "init #0");
if (!EPaperBase::initialise(partial)) { // Call parent impl
return false; // If parent should request another loop, do so
}
// Fast init requested + supported?
if (partial && (this->fast_update_length_ > 0)) {
this->step_ = FSMState::INIT_STEP1_FASTINIT;
this->wait_for_idle_(true); // Must wait for idle before fastinit sequence in next loop
return false; // another loop: step #1 below
}
break; // End state loop below
case FSMState::INIT_STEP1_FASTINIT:
// Step #1: Fast init sequence
ESP_LOGVV(TAG, "init #1");
this->write_fastinit_();
break; // End state loop below
default:
// Unexpected step = bug?
this->mark_failed();
}
this->step_ = FSMState::NONE;
return true; // Finished: State transition waits for idle
}
bool EPaperJD79660::transfer_buffer_chunks_() {
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
const uint32_t start_time = App.get_loop_component_start_time();
const auto buffer_length = this->buffer_length_;
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis());
}
// Cleanup for next transfer
this->current_data_index_ = 0;
// Finished with all buffer chunks
return true;
}
void EPaperJD79660::write_fastinit_() {
// Undocumented register sequence in vendor register range.
// Related to Fast Init/Update.
// Should likely happen after regular init seq and power on, but before refresh.
// Might only work for some models with certain factory MTP.
// Please do not change without knowledge to avoid breakage.
this->send_init_sequence_(this->fast_update_, this->fast_update_length_);
}
bool EPaperJD79660::transfer_data() {
// For now always send full frame buffer in chunks.
// JD79660 might support partial window transfers. But sample code missing.
// And likely minimal impact, solely on SPI transfer time into RAM.
if (this->current_data_index_ == 0) {
this->command(CMD_TRANSFER);
}
return this->transfer_buffer_chunks_();
}
void EPaperJD79660::refresh_screen([[maybe_unused]] bool partial) {
ESP_LOGV(TAG, "Refresh");
this->cmd_data(CMD_REFRESH, {(uint8_t) 0x00});
}
void EPaperJD79660::power_off() {
ESP_LOGV(TAG, "Power off");
this->cmd_data(CMD_POWEROFF, {(uint8_t) 0x00});
}
void EPaperJD79660::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
// "Deepsleep between update": Ensure EPD sleep to avoid early hardware wearout!
this->cmd_data(CMD_DEEPSLEEP, {(uint8_t) 0xA5});
// Notes:
// - VDD: Some boards (Waveshare) with "clever reset logic" would allow switching off
// EPD VDD by pulling reset pin low for longer time.
// However, a) not all boards have this, b) reliable sequence timing is difficult,
// c) saving is not worth it after deepsleep command above.
// If needed: Better option is to drive VDD via MOSFET with separate enable pin.
//
// - Possible safe shutdown:
// EPaperBase::on_safe_shutdown() may also trigger deep_sleep() again.
// Regularly, in IDLE state, this does not make sense for this "deepsleep between update" model,
// but SPI sequence should simply be ignored by sleeping receiver.
// But if triggering during lengthy update, this quick SPI sleep sequence may have benefit.
// Optimally, EPDs should even be set all white for longer storage.
// But full sequence (>15s) not possible w/o app logic.
}
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,145 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* JD7966x IC driver implementation
*
* Currently tested with:
* - JD79660 (max res: 200x200)
*
* May also work for other JD7966x chipset family members with minimal adaptations.
*
* Capabilities:
* - HW frame buffer layout:
* 4 colors (gray0..3, commonly BWYR). Bytes consist of 4px/2bpp.
* Width must be rounded to multiple of 4.
* - Fast init/update (shorter wave forms): Yes. Controlled by CONF_FULL_UPDATE_EVERY.
* Needs undocumented fastinit sequence, based on likely vendor specific MTP content.
* - Partial transfer (transfer only changed window): No. Maybe possible by HW.
* - Partial refresh (refresh only changed window): No. Likely HW limit.
*
* @internal \c final saves few bytes by devirtualization. Remove \c final when subclassing.
*/
class EPaperJD79660 final : public EPaperBase {
public:
EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR),
fast_update_(fast_update),
fast_update_length_(fast_update_length) {
this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp)
this->buffer_length_ = this->row_width_ * height;
}
void fill(Color color) override;
protected:
/** Draw colored pixel into frame buffer */
void draw_pixel_at(int x, int y, Color color) override;
/** Reset (multistep sequence)
* @pre this->reset_pin_ != nullptr // cv.Required check
* @post Should be idle on successful reset. Can mark failures.
*/
bool reset() override;
/** Initialise (multistep sequence) */
bool initialise(bool partial) override;
/** Buffer transfer */
bool transfer_data() override;
/** Power on: Already part of init sequence (likely needed there before transferring buffers).
* So nothing to do in FSM state.
*/
void power_on() override {}
/** Refresh screen
* @param partial Ignored: Needed earlier in \a ::initialize
* @pre Must be idle.
* @post Should return to idle later after processing.
*/
void refresh_screen([[maybe_unused]] bool partial) override;
/** Power off
* @pre Must be idle.
* @post Should return to idle later after processing.
* (latter will take long period like ~15-20s on actual refresh!)
*/
void power_off() override;
/** Deepsleep: Must be used to avoid hardware wearout!
* @pre Must be idle.
* @post Will go busy, and not return idle till ::reset!
*/
void deep_sleep() override;
/** Internal: Send fast init sequence via undocumented vendor registers
* @pre Must be directly after regular ::initialise sequence, before ::transfer_data
* @pre Must be idle.
* @post Should return to idle later after processing.
*/
void write_fastinit_();
/** Internal: Send raw buffer in chunks
* \retval true Finished
* \retval false Loop time elapsed. Need to call again next loop.
*/
bool transfer_buffer_chunks_();
/** @name IC commands @{ */
static constexpr uint8_t CMD_POWEROFF = 0x02;
static constexpr uint8_t CMD_DEEPSLEEP = 0x07;
static constexpr uint8_t CMD_TRANSFER = 0x10;
static constexpr uint8_t CMD_REFRESH = 0x12;
/** @} */
/** State machine constants for \a step_ */
enum class FSMState : uint8_t {
NONE = 0, //!< Initial/default value: Unused
/* Reset state steps */
RESET_STEP0_H,
RESET_STEP1_L,
RESET_STEP2_IDLECHECK,
/* Init state steps */
INIT_STEP0_REGULARINIT,
INIT_STEP1_FASTINIT,
};
/** Wait time (millisec) for first reset phase: High
*
* Wait via FSM loop.
*/
static constexpr uint16_t SLEEP_MS_RESET0 = 200;
/** Wait time (millisec) for second reset phase: Low
*
* Holding Reset Low too long may trigger "clever reset" logic
* of e.g. Waveshare Rev2 boards: VDD is shut down via MOSFET, and IC
* will not report idle anymore!
* FSM loop may spuriously increase delay, e.g. >16ms.
* Therefore, sync wait below, as allowed (code rule "delays > 10ms not permitted"),
* yet only slightly exceeding known IC min req of >1.5ms.
*/
static constexpr uint16_t SLEEP_MS_RESET1 = 2;
/** Wait time (millisec) for third reset phase: High
*
* Wait via FSM loop.
*/
static constexpr uint16_t SLEEP_MS_RESET2 = 200;
// properties initialised in the constructor
const uint8_t *const fast_update_{};
const uint16_t fast_update_length_{};
/** Counter for tracking substeps within FSM state */
FSMState step_{FSMState::NONE};
};
} // namespace esphome::epaper_spi

View File

@@ -4,7 +4,7 @@
namespace esphome::epaper_spi {
class EPaperSpectraE6 : public EPaperBase {
class EPaperSpectraE6 final : public EPaperBase {
public:
EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)

View File

@@ -6,7 +6,7 @@ namespace esphome::epaper_spi {
/**
* An epaper display that needs LUTs to be sent to it.
*/
class EpaperWaveshare : public EPaperMono {
class EpaperWaveshare final : public EPaperMono {
public:
EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut,

View File

@@ -0,0 +1,86 @@
import esphome.codegen as cg
from esphome.components.mipi import flatten_sequence
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from esphome.core import ID
from ..display import CONF_INIT_SEQUENCE_ID
from . import EpaperModel
class JD79660(EpaperModel):
def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs):
super().__init__(name, class_name, **kwargs)
self.fast_update = fast_update
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Validate required pins, as C++ code will assume existence
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
# Delegate to parent
return super().option(name, fallback)
def get_constructor_args(self, config) -> tuple:
# Resembles init_sequence handling for fast_update config
if self.fast_update is None:
fast_update = cg.nullptr, 0
else:
flat_fast_update = flatten_sequence(self.fast_update)
fast_update = (
cg.static_const_array(
ID(
config[CONF_INIT_SEQUENCE_ID].id + "_fast_update", type=cg.uint8
),
flat_fast_update,
),
len(flat_fast_update),
)
return (*fast_update,)
jd79660 = JD79660(
"jd79660",
# Specified refresh times are ~20s (full) or ~15s (fast) due to BWRY.
# So disallow low update intervals (with safety margin), to avoid e.g. FSM update loops.
# Even less frequent intervals (min/h) highly recommended to optimize lifetime!
minimum_update_interval="30s",
# SPI rate: From spec comparisons, IC should allow SCL write cycles up to 10MHz rate.
# Existing code samples also prefer 10MHz. So justifies as default.
# Decrease value further in user config if needed (e.g. poor cabling).
data_rate="10MHz",
# No need to set optional reset_duration:
# Code requires multistep reset sequence with precise timings
# according to data sheet or samples.
)
# Waveshare 1.54-G
#
# Device may have specific factory provisioned MTP content to facilitate vendor register features like fast init.
# Vendor specific init derived from vendor sample code
# <https://github.com/waveshareteam/e-Paper/blob/master/E-paper_Separate_Program/1in54_e-Paper_G/ESP32/EPD_1in54g.cpp>
# Compatible MIT license, see esphome/LICENSE file.
#
# fmt: off
jd79660.extend(
"Waveshare-1.54in-G",
width=200,
height=200,
initsequence=(
(0x4D, 0x78,),
(0x00, 0x0F, 0x29,),
(0x06, 0x0d, 0x12, 0x30, 0x20, 0x19, 0x2a, 0x22,),
(0x50, 0x37,),
(0x61, 200 // 256, 200 % 256, 200 // 256, 200 % 256,), # RES: 200x200 fixed
(0xE9, 0x01,),
(0x30, 0x08,),
# Power On (0x04): Must be early part of init seq = Disabled later!
(0x04,),
),
fast_update=(
(0xE0, 0x02,),
(0xE6, 0x5D,),
(0xA5, 0x00,),
),
)

View File

@@ -10,20 +10,11 @@
#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

@@ -53,8 +53,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MEASURED_POWER, default=-59): cv.int_range(
min=-128, max=0
),
cv.Optional(CONF_TX_POWER, default="3dBm"): cv.All(
cv.decibel, cv.enum(esp32_ble.TX_POWER_LEVELS, int=True)
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),
),
}
).extend(cv.COMPONENT_SCHEMA),
@@ -82,7 +84,10 @@ 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]))
cg.add(var.set_tx_power(config[CONF_TX_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_define("USE_ESP32_BLE_ADVERTISING")

View File

@@ -36,11 +36,16 @@ 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; }
@@ -74,11 +79,14 @@ 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");
esp_err_t err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
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,7 +48,9 @@ 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:
@@ -60,7 +62,9 @@ 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,21 +2,18 @@
#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) {
switch (direction) {
case FanDirection::FORWARD:
return LOG_STR("FORWARD");
case FanDirection::REVERSE:
return LOG_STR("REVERSE");
default:
return LOG_STR("UNKNOWN");
}
return FanDirectionStrings::get_log_str(static_cast<uint8_t>(direction), FanDirectionStrings::LAST_INDEX);
}
FanCall &FanCall::set_preset_mode(const std::string &preset_mode) {

View File

@@ -1,5 +1,6 @@
#include "gpio_binary_sensor.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace gpio {
@@ -7,17 +8,12 @@ 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) {
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");
}
return InterruptTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
}
static const LogString *gpio_mode_to_string(bool use_interrupt) {

View File

@@ -133,20 +133,10 @@ 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 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.
// 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.
int content_length = container->client_.getSize();
ESP_LOGD(TAG, "Content-Length: %d", content_length);
container->content_length = (size_t) content_length;
@@ -174,6 +164,10 @@ 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());
@@ -184,24 +178,42 @@ 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; // Connection closed or EOF for chunked
return HTTP_ERROR_CONNECTION_CLOSED;
}
return 0; // No data yet, caller should retry
}
@@ -215,6 +227,143 @@ 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,6 +18,17 @@
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;
@@ -26,6 +37,13 @@ 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,8 +133,10 @@ 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;
// 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.
// 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.
if (result == HttpReadLoopResult::COMPLETE)
break;
if (result != HttpReadLoopResult::DATA) {

View File

@@ -4,6 +4,7 @@
#include "light_state.h"
#include "esphome/core/log.h"
#include "esphome/core/optional.h"
#include "esphome/core/progmem.h"
namespace esphome::light {
@@ -51,26 +52,13 @@ 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) {
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");
return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0);
}
// Helper to log percentage values
@@ -457,6 +445,52 @@ 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();
@@ -466,46 +500,8 @@ 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]
#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
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;
}
LightCall &LightCall::set_effect(const char *effect, size_t len) {

View File

@@ -9,32 +9,19 @@ namespace esphome::light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
// 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.
// 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.
static ProgmemStr get_color_mode_json_str(ColorMode mode) {
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;
}
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);
}
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {

View File

@@ -8,22 +8,12 @@ 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) {
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");
}
return LockStateStrings::get_log_str(static_cast<uint8_t>(state), 0);
}
Lock::Lock() : state(LOCK_STATE_NONE) {}

View File

@@ -4,6 +4,7 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::logger {
@@ -241,34 +242,20 @@ UARTSelection Logger::get_uart() const { return this->uart_; }
float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; }
#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";
// 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");
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
static const LogString *get_log_level_str(uint8_t level) {
return LogLevelStrings::get_log_str(level, LogLevelStrings::LAST_INDEX);
}
void Logger::dump_config() {
ESP_LOGCONFIG(TAG,
"Logger:\n"
" Max Level: %s\n"
" Initial Level: %s",
LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]), LOG_STR_ARG(LOG_LEVELS[this->current_level_]));
LOG_STR_ARG(get_log_level_str(ESPHOME_LOG_LEVEL)),
LOG_STR_ARG(get_log_level_str(this->current_level_)));
#ifndef USE_HOST
ESP_LOGCONFIG(TAG,
" Log Baud Rate: %" PRIu32 "\n"
@@ -287,7 +274,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(LOG_LEVELS[it.second]));
ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first, LOG_STR_ARG(get_log_level_str(it.second)));
}
#endif
}
@@ -295,7 +282,8 @@ 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(LOG_LEVELS[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)));
}
this->current_level_ = level;
#ifdef USE_LOGGER_LEVEL_LISTENERS

View File

@@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) {
#ifdef CONFIG_PRINTK
// Requires the debug component and an active SWD connection.
// It is used for pyocd rtt -t nrf52840
k_str_out(const_cast<char *>(msg), len);
printk("%.*s", static_cast<int>(len), msg);
#endif
if (this->uart_dev_ == nullptr) {
return;

View File

@@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None):
schema = schema.extend(widget_type.schema)
def validator(value):
value = value or {}
return append_layout_schema(schema, value)(value)
return validator

View File

@@ -13,31 +13,12 @@ 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) {
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");
}
return AlarmMqttStateStrings::get_progmem_str(static_cast<uint8_t>(state), AlarmMqttStateStrings::LAST_INDEX);
}
MQTTAlarmControlPanelComponent::MQTTAlarmControlPanelComponent(AlarmControlPanel *alarm_control_panel)

View File

@@ -8,6 +8,7 @@
#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"
@@ -27,6 +28,11 @@ 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];
@@ -348,36 +354,8 @@ void MQTTClientComponent::loop() {
mqtt_backend_.loop();
if (this->disconnect_reason_.has_value()) {
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;
}
const LogString *reason_s = MQTTDisconnectReasonStrings::get_log_str(
static_cast<uint8_t>(*this->disconnect_reason_), MQTTDisconnectReasonStrings::LAST_INDEX);
if (!network::is_connected()) {
reason_s = LOG_STR("WiFi disconnected");
}

View File

@@ -13,109 +13,44 @@ 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) {
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");
}
return ClimateMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), ClimateMqttModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateMqttActionStrings::get_progmem_str(static_cast<uint8_t>(action), ClimateMqttActionStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateMqttFanModeStrings::get_progmem_str(static_cast<uint8_t>(fan_mode),
ClimateMqttFanModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateMqttSwingModeStrings::get_progmem_str(static_cast<uint8_t>(swing_mode),
ClimateMqttSwingModeStrings::LAST_INDEX);
}
// 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) {
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");
}
return ClimateMqttPresetStrings::get_progmem_str(static_cast<uint8_t>(preset), ClimateMqttPresetStrings::LAST_INDEX);
}
void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {

View File

@@ -14,6 +14,9 @@ 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);
@@ -213,13 +216,9 @@ bool MQTTComponent::send_discovery_() {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
const auto entity_category = this->get_entity()->get_entity_category();
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 (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));
}
if (config.state_topic) {

View File

@@ -1,5 +1,6 @@
#include "mqtt_number.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -12,6 +13,9 @@ 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() {
@@ -48,15 +52,10 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon
if (!unit_of_measurement.empty()) {
root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement;
}
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 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));
}
const auto device_class = this->number_->traits.get_device_class_ref();
if (!device_class.empty()) {

View File

@@ -1,5 +1,6 @@
#include "mqtt_text.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -12,6 +13,9 @@ 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() {
@@ -34,14 +38,8 @@ 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
switch (this->text_->traits.get_mode()) {
case TEXT_MODE_TEXT:
root[MQTT_MODE] = "text";
break;
case TEXT_MODE_PASSWORD:
root[MQTT_MODE] = "password";
break;
}
root[MQTT_MODE] = TextMqttModeStrings::get_progmem_str(static_cast<uint8_t>(this->text_->traits.get_mode()),
static_cast<uint8_t>(TEXT_MODE_TEXT));
config.command_topic = true;
}

View File

@@ -396,9 +396,9 @@ static bool process_rolling_code(Provider &provider, PacketDecoder &decoder) {
/**
* Process a received packet
*/
void PacketTransport::process_(const std::vector<uint8_t> &data) {
void PacketTransport::process_(std::span<const uint8_t> data) {
auto ping_key_seen = !this->ping_pong_enable_;
PacketDecoder decoder((data.data()), data.size());
PacketDecoder decoder(data.data(), data.size());
char namebuf[256]{};
uint8_t byte;
FuData rdata{};

View File

@@ -9,8 +9,9 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#include <vector>
#include <map>
#include <span>
#include <vector>
/**
* Providing packet encoding functions for exchanging data with a remote host.
@@ -113,7 +114,7 @@ class PacketTransport : public PollingComponent {
virtual bool should_send() { return true; }
// to be called by child classes when a data packet is received.
void process_(const std::vector<uint8_t> &data);
void process_(std::span<const uint8_t> data);
void send_data_(bool all);
void flush_();
void add_data_(uint8_t key, const char *id, float data);

View File

@@ -2,6 +2,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::sensor {
@@ -30,20 +31,13 @@ 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) {
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("");
}
// 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);
}
Sensor::Sensor() : state(NAN), raw_state(NAN) {}

View File

@@ -32,6 +32,7 @@ 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,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::template_ {
@@ -28,18 +29,11 @@ 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) {
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");
}
return AlarmSensorTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
}
#endif

View File

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

View File

@@ -46,6 +46,7 @@ CONFIG_SCHEMA = (
RESTORE_MODES, upper=True
),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_MODE): cv.returning_lambda,
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
water_heater.validate_water_heater_mode
@@ -78,6 +79,14 @@ async def to_code(config: ConfigType) -> None:
)
cg.add(var.set_current_temperature_lambda(template_))
if CONF_TARGET_TEMPERATURE in config:
template_ = await cg.process_lambda(
config[CONF_TARGET_TEMPERATURE],
[],
return_type=cg.optional.template(cg.float_),
)
cg.add(var.set_target_temperature_lambda(template_))
if CONF_MODE in config:
template_ = await cg.process_lambda(
config[CONF_MODE],

View File

@@ -16,7 +16,8 @@ void TemplateWaterHeater::setup() {
restore->perform();
}
}
if (!this->current_temperature_f_.has_value() && !this->mode_f_.has_value())
if (!this->current_temperature_f_.has_value() && !this->target_temperature_f_.has_value() &&
!this->mode_f_.has_value())
this->disable_loop();
}
@@ -28,6 +29,9 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
}
traits.set_supports_current_temperature(true);
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
return traits;
}
@@ -42,6 +46,14 @@ void TemplateWaterHeater::loop() {
}
}
auto target_temp = this->target_temperature_f_.call();
if (target_temp.has_value()) {
if (*target_temp != this->target_temperature_) {
this->target_temperature_ = *target_temp;
changed = true;
}
}
auto new_mode = this->mode_f_.call();
if (new_mode.has_value()) {
if (*new_mode != this->mode_) {

View File

@@ -20,6 +20,9 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
template<typename F> void set_current_temperature_lambda(F &&f) {
this->current_temperature_f_.set(std::forward<F>(f));
}
template<typename F> void set_target_temperature_lambda(F &&f) {
this->target_temperature_f_.set(std::forward<F>(f));
}
template<typename F> void set_mode_lambda(F &&f) { this->mode_f_.set(std::forward<F>(f)); }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
@@ -44,6 +47,7 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
// Ordered to minimize padding on 32-bit: 4-byte members first, then smaller
Trigger<> set_trigger_;
TemplateLambda<float> current_temperature_f_;
TemplateLambda<float> target_temperature_f_;
TemplateLambda<water_heater::WaterHeaterMode> mode_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
water_heater::WaterHeaterModeMask supported_modes_;

View File

@@ -13,7 +13,7 @@ from esphome.components.packet_transport import (
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
from esphome.core import ID
from esphome.cpp_generator import literal
from esphome.cpp_generator import MockObj
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["network"]
@@ -23,8 +23,12 @@ MULTI_CONF = True
udp_ns = cg.esphome_ns.namespace("udp")
UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
trigger_args = cg.std_vector.template(cg.uint8)
trigger_argname = "data"
# Listener callback type (non-owning span from UDP component)
listener_args = cg.std_span.template(cg.uint8.operator("const"))
listener_argtype = [(listener_args, trigger_argname)]
# Automation/trigger type (owned vector, safe for deferred actions like delay)
trigger_args = cg.std_vector.template(cg.uint8)
trigger_argtype = [(trigger_args, trigger_argname)]
CONF_ADDRESSES = "addresses"
@@ -118,7 +122,13 @@ async def to_code(config):
trigger_id, trigger_argtype, on_receive
)
trigger_lambda = await cg.process_lambda(
trigger.trigger(literal(trigger_argname)), trigger_argtype
trigger.trigger(
cg.std_vector.template(cg.uint8)(
MockObj(trigger_argname).begin(),
MockObj(trigger_argname).end(),
)
),
listener_argtype,
)
cg.add(var.add_listener(trigger_lambda))
cg.add(var.set_should_listen())

View File

@@ -12,7 +12,7 @@ bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() {
PacketTransport::setup();
if (!this->providers_.empty() || this->is_encrypted_()) {
this->parent_->add_listener([this](std::vector<uint8_t> &buf) { this->process_(buf); });
this->parent_->add_listener([this](std::span<const uint8_t> data) { this->process_(data); });
}
}

View File

@@ -103,8 +103,8 @@ void UDPComponent::setup() {
}
void UDPComponent::loop() {
auto buf = std::vector<uint8_t>(MAX_PACKET_SIZE);
if (this->should_listen_) {
std::array<uint8_t, MAX_PACKET_SIZE> buf;
for (;;) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
auto len = this->listen_socket_->read(buf.data(), buf.size());
@@ -116,9 +116,9 @@ void UDPComponent::loop() {
#endif
if (len <= 0)
break;
buf.resize(len);
ESP_LOGV(TAG, "Received packet of length %zu", len);
this->packet_listeners_.call(buf);
size_t packet_len = static_cast<size_t>(len);
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
}
}
}

View File

@@ -10,7 +10,9 @@
#ifdef USE_SOCKET_IMPL_LWIP_TCP
#include <WiFiUdp.h>
#endif
#include <array>
#include <initializer_list>
#include <span>
#include <vector>
namespace esphome::udp {
@@ -26,7 +28,7 @@ class UDPComponent : public Component {
void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; }
void set_should_broadcast() { this->should_broadcast_ = true; }
void set_should_listen() { this->should_listen_ = true; }
void add_listener(std::function<void(std::vector<uint8_t> &)> &&listener) {
void add_listener(std::function<void(std::span<const uint8_t>)> &&listener) {
this->packet_listeners_.add(std::move(listener));
}
void setup() override;
@@ -41,7 +43,7 @@ class UDPComponent : public Component {
uint16_t broadcast_port_{};
bool should_broadcast_{};
bool should_listen_{};
CallbackManager<void(std::vector<uint8_t> &)> packet_listeners_{};
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
std::unique_ptr<socket::Socket> broadcast_socket_ = nullptr;

View File

@@ -2,12 +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 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,6 +27,8 @@ 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,17 +23,11 @@ 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) {
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");
}
return ValveOperationStrings::get_log_str(static_cast<uint8_t>(op), ValveOperationStrings::LAST_INDEX);
}
Valve::Valve() : position{VALVE_OPEN} {}

View File

@@ -233,25 +233,13 @@ 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) {
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");
}
return WaterHeaterModeStrings::get_log_str(static_cast<uint8_t>(mode), WaterHeaterModeStrings::LAST_INDEX);
}
void WaterHeater::dump_traits_(const char *tag) {

View File

@@ -29,6 +29,10 @@
#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
@@ -2104,19 +2108,6 @@ 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);
}
@@ -2158,7 +2149,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_state_to_string(obj->state)),
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::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,25 +236,23 @@ 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) {
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
return LOG_STR("INITIAL_CONNECT");
if (phase == WiFiRetryPhase::INITIAL_CONNECT)
return LOG_STR("INITIAL_CONNECT");
#ifdef USE_WIFI_FAST_CONNECT
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
return LOG_STR("FAST_CONNECT_CYCLING");
if (phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS)
return LOG_STR("FAST_CONNECT_CYCLING");
#endif
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");
}
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");
}
bool WiFiComponent::went_through_explicit_hidden_phase_() const {
@@ -1470,6 +1468,14 @@ 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;
}
@@ -1481,7 +1487,11 @@ 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;
}
@@ -2202,8 +2212,31 @@ 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,6 +594,9 @@ 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);
@@ -635,6 +638,16 @@ 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
@@ -658,13 +671,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
@@ -681,6 +694,15 @@ 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
@@ -688,7 +710,8 @@ 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;
// Group all 32-bit integers together
// 4-byte members
float output_power_{NAN};
uint32_t action_started_;
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
@@ -697,7 +720,7 @@ class WiFiComponent : public Component {
uint32_t ap_timeout_{};
#endif
// Group all 8-bit values together
// 1-byte enums and integers
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2};
@@ -708,17 +731,39 @@ 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
// Group all boolean values together
// 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_{};
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};
@@ -733,32 +778,10 @@ 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,6 +36,7 @@ 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 {
@@ -398,106 +399,82 @@ 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) {
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");
}
return AuthModeStrings::get_log_str(mode, AuthModeStrings::LAST_INDEX);
}
// 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 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");
}
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");
}
// TODO: This callback runs in ESP8266 system context with limited stack (~2KB).
@@ -519,16 +496,6 @@ 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;
}
@@ -547,16 +514,9 @@ 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
// 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);
}
global_wifi_component->pending_.disconnect = true;
#endif
break;
}
@@ -568,8 +528,6 @@ 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;
}
@@ -583,10 +541,8 @@ 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
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));
}
// Defer listener callbacks to main loop - system context has limited stack
global_wifi_component->pending_.got_ip = true;
#endif
break;
}
@@ -679,21 +635,15 @@ void WiFiComponent::wifi_pre_setup_() {
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
station_status_t status = wifi_station_get_connect_status();
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;
}
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;
}
bool WiFiComponent::wifi_scan_start_(bool passive) {
static bool first_scan = false;
@@ -798,9 +748,7 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
needs_full ? "" : " (filtered)");
this->scan_done_ = true;
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : global_wifi_component->scan_results_listeners_) {
listener->on_wifi_scan_results(global_wifi_component->scan_result_);
}
this->pending_.scan_complete = true; // Defer listener callbacks to main loop
#endif
}
@@ -987,7 +935,34 @@ 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_() {}
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
}
} // namespace esphome::wifi
#endif

View File

@@ -753,9 +753,7 @@ 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()) {
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));
}
this->notify_ip_state_listeners_();
}
#endif
@@ -779,10 +777,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
s_sta_connecting = false;
error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
this->notify_disconnect_state_listeners_();
#endif
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) {
@@ -793,9 +788,7 @@ 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
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));
}
this->notify_ip_state_listeners_();
#endif
#if USE_NETWORK_IPV6
@@ -804,9 +797,7 @@ 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
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));
}
this->notify_ip_state_listeners_();
#endif
#endif /* USE_NETWORK_IPV6 */
@@ -883,9 +874,7 @@ 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
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
this->notify_scan_results_listeners_();
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {

View File

@@ -468,9 +468,7 @@ 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
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));
}
this->notify_ip_state_listeners_();
#endif
}
#endif
@@ -527,10 +525,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
}
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
this->notify_disconnect_state_listeners_();
#endif
break;
}
@@ -553,18 +548,14 @@ 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
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));
}
this->notify_ip_state_listeners_();
#endif
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: {
ESP_LOGV(TAG, "Got IPv6");
#ifdef USE_WIFI_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));
}
this->notify_ip_state_listeners_();
#endif
break;
}
@@ -708,9 +699,7 @@ void WiFiComponent::wifi_scan_done_callback_() {
needs_full ? "" : " (filtered)");
WiFi.scanDelete();
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
this->notify_scan_results_listeners_();
#endif
}

View File

@@ -264,9 +264,7 @@ 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
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
this->notify_scan_results_listeners_();
#endif
}
@@ -290,9 +288,7 @@ 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;
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));
}
this->notify_ip_state_listeners_();
}
#endif
} else if (!is_connected && s_sta_was_connected) {
@@ -301,10 +297,7 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = false;
ESP_LOGV(TAG, "Disconnected");
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
this->notify_disconnect_state_listeners_();
#endif
}
@@ -322,9 +315,7 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = true;
ESP_LOGV(TAG, "Got IP address");
#ifdef USE_WIFI_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));
}
this->notify_ip_state_listeners_();
#endif
}
}

View File

@@ -1403,6 +1403,17 @@ 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 > 15)
if (val == INVALID_HEX_CHAR)
return 0;
data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4;
}

View File

@@ -874,6 +874,9 @@ 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';
@@ -881,7 +884,7 @@ constexpr uint8_t parse_hex_char(char c) {
return c - 'A' + 10;
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
return 255;
return INVALID_HEX_CHAR;
}
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)

View File

@@ -1,5 +1,11 @@
#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)
@@ -32,3 +38,80 @@ 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

@@ -12,6 +12,7 @@ std_shared_ptr = std_ns.class_("shared_ptr")
std_string = std_ns.class_("string")
std_string_ref = std_ns.namespace("string &")
std_vector = std_ns.class_("vector")
std_span = std_ns.class_("span")
uint8 = global_ns.namespace("uint8_t")
uint16 = global_ns.namespace("uint16_t")
uint32 = global_ns.namespace("uint32_t")

View File

@@ -317,6 +317,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl
# Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None:
# Check if proc exists (if 'start' has been run)

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.0.0 ; dsmr
esphome/dsmr_parser@1.1.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,6 +23,7 @@ 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

@@ -1,10 +1,14 @@
"""
Test schema.extend functionality in esphome.config_validation.
Test config_validation 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:
@@ -49,3 +53,37 @@ 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

@@ -25,6 +25,22 @@ display:
lambda: |-
it.circle(64, 64, 50, Color::BLACK);
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-1.54in-G
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-v3

View File

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,6 @@
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

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

View File

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

View File

@@ -20,6 +20,8 @@ lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
top_layer:
- id: lvgl_1
displays: sdl1
on_idle:

View File

@@ -412,6 +412,7 @@ water_heater:
name: "Template Water Heater"
optimistic: true
current_temperature: !lambda "return 42.0f;"
target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
supported_modes:
- "OFF"

View File

@@ -29,7 +29,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state,
)
from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path
@@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state"
finally:
ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()

View File

@@ -10,6 +10,7 @@ water_heater:
name: Test Boiler
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
# Note: No mode lambda - we want optimistic mode changes to stick
# A mode lambda would override mode changes in loop()
supported_modes:

View File

@@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]
sock.close()
def _get_free_udp_port() -> int:
"""Get a free UDP port by binding to port 0 and releasing."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
@pytest.mark.asyncio
async def test_udp_send_receive(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test UDP component can send messages with multiple addresses configured."""
# Track log lines to verify dump_config output
"""Test UDP component can send and receive messages."""
log_lines: list[str] = []
receive_event = asyncio.Event()
def on_log_line(line: str) -> None:
log_lines.append(line)
if "Received UDP:" in line:
receive_event.set()
async with udp_listener() as (udp_port, receiver):
# Replace placeholders in the config
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port))
async with udp_listener() as (broadcast_port, receiver):
listen_port = _get_free_udp_port()
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port))
async with (
run_compiled(config, line_callback=on_log_line),
@@ -169,3 +180,19 @@ async def test_udp_send_receive(
assert "Address: 127.0.0.2" in log_text, (
f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}"
)
# Test receiving a UDP packet (exercises on_receive with std::span)
test_payload = b"TEST_RECEIVE_UDP"
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
send_sock.sendto(test_payload, ("127.0.0.1", listen_port))
finally:
send_sock.close()
try:
await asyncio.wait_for(receive_event.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(
f"on_receive did not fire. Expected 'Received UDP:' in logs. "
f"Last log lines: {log_lines[-20:]}"
)

View File

@@ -85,6 +85,9 @@ async def test_water_heater_template(
assert initial_state.current_temperature == 45.0, (
f"Expected current temp 45.0, got {initial_state.current_temperature}"
)
assert initial_state.target_temperature == 60.0, (
f"Expected target temp 60.0, got {initial_state.target_temperature}"
)
# Test changing to GAS mode
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS)

View File

@@ -0,0 +1,21 @@
# 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