Merge pull request #20542 from esphome/bump-2026.10.0b3

2026.10.0b3
This commit is contained in:
Jesse Hills
2026-10-12 15:42:25 +13:00
committed by GitHub
124 changed files with 2995 additions and 565 deletions
+1 -1
View File
@@ -568,7 +568,7 @@ esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax
esphome/components/teleinfo/* @0hax @FredM67
esphome/components/tem3200/* @bakerkj
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
esphome/components/template/datetime/* @rfdarter
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0b2
PROJECT_NUMBER = 2026.10.0b3
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+3 -3
View File
@@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple):
# Arduino core version -> its build in esphome-libs/arduino-esp8266
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
Version(3, 1, 2): FrameworkRelease(
"3.1.2-esphome.2",
"9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7",
37192128,
"3.1.2-esphome.3",
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
37185650,
),
}
+6 -51
View File
@@ -672,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
@register_condition("and", AndCondition, validate_condition_list)
async def and_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
async def or_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("all", AndCondition, validate_condition_list)
async def all_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
@register_condition("any", OrCondition, validate_condition_list)
async def any_condition_to_code(
@register_condition("xor", XorCondition, validate_condition_list)
async def condition_group_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
# A group of one is that condition for and, or and xor; skip the wrapper.
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@@ -734,19 +702,6 @@ async def not_condition_to_code(
return cg.new_Pvariable(condition_id, template_arg, condition)
@register_condition("xor", XorCondition, validate_condition_list)
async def xor_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
async def lambda_condition_to_code(
config: ConfigType,
+1 -1
View File
@@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
"rule link",
+1 -1
View File
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
+14 -7
View File
@@ -74,33 +74,40 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
+17 -4
View File
@@ -444,7 +444,7 @@ message DeviceWizardRequest {
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...",
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
@@ -468,6 +468,14 @@ message DeviceWizardRequest {
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
@@ -483,8 +491,13 @@ message DeviceWizardResponse {
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM,
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
@@ -496,7 +509,7 @@ message WizardInputSetRequest {
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 255];
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
+49 -7
View File
@@ -14,26 +14,68 @@ namespace esphome::api {
static const char *const TAG = "api.wizard";
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
+33 -2
View File
@@ -6,6 +6,9 @@
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
@@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
@@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one.
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
+54 -17
View File
@@ -6,7 +6,6 @@ Home Assistant shows the wizard when the device is added. See api_wizard.h for t
from collections.abc import Callable
import importlib
import json
from types import ModuleType
from typing import Any
from esphome import automation
@@ -20,6 +19,8 @@ from esphome.const import (
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
@@ -27,7 +28,12 @@ from esphome.const import (
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash
from esphome.helpers import (
fnv1_hash,
fnv1_hash_object_id,
fnv1a_32bit_hash,
zstd_module,
)
from esphome.types import ConfigType
API_DOMAIN = "api"
@@ -46,6 +52,10 @@ WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
@@ -101,6 +111,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
@@ -109,6 +134,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
@@ -119,6 +148,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
@@ -243,6 +273,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
@@ -351,14 +383,6 @@ automation.register_apply_condition(
)
def zstd_module() -> ModuleType:
"""The zstd module: the standard library one from Python 3.14, otherwise the backport."""
try:
return importlib.import_module("compression.zstd")
except ImportError:
return importlib.import_module("backports.zstd")
def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
@@ -374,6 +398,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
@@ -387,7 +415,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...",
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
@@ -399,6 +427,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
@@ -444,14 +480,15 @@ async def to_code(wizard: ConfigType) -> None:
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}"))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
+16 -4
View File
@@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
CONF_CANBUS_ID = "canbus_id"
CONF_BIT_RATE = "bit_rate"
CONF_ON_FRAME = "on_frame"
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
def validate_id(config: ConfigType) -> ConfigType:
@@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(CanbusComponent),
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
CAN_SPEEDS, upper=True
),
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
cv.Optional(
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
): cv.positive_not_null_int,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
@@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
cg.add(var.set_can_id(config[CONF_CAN_ID]))
if config[CONF_USE_EXTENDED_ID]:
cg.add(var.set_use_extended_id(True))
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
cg.add(var.set_max_frames_per_loop(max_frames))
for conf in config.get(CONF_ON_FRAME, []):
can_id = conf[CONF_CAN_ID]
+15 -7
View File
@@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
void Canbus::loop() {
struct CanFrame can_message;
// read all messages until queue is empty
int message_counter = 0;
while (this->read_message(&can_message) == canbus::ERROR_OK) {
message_counter++;
uint32_t message_counter = 0;
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
if (this->read_message(&can_message) != canbus::ERROR_OK) {
break;
}
if (can_message.use_extended_id) {
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
} else {
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
can_message.can_data_length_code);
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
}
std::vector<uint8_t> data;
@@ -100,6 +102,12 @@ void Canbus::loop() {
}
}
}
if (message_counter == this->max_frames_per_loop_) {
ESP_LOGW(TAG,
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
this->max_frames_per_loop_);
}
}
} // namespace esphome::canbus
+4 -2
View File
@@ -78,6 +78,7 @@ class Canbus : public Component {
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
void add_trigger(CanbusTrigger *trigger);
/**
@@ -96,8 +97,9 @@ class Canbus : public Component {
template<typename... Ts> friend class CanbusSendAction;
std::vector<CanbusTrigger *> triggers_{};
uint32_t can_id_;
bool use_extended_id_;
CanSpeed bit_rate_;
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
bool use_extended_id_{false};
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
+5
View File
@@ -2925,6 +2925,11 @@ async def to_code(config):
# produce reproducible outputs and downstream tooling can reuse artifacts.
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
# --undefined: libsrc.a is scanned before the IDF libraries that also register them.
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit")
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-DUSE_ESP_IDF")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
+20
View File
@@ -0,0 +1,20 @@
/*
* Linker wrap stub for __cxa_atexit().
*
* GCC registers the destructor of every global and function local static
* object with __cxa_atexit(). The firmware never returns from main or calls
* exit(), so these destructors never run, but the first registration links
* newlib's exit handler tables (about 400 bytes of RAM on ESP32).
*/
#include "esphome/core/defines.h"
#ifdef USE_ESP32
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP32
@@ -150,6 +150,8 @@ void BLEClientBase::connect() {
// A registration whose event never arrived must not block this connection's release.
this->services_released_ = false;
this->pending_notify_regs_ = 0;
// A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own.
this->conn_id_ = UNSET_CONN_ID;
// Enable loop for state processing
this->enable_loop();
// Immediately transition to CONNECTING to prevent duplicate connection attempts
@@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
if (!this->check_addr(param->open.remote_bda))
return false;
this->log_gattc_lifecycle_event_("OPEN");
// conn_id was already set in ESP_GATTC_CONNECT_EVT
this->service_count_ = 0;
// ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an
@@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
break;
}
if (this->state() != espbt::ClientState::CONNECTING) {
// This should not happen but lets log it in case it does
// because it means we have a bad assumption about how the
// ESP BT stack works.
const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN;
// Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT,
// so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption
// about how the ESP BT stack works, so log it.
const bool expected_failure =
open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID;
if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) {
ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_,
this->address_str_, espbt::client_state_to_string(this->state()), param->open.status);
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
if (open_failed) {
this->log_gattc_warning_("Connection open", param->open.status);
// Connection was never established so CLOSE_EVT may not follow
this->set_idle_();
break;
}
// An open on a link that is already up sends no CONNECT_EVT, so take the id here.
this->conn_id_ = param->open.conn_id;
if (this->want_disconnect_) {
// Disconnect was requested after connecting started,
// but before the connection was established. Now that we have
-2
View File
@@ -37,7 +37,6 @@ void GreeClimate::transmit_state() {
remote_state[1] = this->temperature_();
if (model == GREE_YAN) {
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[4] = this->vertical_swing_();
}
@@ -61,7 +60,6 @@ void GreeClimate::transmit_state() {
}
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010
+7 -1
View File
@@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00;
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9.
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG };
@@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR {
uint8_t preset_();
Model model_{};
uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2]
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
};
} // namespace esphome::gree
+19 -6
View File
@@ -17,12 +17,22 @@ CONF_HEALTH = "health"
CONF_XFAN = "xfan"
CONF_GREE_ID = "gree_id"
# Switch configurations: (config_key, display_name, bit_mask, icon)
# Switch configurations: (config_key, display_name, mode_bit, icon)
SWITCH_CONFIGS = (
(CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"),
(CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"),
(CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"),
(CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"),
(
CONF_TURBO,
"Gree Turbo Switch",
gree_ns.GREE_MODE_BIT_TURBO,
"mdi:car-turbocharger",
),
(CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"),
(CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"),
(
CONF_XFAN,
"Gree X-FAN Switch",
gree_ns.GREE_MODE_BIT_XFAN,
"mdi:wall-sconce-flat",
),
)
SUPPORTED_MODELS = {
@@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(key): switch.switch_schema(
GreeModeBitSwitch,
icon=icon,
default_restore_mode="RESTORE_DEFAULT_OFF",
# The display light was on before the switches existed, so it stays on by default
default_restore_mode="RESTORE_DEFAULT_ON"
if key == CONF_LIGHT
else "RESTORE_DEFAULT_OFF",
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
)
+6 -3
View File
@@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() {
swing_h_cmd = HDIR_SWING;
}
// MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto,
// so low/medium/high have to start at FAN_1 there instead of FAN_2.
const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC;
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
fan_speed_cmd = FAN_2;
fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2;
break;
case climate::CLIMATE_FAN_MEDIUM:
fan_speed_cmd = FAN_3;
fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3;
break;
case climate::CLIMATE_FAN_HIGH:
fan_speed_cmd = FAN_4;
fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4;
break;
case climate::CLIMATE_FAN_AUTO:
default:
@@ -18,6 +18,8 @@ namespace esphome::improv_serial {
static const char *const TAG = "improv_serial";
static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0;
void ImprovSerialComponent::setup() {
global_improv_serial_component = this;
#ifdef USE_IMPROV_SERIAL_UART
@@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(),
this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID);
this->set_state_(improv::STATE_PROVISIONED);
this->send_settings_response_(improv::WIFI_SETTINGS);
@@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
command.password.c_str());
this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
[this]() { this->on_wifi_connect_timeout_(); });
#else
// No Wi-Fi support compiled in; there is nothing to provision.
+3
View File
@@ -491,6 +491,9 @@ async def component_to_code(config):
# Not enabled on RTL87xx/LN882x — costs more heap than it saves there.
cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1")
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
# LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c).
if config[CONF_COMPONENT_ID] == COMPONENT_LN882X:
cg.add_build_flag("-Wl,--wrap=pvPortMalloc")
@@ -0,0 +1,20 @@
/*
* Linker wrap stub for __cxa_atexit().
*
* GCC registers the destructor of every global and function local static
* object with __cxa_atexit(). The firmware never returns from main or calls
* exit(), so these destructors never run, but the first registration links
* newlib's exit handler tables.
*/
#include "esphome/core/defines.h"
#ifdef USE_LIBRETINY
namespace esphome::libretiny {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_LIBRETINY
@@ -4,6 +4,8 @@
namespace esphome::mcp23xxx_base {
void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); }
template<uint8_t N> void MCP23XXXGPIOPin<N>::setup() {
this->pin_mode(flags_);
// When interrupt_pin is configured, pin_mode() already auto-enables CHANGE
@@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base {
enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING };
// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the
// ISR in flash.
void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg);
template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander<uint8_t, N> {
public:
virtual void pin_mode(uint8_t pin, gpio::Flags flags);
@@ -35,7 +39,8 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
void setup_interrupt_pin_() {
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast<Component *>(this),
gpio::INTERRUPT_FALLING_EDGE);
this->set_invalidate_on_read_(false);
}
// Disable loop until an input pin is configured via pin_mode()
@@ -43,7 +48,6 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
// For polling mode, loop is re-enabled when pin_mode() registers an input pin
this->disable_loop();
}
static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); }
// read a given register
virtual bool read_reg(uint8_t reg, uint8_t *value) = 0;
+2
View File
@@ -273,6 +273,8 @@ class MQTTComponent : public Component {
void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0);
protected:
static constexpr uint32_t SEND_DEFER_ID = 0;
/// Helper method to get the discovery topic for this component into a buffer.
StringRef get_discovery_topic_to_(std::span<char, MQTT_DISCOVERY_TOPIC_MAX_LEN> buf,
const MQTTDiscoveryInfo &discovery_info) const;
+1 -1
View File
@@ -121,7 +121,7 @@ void MQTTFanComponent::setup() {
});
}
this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); });
this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
void MQTTFanComponent::dump_config() {
+1 -1
View File
@@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() {
}
void MQTTJSONLightComponent::on_light_remote_values_update() {
this->defer("send", [this]() { this->publish_state_(); });
this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); });
}
MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {}
+1 -1
View File
@@ -29,7 +29,7 @@ void MQTTLockComponent::setup() {
}
});
this->lock_->add_on_state_callback(
[this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); });
[this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
void MQTTLockComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str());
+1 -1
View File
@@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() {
}
});
this->switch_->add_on_state_callback(
[this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); });
[this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); });
}
void MQTTSwitchComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str());
+1 -1
View File
@@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() {
}
});
this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); });
this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
bool MQTTUpdateComponent::publish_state() {
+42 -6
View File
@@ -12,6 +12,7 @@ from esphome.build_helpers import pch
from esphome.build_helpers.ccache import resolve_ccache_path
import esphome.codegen as cg
from esphome.components.zephyr import (
Section,
add_extra_script,
copy_files as zephyr_copy_files,
zephyr_add_overlay,
@@ -125,11 +126,37 @@ def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR
if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]])
sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]
if CORE.testing_mode:
sections = _testing_mode_sections(sections)
zephyr_add_pm_static(sections)
return config
# In testing mode, fake a larger flash to allow linking grouped component
# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot,
# which an openthread config alone fills; CI images are never flashed.
NRF52840_FLASH_SIZE = 0x100000
TESTING_FLASH_SIZE = 0x400000
def _testing_mode_sections(sections: list[Section]) -> list[Section]:
"""Move a bootloader pinned to the end of the real flash to the end of the
faked one, so the partition manager still sees a single gap for the app."""
return [
Section(
section.name,
section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE,
section.size,
section.region,
)
if section.end_address == NRF52840_FLASH_SIZE
else section
for section in sections
]
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
@@ -380,6 +407,15 @@ async def to_code(config: ConfigType) -> None:
zephyr_setup_preferences()
zephyr_to_code(config)
if CORE.testing_mode:
zephyr_add_overlay(
f"""
&flash0 {{
reg = <0x0 {TESTING_FLASH_SIZE:#x}>;
}};
"""
)
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -695,13 +731,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
return True # Handled: PYOCD upload
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
from .ble_logger import is_mac_address
from .ble_logger import is_ble_address
from .ota import smpmgr_scan, smpmgr_upload
if host == "BLE":
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
if is_mac_address(host):
if is_ble_address(host):
mcumgr_device = host
if mcumgr_device:
@@ -716,7 +752,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_mac_address, logger_connect, logger_scan
from .ble_logger import is_ble_address, logger_connect, logger_scan
if devices[0] == "BLE":
ble_device = asyncio.run(logger_scan(CORE.name))
@@ -725,7 +761,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
else:
return True
if is_mac_address(address):
if is_ble_address(address):
asyncio.run(logger_connect(address))
return True
return False
@@ -798,7 +834,7 @@ _PCH_CMAKE_LINES = [
"set(esphome_kept_options)",
"set(esphome_pch_headers)",
"foreach(option IN LISTS esphome_options)",
' if(option MATCHES "imacros> (.+)$")',
' if(option MATCHES "imacros> ([^>]+)")',
' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")',
" list(APPEND esphome_kept_options",
' "$<$<NOT:$<AND:$<COMPILE_LANGUAGE:CXX>,$<STREQUAL:$<TARGET_PROPERTY:NAME>,app>>>:${option}>")',
+20 -3
View File
@@ -4,6 +4,7 @@ import re
from typing import Final
from bleak import BleakClient, BleakScanner, BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import (
BleakCharacteristicNotFoundError,
BleakDBusError,
@@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
MAC_ADDRESS_PATTERN: Final = re.compile(
r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE
)
# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address
UUID_PATTERN: Final = re.compile(
r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$",
flags=re.IGNORECASE,
)
def is_mac_address(value: str) -> bool:
return MAC_ADDRESS_PATTERN.match(value)
def is_ble_address(value: str) -> bool:
return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value))
def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool:
# device.name can be a stale cached name on macOS; adv.local_name is what is on the air now
return name in (device.name, adv.local_name)
async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None:
return await BleakScanner.find_device_by_filter(
lambda device, adv: _name_matches(device, adv, name), timeout=timeout
)
async def logger_scan(name: str) -> BLEDevice | None:
_LOGGER.info("Scanning bluetooth for %s...", name)
device = await BleakScanner.find_device_by_name(name)
device = await find_device_by_name(name)
if not device:
_LOGGER.error("%s Bluetooth LE device was not found!", name)
return device
+10 -11
View File
@@ -4,7 +4,6 @@ import json
import logging
from pathlib import Path
from bleak import BleakScanner
from bleak.exc import BleakDBusError, BleakDeviceNotFoundError
from smp.exceptions import SMPBadStartDelimiter
from smpclient import SMPClient
@@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport
from esphome.core import EsphomeError
from esphome.espota2 import ProgressBar
from .ble_logger import is_mac_address
from .ble_logger import find_device_by_name, is_ble_address
SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84"
BLE_SCAN_TIMEOUT = 10.0 # seconds
RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute
@@ -45,12 +43,12 @@ def _json_state(o: object) -> object:
async def smpmgr_scan(name: str) -> str:
_LOGGER.info("Scanning bluetooth for %s...", name)
for device in await BleakScanner.discover(
timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID]
):
if device.name == name:
return device.address
raise EsphomeError(f"BLE device {name} with OTA service not found")
# No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on.
# The SMP service is checked on connect.
device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT)
if device is None:
raise EsphomeError(f"BLE device {name} not found")
return device.address
async def smpmgr_upload(device: str, firmware: Path) -> None:
@@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes:
async def _smpmgr_upload(device: str, firmware: Path) -> None:
image_tlv_sha256 = _get_image_tlv_sha256(firmware)
if is_mac_address(device):
if is_ble_address(device):
smp_client = SMPClient(SMPBLETransport(), device)
else:
smp_client = SMPClient(SMPSerialTransport(), device)
@@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None:
) from exc
raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc
except SMPBLETransportException as exc:
raise EsphomeError(f"Connection error with {device}") from exc
# Reached when a device with the right name has no SMP service, among other causes
raise EsphomeError(f"Connection error with {device}: {exc}") from exc
_LOGGER.info("Connected %s...", device)
try:
+2 -6
View File
@@ -306,7 +306,7 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
// play_next_() can trigger more items to be queued
if (!this->param_queue_.empty()) {
auto &params = this->param_queue_.front();
this->play_next_tuple_(params, std::make_index_sequence<sizeof...(Ts)>{});
std::apply([this](auto &...args) { this->play_next_(args...); }, params);
this->param_queue_.pop_front();
} else {
// Queue is now empty - disable loop until next play_complex
@@ -323,12 +323,8 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
}
protected:
template<size_t... S> void play_next_tuple_(const std::tuple<Ts...> &tuple, std::index_sequence<S...> /*unused*/) {
this->play_next_(std::get<S>(tuple)...);
}
C *script_;
std::list<std::tuple<Ts...>> param_queue_;
std::list<std::tuple<stored_trigger_arg_t<Ts>...>> param_queue_;
};
} // namespace esphome::script
@@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 {
static const char *const TAG = "seeed_mr24hpc1";
static constexpr uint32_t STARTUP_DELAY_MS = 1000;
// Prints the component's configuration data. dump_config() prints all of the component's configuration
// items in an easy-to-read format, including the configuration key-value pairs.
void MR24HPC1Component::dump_config() {
@@ -76,8 +78,7 @@ void MR24HPC1Component::setup() {
this->custom_mode_end_text_sensor_->publish_state("Not in custom mode");
}
#endif
this->set_custom_end_mode();
this->poll_time_base_func_check_ = true;
this->poll_time_base_func_check_ = false;
this->check_dev_inf_sign_ = true;
this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE;
this->sg_data_len_ = 0;
@@ -92,7 +93,13 @@ void MR24HPC1Component::setup() {
memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE);
memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE);
this->set_interval(8000, [this]() { this->update_(); });
// The module locks up when queried during its own boot, so hold all TX for a second
this->set_timeout(STARTUP_DELAY_MS, [this]() {
this->module_ready_ = true;
this->set_custom_end_mode();
this->poll_time_base_func_check_ = true;
this->set_interval(8000, [this]() { this->update_(); });
});
}
// Timed polling of radar data
@@ -686,6 +693,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) {
// Sending data frames
void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) {
if (!this->module_ready_)
return;
this->write_array(query, string_length);
}
@@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component,
int sg_start_query_data_;
bool check_dev_inf_sign_;
bool poll_time_base_func_check_;
bool module_ready_{false};
void update_();
void r24_split_data_frame_(uint8_t value);
+2 -1
View File
@@ -4,7 +4,8 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@0hax"]
CODEOWNERS = ["@0hax", "@FredM67"]
DOMAIN = "teleinfo"
MULTI_CONF = True
teleinfo_ns = cg.esphome_ns.namespace("teleinfo")
+136 -8
View File
@@ -1,10 +1,32 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MILLIVOLT,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
@@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein
TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = sensor.sensor_schema(
TeleInfoSensor,
unit_of_measurement=UNIT_WATT_HOURS,
icon=ICON_FLASH,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
).extend(TELEINFO_LISTENER_SCHEMA)
def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]:
return {
CONF_UNIT_OF_MEASUREMENT: unit,
CONF_DEVICE_CLASS: device_class,
CONF_STATE_CLASS: state_class,
}
_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING)
_REACTIVE_ENERGY = _preset(
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
)
_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT)
_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT)
_APPARENT_POWER_VA = _preset(
UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_APPARENT_POWER_KVA = _preset(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT)
# Legacy defaults, kept for tags without a known preset
_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH}
# Units that are valid for each device class, used to decide whether a preset's
# device class can be kept when the user overrides the unit (and vice versa)
_COMPATIBLE_UNITS = {
DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS},
DEVICE_CLASS_REACTIVE_ENERGY: {
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
},
DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP},
DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT},
DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS},
DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT},
}
# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant.
TIC_TAG_CONFIGS = {
# Standard mode
"EA": _ENERGY,
"ER": _REACTIVE_ENERGY,
"IRMS": _CURRENT,
"URMS": _VOLTAGE,
"UMOY": _VOLTAGE,
"SINST": _APPARENT_POWER_VA,
"SMAX": _APPARENT_POWER_VA,
"CC": _POWER,
"PREF": _APPARENT_POWER_KVA,
"PCOUP": _APPARENT_POWER_KVA,
# Historical mode
"BASE": _ENERGY,
"HCH": _ENERGY,
"EJP": _ENERGY,
"BBRH": _ENERGY,
"IINST": _CURRENT,
"IMAX": _CURRENT,
"ISOUSC": _CURRENT,
"ADPS": _CURRENT,
"ADIR": _CURRENT,
"PAPP": _APPARENT_POWER_VA,
"PMAX": _POWER,
}
def apply_tag_config(config: ConfigType) -> ConfigType:
"""Apply preset configurations based on the tag name.
Only keys not set by the user are filled in. For known tags, a preset unit or
device class is dropped if it is incompatible with the one set by the user.
Unknown tags get the legacy defaults, merged key by key as before.
"""
if CONF_TAG_NAME not in config:
return config
# Runs before cv.string, so the raw value may not be a string yet
tag_name = str(config[CONF_TAG_NAME])
preset = next(
(p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)),
None,
)
config = dict(config)
if preset is None:
for key, value in _LEGACY_DEFAULT.items():
config.setdefault(key, value)
return config
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
for key, value in preset.items():
if key in config:
continue
if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible:
continue
config[key] = value
return config
CONFIG_SCHEMA = cv.All(
apply_tag_config,
sensor.sensor_schema(
TeleInfoSensor,
accuracy_decimals=0,
).extend(TELEINFO_LISTENER_SCHEMA),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID])
cg.add(teleinfo.register_teleinfo_listener(var))
+3 -4
View File
@@ -116,8 +116,7 @@ void TeleInfo::loop() {
* 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd
*
*/
while ((buf_finger = static_cast<char *>(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) &&
((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare)
while ((buf_finger = static_cast<char *>(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) {
/*
* Make sure timesamp is nullified between each tag as some tags don't
* have a timestamp
@@ -168,8 +167,8 @@ void TeleInfo::loop() {
continue;
}
/* Advance buf_finger to end of group */
buf_finger += field_len + 1 + 1 + 1;
/* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */
buf_finger = grp_end + 1;
publish_value_(std::string(tag_), std::string(val_));
}
+8
View File
@@ -26,6 +26,8 @@ from esphome.const import (
CONF_SECONDS,
CONF_TIMEZONE,
CONF_TRIGGER_ID,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
@@ -462,6 +464,12 @@ async def register_time(time_var, config):
async def to_code(config):
if CORE.using_zephyr:
zephyr_add_prj_conf("POSIX_CLOCK", True)
# From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(),
# which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time()
# always returns -1.
if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0):
zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True)
zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True)
cg.add_define("USE_TIME")
cg.add_global(time_ns.using)
@@ -0,0 +1,179 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "esphome/core/helpers.h"
namespace esphome::uart {
/// Software serial RX decoder: on_edge() counts bits from the time between edges;
/// finalize() completes a byte whose idle high tail has no closing edge.
/// Times are in any steady tick unit.
class SoftwareSerialRxDecoder {
public:
static constexpr uint8_t RX_IDLE = 0xFF;
/// Configure framing and buffer; drops all state. Follow with reset().
void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer,
size_t buffer_size) {
this->bit_ticks_ = bit_ticks;
this->data_bits_ = data_bits;
this->stop_bit_ = data_bits + (parity ? 1 : 0);
this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2);
this->buffer_ = buffer;
this->buffer_size_ = buffer_size;
this->in_pos_ = 0;
this->out_pos_ = 0;
this->reset(0, true);
}
/// Forget any partial frame; `level` is the current line level.
void reset(uint32_t now, bool level) {
this->bit_ = RX_IDLE;
this->cur_byte_ = 0;
this->last_level_ = level;
this->last_edge_ = now;
}
/// ISR. True to wake the loop: a byte was pushed, or the line went high in an
/// open frame (a data 1 and the idle tail look alike) so finalize() may be needed.
bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) {
const bool last_level = this->last_level_;
// Collapsed edge
if (level == last_level)
return false;
// LX106 has no divider, so count
uint32_t delta = now - this->last_edge_;
if (delta > this->max_run_ticks_)
delta = this->max_run_ticks_;
delta += this->bit_ticks_ / 2;
uint32_t bits = 0;
while (delta >= this->bit_ticks_) {
delta -= this->bit_ticks_;
bits++;
}
const bool pushed = this->consume_run_(bits, last_level);
this->last_edge_ = now;
this->last_level_ = level;
return pushed || (level && this->bit_ != RX_IDLE);
}
bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; }
bool finalize_due(uint32_t now) const {
const uint8_t bit = this->bit_;
if (bit == RX_IDLE)
return false;
return now - this->last_edge_ >= this->tail_ticks_(bit);
}
/// Call with the ISR masked.
void finalize(uint32_t now) {
if (!this->last_level_ || !this->finalize_due(now))
return;
// Remaining bits are all ones
const uint8_t bit = this->bit_;
uint8_t cur = this->cur_byte_;
if (bit < this->data_bits_)
cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1);
this->push_byte(cur);
this->bit_ = RX_IDLE;
}
/// Drops the byte when full; holds buffer_size - 1 bytes.
bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) {
size_t in = this->in_pos_;
size_t next = in + 1;
if (next == this->buffer_size_)
next = 0;
if (next == this->out_pos_)
return false;
this->buffer_[in] = data;
this->in_pos_ = next;
return true;
}
uint8_t *buffer() const { return this->buffer_; }
size_t buffer_size() const { return this->buffer_size_; }
size_t available() const {
// Read volatile in_pos_ once to avoid TOCTOU race with ISR.
size_t in = this->in_pos_;
if (in >= this->out_pos_)
return in - this->out_pos_;
return this->buffer_size_ - this->out_pos_ + in;
}
uint8_t peek_byte() const {
if (this->in_pos_ == this->out_pos_)
return 0;
return this->buffer_[this->out_pos_];
}
uint8_t read_byte() {
if (this->in_pos_ == this->out_pos_)
return 0;
uint8_t data = this->buffer_[this->out_pos_];
size_t next = this->out_pos_ + 1;
this->out_pos_ = next == this->buffer_size_ ? 0 : next;
return data;
}
protected:
uint32_t tail_ticks_(uint8_t bit) const {
return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2;
}
bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) {
uint8_t bit = this->bit_;
uint8_t cur = this->cur_byte_;
bool pushed = false;
while (bits > 0) {
if (bit == RX_IDLE) {
if (level)
break;
// Start bit
bit = 0;
cur = 0;
bits--;
} else if (bit < this->data_bits_) {
uint8_t n = this->data_bits_ - bit;
if (n > bits)
n = bits;
if (level)
cur |= ((1U << n) - 1) << bit;
bit += n;
bits -= n;
} else if (bit < this->stop_bit_) {
// Parity, not checked
bit++;
bits--;
} else {
// Stop bit; low is a framing error
if (level)
pushed = this->push_byte(cur);
bit = RX_IDLE;
break;
}
}
this->bit_ = bit;
this->cur_byte_ = cur;
return pushed;
}
// Members ordered largest to smallest to minimize padding
uint32_t bit_ticks_{0};
uint32_t max_run_ticks_{0};
volatile uint32_t last_edge_{0};
uint8_t *buffer_{nullptr};
size_t buffer_size_{0};
volatile size_t in_pos_{0};
size_t out_pos_{0}; // reader only
/// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE.
volatile uint8_t bit_{RX_IDLE};
volatile uint8_t cur_byte_{0};
volatile bool last_level_{true};
uint8_t data_bits_{8};
uint8_t stop_bit_{8};
};
} // namespace esphome::uart
+2 -2
View File
@@ -10,8 +10,8 @@ namespace esphome::uart {
static const char *const TAG = "uart";
// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD
PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN");
// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE
PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN");
void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity,
uint8_t data_bits) {
+3
View File
@@ -18,6 +18,9 @@ enum UARTParityOptions {
UART_CONFIG_PARITY_NONE,
UART_CONFIG_PARITY_EVEN,
UART_CONFIG_PARITY_ODD,
// Only USB serial adapters (usb_uart) support these
UART_CONFIG_PARITY_MARK,
UART_CONFIG_PARITY_SPACE,
};
#ifdef USE_UART_DEBUGGER
@@ -15,7 +15,13 @@
namespace esphome::uart {
static const char *const TAG = "uart";
ESPHOME_LOG_TAG(TAG, "uart");
// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes.
static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200;
// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a
// CpuFrequencyBoost; the cycle counter does not.
static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000;
__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; }
bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
uint32_t ESP8266UartComponent::get_config() {
@@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() {
"\n Wake on data RX: ENABLED"
#endif
);
if (this->rx_pin_ != nullptr) {
ESP_LOGCONFIG(TAG, " RX decoder: %s",
this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge")
: LOG_STR_LITERAL("start bit sampler"));
}
}
this->check_logger_conflict();
}
@@ -239,23 +250,39 @@ UARTFlushResult ESP8266UartComponent::flush() {
void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate,
uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity,
size_t rx_buffer_size) {
// load_settings() re-enters here
if (this->gpio_rx_pin_ != nullptr)
this->gpio_rx_pin_->detach_interrupt();
this->bit_time_ = F_CPU / baud_rate;
this->rx_buffer_size_ = rx_buffer_size;
this->stop_bits_ = stop_bits;
this->data_bits_ = data_bits;
this->parity_ = parity;
// to_isr() allocates, so call it only for a new or changed pin
if (tx_pin != nullptr) {
if (tx_pin != this->gpio_tx_pin_)
this->tx_pin_ = tx_pin->to_isr();
gpio_tx_pin_ = tx_pin;
gpio_tx_pin_->setup();
tx_pin_ = gpio_tx_pin_->to_isr();
tx_pin_.digital_write(true);
}
if (rx_pin != nullptr) {
if (rx_pin != this->gpio_rx_pin_)
this->rx_pin_ = rx_pin->to_isr();
gpio_rx_pin_ = rx_pin;
gpio_rx_pin_->setup();
rx_pin_ = gpio_rx_pin_->to_isr();
rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
uint8_t *buffer = this->rx_.buffer();
if (this->rx_.buffer_size() != rx_buffer_size) {
delete[] buffer; // NOLINT
buffer = new uint8_t[rx_buffer_size]; // NOLINT
}
this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE,
stop_bits, buffer, rx_buffer_size);
this->rx_.reset(rx_now(), this->rx_pin_.digital_read());
if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) {
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE);
} else {
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
}
}
}
// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles
@@ -288,8 +315,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
if (arg->stop_bits_ == 2)
arg->wait_(&wait, start, bit_time);
arg->rx_buffer_[arg->rx_in_pos_] = rec;
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
arg->rx_.push_byte(rec);
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
@@ -299,6 +325,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
wake_loop_isrsafe();
#endif
}
void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) {
const uint32_t now = rx_now();
const bool level = arg->rx_pin_.digital_read();
[[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level);
#ifdef USE_UART_WAKE_LOOP_ON_RX
if (wake)
wake_loop_isrsafe();
#endif
}
void ESP8266SoftwareSerial::rx_finalize_pending_() {
if (!this->rx_.finalize_due(rx_now())) {
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Tail not elapsed; wake again only once drained
if (this->rx_.available() == 0)
wake_loop_threadsafe();
#endif
return;
}
InterruptLock lock;
this->rx_.finalize(rx_now());
}
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) {
ESP_LOGE(TAG, "UART doesn't have TX pins set!");
@@ -350,28 +397,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const
this->wait_(wait, start, bit_time);
}
uint8_t ESP8266SoftwareSerial::read_byte() {
if (this->rx_in_pos_ == this->rx_out_pos_)
return 0;
uint8_t data = this->rx_buffer_[this->rx_out_pos_];
this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_;
return data;
this->rx_sync_();
return this->rx_.read_byte();
}
uint8_t ESP8266SoftwareSerial::peek_byte() {
if (this->rx_in_pos_ == this->rx_out_pos_)
return 0;
return this->rx_buffer_[this->rx_out_pos_];
this->rx_sync_();
return this->rx_.peek_byte();
}
void ESP8266SoftwareSerial::flush() {
// Flush is a NO-OP with software serial, all bytes are written immediately.
}
size_t ESP8266SoftwareSerial::available() {
// Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR.
// When in >= out, data is contiguous: [out..in).
// When in < out, data wraps: [out..buf_size) + [0..in).
size_t in = this->rx_in_pos_;
if (in >= this->rx_out_pos_)
return in - this->rx_out_pos_;
return this->rx_buffer_size_ - this->rx_out_pos_ + in;
this->rx_sync_();
return this->rx_.available();
}
} // namespace esphome::uart
@@ -7,6 +7,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "software_serial_rx_decoder.h"
#include "uart_component.h"
namespace esphome::uart {
@@ -26,24 +27,30 @@ class ESP8266SoftwareSerial {
size_t available();
protected:
/// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR.
static void gpio_intr(ESP8266SoftwareSerial *arg);
static void gpio_intr_edge(ESP8266SoftwareSerial *arg);
void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time);
bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time);
void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time);
void rx_finalize_pending_();
void ESPHOME_ALWAYS_INLINE rx_sync_() {
if (this->rx_.pending())
this->rx_finalize_pending_();
}
// Members ordered largest to smallest to minimize padding
uint32_t bit_time_{0};
uint8_t *rx_buffer_{nullptr};
size_t rx_buffer_size_;
volatile size_t rx_in_pos_{0};
size_t rx_out_pos_{0};
uint8_t stop_bits_;
uint8_t data_bits_;
UARTParityOptions parity_;
InternalGPIOPin *gpio_tx_pin_{nullptr};
ISRInternalGPIOPin tx_pin_;
InternalGPIOPin *gpio_rx_pin_{nullptr};
ISRInternalGPIOPin rx_pin_;
SoftwareSerialRxDecoder rx_;
UARTParityOptions parity_;
uint8_t stop_bits_;
uint8_t data_bits_;
};
class ESP8266UartComponent final : public UARTComponent, public Component {
@@ -158,6 +158,8 @@ void HostUartComponent::setup() {
options.c_cflag |= PARENB;
options.c_cflag |= PARODD;
break;
default:
break;
};
// Set stop bits
if (this->stop_bits_ == 2) {
@@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() {
case UART_CONFIG_PARITY_ODD:
config |= SERIAL_PARITY_ODD;
break;
default:
break;
}
config |= (this->data_bits_ - 4) << 8;
+39 -14
View File
@@ -1,4 +1,5 @@
from itertools import combinations
import itertools
import logging
import esphome.codegen as cg
from esphome.components.const import CONF_MANUFACTURER
@@ -22,6 +23,8 @@ import esphome.final_validate as fv
from esphome.helpers import cpp_u16string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["bytebuffer"]
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["esp32"]
@@ -71,15 +74,19 @@ def usb_device_schema(
)
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _clients_overlap(first: ConfigType, second: ConfigType) -> bool:
# Two entries overlap when no field they both constrain tells them apart
for first, second in combinations(configs, 2):
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
break
else:
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
return False
return True
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
for first, second in itertools.combinations(configs, 2):
if _clients_overlap(first, second):
raise cv.Invalid(
f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}"
)
@@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _final_validate(config: ConfigType) -> ConfigType:
# Every USB client on the bus, whichever component configured it: any two could
# otherwise open the same device
clients = list(config.get(CONF_DEVICES) or ())
clients.extend(fv.full_config.get().get("usb_uart") or ())
validate_usb_clients(clients)
# Any two overlapping clients could otherwise open the same device
devices = config.get(CONF_DEVICES) or []
uarts = fv.full_config.get().get("usb_uart") or []
validate_usb_clients(devices)
validate_usb_clients(uarts)
# Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call
for device, uart in itertools.product(devices, uarts):
if not _clients_overlap(device, uart):
continue
# The device matches nothing the usb_uart entry does not already claim
redundant = all(
uart.get(key) in (wildcard, device.get(key))
for key, wildcard in _FILTER_WILDCARDS.items()
)
_LOGGER.warning(
"usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. "
"This will be an error in 2027.10.0",
device[CONF_ID],
uart[CONF_ID],
"remove it from usb_host devices"
if redundant
else "narrow its filter so it no longer matches that device",
)
return config
+6 -2
View File
@@ -1,7 +1,12 @@
import esphome.codegen as cg
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent
from esphome.components.uart import (
CONF_DEBUG_PREFIX,
CONF_FLUSH_TIMEOUT,
UARTComponent,
UARTParityOptions,
)
from esphome.components.usb_host import (
get_max_packet_size,
register_usb_client,
@@ -38,7 +43,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool:
)
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
UART_PARITY_OPTIONS = {
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
"EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN,
+5 -8
View File
@@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
factor = 256 - factor;
uint16_t value = 0xC0;
if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2)
if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2)
value |= 4;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
break;
default:
value |= 8 | ((channel->parity_ - 1) << 4);
break;
}
// Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space
const LineParity parity = channel->line_parity_();
if (parity != LineParity::LINE_PARITY_NONE)
value |= 8 | ((static_cast<uint8_t>(parity) - 1) << 4);
value |= channel->data_bits_ - 5;
value <<= 8;
value |= 0x8C;
+2 -2
View File
@@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_);
return true;
case 1: {
uint16_t line_control = channel->stop_bits_;
line_control |= static_cast<uint8_t>(channel->parity_) << 4;
uint16_t line_control = static_cast<uint8_t>(channel->line_stop_bits_());
line_control |= static_cast<uint8_t>(channel->line_parity_()) << 4;
line_control |= channel->data_bits_ << 8;
ESP_LOGD(TAG, "Line control value 0x%X", line_control);
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_);
+2 -28
View File
@@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b
}
case 2: { // set line properties (data bits / parity / stop bits)
uint16_t value = channel->data_bits_;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
value |= (0x00 << 8);
break;
case UART_CONFIG_PARITY_ODD:
value |= (0x01 << 8);
break;
case UART_CONFIG_PARITY_EVEN:
value |= (0x02 << 8);
break;
case UART_CONFIG_PARITY_MARK:
value |= (0x03 << 8);
break;
case UART_CONFIG_PARITY_SPACE:
value |= (0x04 << 8);
break;
}
switch (channel->stop_bits_) {
default: // 1 bit
value |= (0x00 << 11);
break;
case UART_CONFIG_STOP_BITS_1_5:
value |= (0x01 << 11);
break;
case UART_CONFIG_STOP_BITS_2:
value |= (0x02 << 11);
break;
}
value |= static_cast<uint8_t>(channel->line_parity_()) << 8;
value |= static_cast<uint8_t>(channel->line_stop_bits_()) << 11;
value |= (0x00 << 14);
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value,
channel->cdc_dev_.bulk_interface_number + 1);
+2 -27
View File
@@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b
encode_baud_divisor(line_coding, baud);
}
// Stop bits: 0=1, 1=1.5, 2=2
switch (channel->get_stop_bits()) {
case 2:
line_coding[4] = 2;
break;
default:
line_coding[4] = 0;
break;
}
line_coding[4] = static_cast<uint8_t>(channel->line_stop_bits_());
// Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space
switch (channel->parity_) {
case UART_CONFIG_PARITY_ODD:
line_coding[5] = 1;
break;
case UART_CONFIG_PARITY_EVEN:
line_coding[5] = 2;
break;
case UART_CONFIG_PARITY_MARK:
line_coding[5] = 3;
break;
case UART_CONFIG_PARITY_SPACE:
line_coding[5] = 4;
break;
default:
line_coding[5] = 0;
break;
}
line_coding[5] = static_cast<uint8_t>(channel->line_parity_());
// Data bits
line_coding[6] = channel->get_data_bits();
+40 -10
View File
@@ -270,9 +270,10 @@ void USBUartComponent::dump_config() {
" Flush Timeout: %" PRIu32 " ms\n"
" Debug: %s\n"
" Dummy receiver: %s",
channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_],
STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_),
YESNO(channel->dummy_receiver_));
channel->index_, channel->baud_rate_, channel->data_bits_,
PARITY_NAMES[static_cast<uint8_t>(channel->line_parity_())],
STOP_BITS_NAMES[static_cast<uint8_t>(channel->line_stop_bits_())], channel->flush_timeout_ms_,
YESNO(channel->debug_), YESNO(channel->dummy_receiver_));
}
}
void USBUartComponent::start_input(USBUartChannelBase *channel) {
@@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b
// sends RSTACK.
uint32_t baud = channel->baud_rate_;
std::vector<uint8_t> line_coding = {
static_cast<uint8_t>(baud & 0xFF), static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF), static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop
static_cast<uint8_t>(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space
static_cast<uint8_t>(channel->data_bits_), // bDataBits
static_cast<uint8_t>(baud & 0xFF),
static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF),
static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->line_stop_bits_()), // bCharFormat
static_cast<uint8_t>(channel->line_parity_()), // bParityType
static_cast<uint8_t>(channel->data_bits_), // bDataBits
};
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_,
(unsigned) channel->parity_, channel->data_bits_);
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud,
STOP_BITS_NAMES[static_cast<uint8_t>(channel->line_stop_bits_())],
PARITY_NAMES[static_cast<uint8_t>(channel->line_parity_())], channel->data_bits_);
this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number,
line_coding);
return true;
@@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() {
return true;
}
LineStopBits USBUartChannelBase::line_stop_bits_() const {
switch (this->stop_bits_) {
case UART_CONFIG_STOP_BITS_1_5:
return LineStopBits::LINE_STOP_BITS_1_5;
case UART_CONFIG_STOP_BITS_2:
return LineStopBits::LINE_STOP_BITS_2;
default:
return LineStopBits::LINE_STOP_BITS_1;
}
}
LineParity USBUartChannelBase::line_parity_() const {
switch (this->parity_) {
case uart::UART_CONFIG_PARITY_ODD:
return LineParity::LINE_PARITY_ODD;
case uart::UART_CONFIG_PARITY_EVEN:
return LineParity::LINE_PARITY_EVEN;
case uart::UART_CONFIG_PARITY_MARK:
return LineParity::LINE_PARITY_MARK;
case uart::UART_CONFIG_PARITY_SPACE:
return LineParity::LINE_PARITY_SPACE;
default:
return LineParity::LINE_PARITY_NONE;
}
}
void USBUartChannelBase::load_settings(bool /*dump_config*/) {
// The per-channel control transfers already log their values at debug level.
this->parent_->apply_channel_settings(this);
+25 -28
View File
@@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t {
CHIP_UNKNOWN = 0xFF,
};
enum UARTParityOptions {
UART_CONFIG_PARITY_NONE = 0,
UART_CONFIG_PARITY_ODD,
UART_CONFIG_PARITY_EVEN,
UART_CONFIG_PARITY_MARK,
UART_CONFIG_PARITY_SPACE,
};
// 1 and 2 are the stop bit count the uart component uses, so a count set through the
// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count.
enum UARTStopBitsOptions {
UART_CONFIG_STOP_BITS_1 = 0,
UART_CONFIG_STOP_BITS_1_5,
UART_CONFIG_STOP_BITS_2,
UART_CONFIG_STOP_BITS_1 = 1,
UART_CONFIG_STOP_BITS_2 = 2,
UART_CONFIG_STOP_BITS_1_5 = 3,
};
// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the
// CP210x, FTDI and PL2303 requests, all use these values
enum class LineStopBits : uint8_t {
LINE_STOP_BITS_1 = 0,
LINE_STOP_BITS_1_5 = 1,
LINE_STOP_BITS_2 = 2,
};
enum class LineParity : uint8_t {
LINE_PARITY_NONE = 0,
LINE_PARITY_ODD = 1,
LINE_PARITY_EVEN = 2,
LINE_PARITY_MARK = 3,
LINE_PARITY_SPACE = 4,
};
// Indexed by LineParity
static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"};
// Indexed by LineStopBits
static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"};
class RingBuffer {
@@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
// Re-apply the current line settings (baud, parity, etc) to this already-open channel.
void load_settings(bool dump_config) override;
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
void set_parity(UARTParityOptions parity) {
this->parity_ = parity;
// Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value.
// MARK/SPACE have no uart:: equivalent and report as NONE.
switch (parity) {
case UART_CONFIG_PARITY_EVEN:
uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN);
break;
case UART_CONFIG_PARITY_ODD:
uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD);
break;
default:
uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE);
break;
}
}
void set_debug(bool debug) { this->debug_ = debug; }
void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; }
void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); }
@@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
// Not directly instantiable; construct a concrete channel type instead.
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
void check_logger_conflict() override {}
LineStopBits line_stop_bits_() const;
LineParity line_parity_() const;
// Larger structures first (8+ bytes)
RingBuffer input_buffer_;
LockFreeQueue<UsbOutputChunk, USB_OUTPUT_CHUNK_COUNT> output_queue_;
@@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
CdcEps cdc_dev_{};
StringRef debug_prefix_{};
// 4-byte fields
UARTParityOptions parity_{UART_CONFIG_PARITY_NONE};
uint32_t flush_timeout_ms_{100};
// 1-byte fields (no padding between groups)
std::atomic<bool> input_started_{true};
+7 -4
View File
@@ -76,13 +76,13 @@ template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>,
// Enable WiFi
global_wifi_component->enable();
// Set timeout for the connection
this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() {
this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() {
// If the timeout is reached, stop connecting and revert to the old AP
global_wifi_component->disable();
global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password());
global_wifi_component->enable();
// Start a timeout for the fallback if the connection to the old AP fails
this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() {
this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() {
this->connecting_ = false;
this->error_trigger_.trigger();
});
@@ -97,8 +97,8 @@ template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>,
return;
if (global_wifi_component->is_connected()) {
// The WiFi is connected, stop the timeout and reset the connecting flag
this->cancel_timeout("wifi-connect-timeout");
this->cancel_timeout("wifi-fallback-timeout");
this->cancel_timeout(CONNECT_TIMEOUT_ID);
this->cancel_timeout(FALLBACK_TIMEOUT_ID);
this->connecting_ = false;
char ssid_buf[SSID_BUFFER_SIZE];
if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) {
@@ -112,6 +112,9 @@ template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>,
}
protected:
static constexpr uint32_t CONNECT_TIMEOUT_ID = 0;
static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1;
bool connecting_{false};
WiFiAP new_sta_;
WiFiAP old_sta_;
+2 -1
View File
@@ -398,6 +398,7 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
/// disconnect event cannot keep the networks from being tried for good.
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
#endif
/// Cooldown duration when fallback AP is active and captive portal may be running
@@ -1222,7 +1223,7 @@ void WiFiComponent::connect_soon_() {
// New credentials from the portal: the AP pauses for them once its answer
// is out, and the STA state machine picks them up from there.
if (this->ap_setup_) {
this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() {
this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() {
if (this->ap_setup_)
this->pause_exclusive_ap_();
});
@@ -13,6 +13,7 @@ from esphome.components.zephyr import (
from esphome.components.zephyr.const import (
BOOTLOADER_MCUBOOT,
KEY_BOOTLOADER,
KEY_PM_STATIC,
KEY_SYSBUILD,
)
from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu
@@ -146,14 +147,15 @@ async def to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True)
zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True)
zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True)
# OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific --
# every mcumgr transport needs it, not just BLE.
zephyr_add_prj_conf("MCUMGR_GRP_OS", True)
zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True)
transport = config[CONF_TRANSPORT]
if transport[CONF_BLE]:
zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True)
zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True)
zephyr_add_prj_conf("MCUMGR_GRP_OS", True)
zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True)
zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True)
request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP
if CONF_HARDWARE_UART in transport:
@@ -207,10 +209,11 @@ async def to_code(config: ConfigType) -> None:
bootloader = zephyr_data()[KEY_BOOTLOADER]
if bootloader != BOOTLOADER_MCUBOOT:
sections = BOOTLOADER_CONFIG[bootloader]
# Derive partition addresses from the SoftDevice and bootloader sections so
# that the DTS flash map matches what the Partition Manager produces:
# Derive partition addresses from the SoftDevice and bootloader sections
# registered with the Partition Manager (moved in testing mode) so that
# the DTS flash map matches what it produces:
# MCUboot sits immediately after the SoftDevice, then slot0, then slot1.
sections = zephyr_data()[KEY_PM_STATIC]
mcuboot_size = 0x9000
sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name)
bl_start = next(s.address for s in sections if "Adafruit" in s.name)
@@ -14,6 +14,7 @@ from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
get_slot_index,
zigbee_assign,
zigbee_new_attr_list,
zigbee_new_cluster_list,
zigbee_new_variable,
@@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None:
# Create the binary attributes structure
time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t")
zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE"))
attr_list = zigbee_new_attr_list(
attr_list_name,
"ZB_ZCL_DECLARE_TIME_ATTR_LIST",
@@ -2,6 +2,7 @@
#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include <limits>
namespace esphome::zigbee {
@@ -10,12 +11,14 @@ static const char *const TAG = "zigbee.time";
// This time standard is the number of
// seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time).
constexpr time_t EPOCH_2000 = 946684800;
// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE.
constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits<uint32_t>::max() - EPOCH_2000;
ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint,
zb_uint32_t nw_time) {
if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) {
if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) {
global_time->set_epoch_time(nw_time + EPOCH_2000);
} else if (status != RET_TIMEOUT || !global_time->has_time_) {
ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level,
@@ -26,7 +29,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t
void ZigbeeTime::setup() {
global_time = this;
this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); });
synchronize_epoch_(EPOCH_2000);
this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); });
}
@@ -39,8 +41,12 @@ void ZigbeeTime::dump_config() {
}
void ZigbeeTime::update() {
time_t time = timestamp_now();
this->cluster_attributes_->time = time - EPOCH_2000;
const time_t now = this->timestamp_now();
if (now < EPOCH_2000) {
this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE;
} else {
this->cluster_attributes_->time = static_cast<uint32_t>(now - EPOCH_2000);
}
}
void ZigbeeTime::set_epoch_time(uint32_t epoch) {
@@ -63,8 +69,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) {
if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) {
if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) {
zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32;
ESP_LOGI(TAG, "Synchronize time to %u", value);
this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); });
if (value <= MAX_ZCL_TIME) {
ESP_LOGI(TAG, "Synchronize time to %u", value);
this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); });
} else {
ESP_LOGW(TAG, "Ignoring invalid time %u", value);
}
} else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) {
zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8;
ESP_LOGI(TAG, "Time status %hd", value);
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.10.0b2"
__version__ = "2026.10.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+8
View File
@@ -8,6 +8,7 @@
#include "esphome/core/string_ref.h"
#include <concepts>
#include <functional>
#include <type_traits>
#include <utility>
#include <vector>
@@ -490,6 +491,13 @@ template<typename... X> class TemplatableValue<std::string, X...> {
};
};
/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger
/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be
/// stored sliced, so entity-like argument types must not be copyable.
template<typename T>
using stored_trigger_arg_t =
std::conditional_t<std::is_copy_constructible_v<std::remove_cvref_t<T>>, std::remove_cvref_t<T>, T>;
/** Base class for all automation conditions.
*
* @tparam Ts The template parameters to pass when executing.
+14 -7
View File
@@ -11,6 +11,7 @@
#include <array>
#include <list>
#include <tuple>
#include <type_traits>
#include <vector>
@@ -500,7 +501,7 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
// Store for later processing
auto now = millis();
auto timeout = this->timeout_value_.optional_value(x...);
this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...));
this->var_queue_.emplace_back(now, timeout, x...);
// Do immediate check with fresh timestamp - don't call loop() synchronously!
// Let the event loop call it to avoid reentrancy issues
@@ -532,7 +533,13 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
}
protected:
using QueueItem = std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>;
struct QueueItem {
QueueItem(uint32_t start, optional<uint32_t> timeout, const Ts &...x)
: start(start), timeout(timeout), args(x...) {}
uint32_t start;
optional<uint32_t> timeout;
std::tuple<stored_trigger_arg_t<Ts>...> args;
};
// Helper: Process queue, triggering completed items and removing them
// Returns true if queue still has pending items
@@ -546,14 +553,14 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
std::list<QueueItem> pending;
while (!queue.empty()) {
auto it = queue.begin();
auto start = std::get<uint32_t>(*it);
auto timeout = std::get<optional<uint32_t>>(*it);
auto start = it->start;
auto timeout = it->timeout;
// Check if timeout has expired
auto expired = timeout && (now - start) >= *timeout;
// Keep waiting if not expired and condition not met
if (!expired && !this->condition_->check_tuple(std::get<std::tuple<Ts...>>(*it))) {
if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) {
pending.splice(pending.end(), queue, it);
continue;
}
@@ -564,7 +571,7 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
std::list<QueueItem> completed;
completed.splice(completed.begin(), queue, it);
uint8_t generation = this->stop_generation_;
this->play_next_tuple_(std::get<std::tuple<Ts...>>(completed.front()));
std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args);
if (generation != this->stop_generation_) {
// stop() ran inside the chain - the items still held locally were cancelled
pending.clear();
@@ -581,7 +588,7 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
Condition<Ts...> *condition_;
std::list<QueueItem> var_queue_{};
// Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_()
// Bumped by stop() so process_queue_() can detect a stop from inside play_next_()
uint8_t stop_generation_{0};
};
+1
View File
@@ -275,6 +275,7 @@
#define USE_API_WIZARD
#define USE_API_WIZARD_INPUTS
#define USE_API_WIZARD_LINKED_INPUTS
#define USE_API_WIZARD_MULTIPLE_INPUTS
#define USE_API_WIZARD_STANDALONE_INPUTS
#define USE_API_PLAINTEXT
#define USE_API_USER_DEFINED_ACTIONS
+57
View File
@@ -1,5 +1,6 @@
"""ESP-IDF direct build API for ESPHome."""
import contextlib
from dataclasses import dataclass, field
import fnmatch
import hashlib
@@ -125,6 +126,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
env_cache[version] = os.environ.copy()
# Do not leak PYTHONPATH into child env
env_cache[version].pop("PYTHONPATH", None)
# Do not let an inherited IDF_TARGET override the variant
env_cache[version].pop("IDF_TARGET", None)
# Use provided IDF framework if available
if _esphome_manages_idf():
@@ -228,6 +231,8 @@ FILTER_IDF_LINES: list[str] = [
# click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE.
_CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
_CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$")
_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE)
_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$")
@dataclass(frozen=True, kw_only=True)
@@ -311,6 +316,54 @@ def tree_skips_bootloader(build_dir: Path) -> bool:
return cache.get(SKIP_BOOTLOADER_DEFINE) == "1"
def _foreign_idf_target(target: str) -> str | None:
"""Return another IDF target recorded in the CMake caches or sdkconfig."""
build_dir = _build_dir()
sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
found = []
for cache_path in (
build_dir / "CMakeCache.txt",
build_dir / "bootloader" / "CMakeCache.txt",
):
with contextlib.suppress(OSError, ValueError):
cache = _parse_cmakecache(cache_path)
found.append(cache.get("IDF_TARGET"))
if m := _TOOLCHAIN_FILE_TARGET.search(
cache.get("CMAKE_TOOLCHAIN_FILE", "")
):
found.append(m.group(1))
with contextlib.suppress(OSError, ValueError):
if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")):
found.append(m.group(1))
return next((t for t in found if t and t != target), None)
def _clean_foreign_target_tree() -> None:
"""Clean a build configured for another IDF target, like idf.py set-target."""
variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT)
if variant is None:
return
target = variant_to_idf_target(variant)
if (old := _foreign_idf_target(target)) is None:
return
from esphome.writer import clean_build
_LOGGER.info(
"IDF target changed from %s to %s, cleaning build files...", old, target
)
clean_build(clear_pio_cache=False)
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
return
_reset_sdkconfig()
def _reset_sdkconfig() -> None:
"""Restart the project sdkconfig from ESPHome's own options."""
internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal")
if internal.is_file():
shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}"))
def _skip_bootloader() -> bool:
"""Whether this tree should not build a bootloader at all; per-run memo."""
cache = _cache()
@@ -610,6 +663,9 @@ def _configure_project(verbose: bool = False) -> int:
if not discovered:
_LOGGER.error("Component discovery found no built-in ESP-IDF components")
return 1
# The discovery configure builds every IDF component, and their Kconfig
# selects (e.g. esp-tls turning TLS on) would otherwise stay in sdkconfig
_reset_sdkconfig()
if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
@@ -809,6 +865,7 @@ def run_compile(config, verbose: bool) -> int:
3. Run full build
"""
jobs = _build_jobs(config)
_clean_foreign_target_tree()
if need_reconfigure():
if (rc := _configure_project(verbose)) != 0:
return rc
+10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, MutableMapping
from contextlib import suppress
import importlib
import ipaddress
import logging
import os
@@ -10,6 +11,7 @@ import platform
import re
import stat
import sys
from types import ModuleType
from typing import TYPE_CHECKING, TextIO
from esphome.const import __version__ as ESPHOME_VERSION
@@ -827,3 +829,11 @@ def docs_url(path: str) -> str:
path = path.removeprefix("/")
return docs_format.format(path=path)
def zstd_module() -> ModuleType:
"""The zstd module: the standard library one from Python 3.14, otherwise the backport."""
try:
return importlib.import_module("compression.zstd")
except ImportError:
return importlib.import_module("backports.zstd")
+1 -1
View File
@@ -19,7 +19,7 @@ puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14
backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14
pillow==12.3.0
resvg-py==0.5.0
freetype-py==2.5.1
+150 -44
View File
@@ -13,15 +13,20 @@ from __future__ import annotations
import argparse
from collections import defaultdict
from collections.abc import Set as AbstractSet
import contextlib
from dataclasses import dataclass, field
import functools
import json
import math
from pathlib import Path
import sys
from typing import Any, NamedTuple
# Add esphome to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from esphome import yaml_util
from script.analyze_component_buses import (
ISOLATED_COMPONENTS,
ISOLATED_SIGNATURE_PREFIX,
@@ -31,29 +36,52 @@ from script.analyze_component_buses import (
merge_compatible_bus_groups,
)
from script.helpers import (
_extract_components_from_yaml,
get_component_test_files,
parse_test_filename,
split_conflicting_groups,
)
# Estimated CI seconds for one build per test platform, read from batch job
# logs. Absolute accuracy does not matter, only the ratios between platforms.
# A full run takes over an hour, so recording real timings is not practical.
# Estimated CI seconds for one build per test platform, fitted to the batch
# job logs of a full run (every component, PR #20389). Absolute accuracy does
# not matter, only the ratios between platforms. A full run takes over an
# hour, so recording real timings is not practical.
PLATFORM_BUILD_SECONDS = {
"host": 10,
"esp8266-ard": 30,
"esp32-idf": 45,
"rtl87xx-ard": 35,
"ln882x-ard": 50,
"rp2040-ard": 50,
"rp2350-ard": 50,
"bk72xx-ard": 60,
"rtl87xx-ard": 40,
"esp32-c3-idf": 40,
"esp32-s2-idf": 40,
"rp2350-ard": 40,
"rp2040-ard": 45,
"esp32-idf": 55,
"esp32-p4-idf": 55,
"ln882x-ard": 55,
"esp32-s3-idf": 60,
"bk72xx-ard": 70,
"esp32-c6-idf": 70,
}
NRF52_BUILD_SECONDS = 45
# Other ESP32 chips and Arduino on ESP32 share less of the ccache
DEFAULT_BUILD_SECONDS = 90
NRF52_BUILD_SECONDS = 50
# Arduino on any ESP32 chip builds the IDF plus the Arduino core on top
ESP32_ARDUINO_BUILD_SECONDS = 90
# The remaining ESP32 chips on ESP-IDF
DEFAULT_BUILD_SECONDS = 50
# Each extra component merged into a grouped build makes it larger
GROUPED_COMPONENT_SECONDS = 5
GROUPED_COMPONENT_SECONDS = 3
# Components that pull in a large library the build compiles from source
# (TensorFlow Lite Micro, FastLED, LVGL, the HTTP server), added to every
# build whose test config enables them. Fitted the same way as the platforms.
HEAVY_COMPONENT_SECONDS: dict[str, int] = {
"micro_wake_word": 200,
"fastled_clockless": 90,
"fastled_spi": 90,
"wled": 60,
"lvgl": 50,
"web_server": 50,
"online_image": 45,
"prometheus": 35,
"bluetooth_proxy": 30,
}
# Estimated build seconds per CI runner; sets how many runners are used.
# Approximate: the runner count charges each grouped build once, but a large
# group spreads over several runners, which each pay for that build.
@@ -90,9 +118,62 @@ def build_seconds(platform: str) -> int:
return seconds
if platform.startswith("nrf52"):
return NRF52_BUILD_SECONDS
if platform.startswith("esp32") and platform.endswith("-ard"):
return ESP32_ARDUINO_BUILD_SECONDS
return DEFAULT_BUILD_SECONDS
def _config_components(data: Any) -> set[str]:
"""Return the components a loaded test config and its ``packages:`` use."""
if not isinstance(data, dict):
return set()
found = _extract_components_from_yaml(data)
if isinstance(packages := data.get("packages"), dict):
for package in packages.values():
if isinstance(package, yaml_util.IncludeFile):
found |= _file_components(
(package.parent_file.parent / package.file).resolve()
)
else:
found |= _config_components(package)
return found
@functools.cache
def _file_components(path: Path) -> frozenset[str]:
"""Return the components a config file uses; shared packages load once."""
return frozenset(
_config_components(yaml_util.load_yaml(path, track_document_range=False))
)
def heavy_components(component: str, test_file: Path) -> frozenset[str]:
"""Return the heavy components a test compiles.
A test counts as heavy when it belongs to a heavy component or when its
config enables one, such as a display test that draws with lvgl.
"""
heavy = {component} & HEAVY_COMPONENT_SECONDS.keys()
# A config that does not load is left to the build to report
with contextlib.suppress(Exception):
heavy |= _file_components(test_file.resolve()) & HEAVY_COMPONENT_SECONDS.keys()
return frozenset(heavy)
def heavy_seconds(heavy: AbstractSet[str]) -> int:
"""Return the library seconds a set of heavy components adds to a build."""
return sum(HEAVY_COMPONENT_SECONDS[name] for name in heavy)
class _GroupedShare(NamedTuple):
"""A component's part in one (signature, platform) shared build."""
# Seconds of its files on that platform when no other member shares it
solo: int
# Heavy components its base test brings into the shared build
heavy: frozenset[str]
@dataclass
class _BatchItem:
"""A component and the builds it adds to the batch it lands in."""
@@ -101,40 +182,52 @@ class _BatchItem:
# Builds that always run on their own (isolated tests, variants on a
# platform without a base test)
own_seconds: int
# Per (signature, platform) shared build: the seconds of this component's
# files on that platform when no other batch member shares the build
grouped_builds: dict[tuple[str, str], int] = field(default_factory=dict)
grouped_builds: dict[tuple[str, str], _GroupedShare] = field(default_factory=dict)
def standalone_seconds(self) -> int:
return self.own_seconds + sum(self.grouped_builds.values())
return self.own_seconds + sum(
share.solo for share in self.grouped_builds.values()
)
def _grouped_build_seconds(platform: str, solo_seconds: list[int]) -> int:
def _grouped_build_seconds(platform: str, members: list[_GroupedShare]) -> int:
"""Return the seconds of one (signature, platform) build in a batch.
A library two members both enable compiles once, so the union is charged.
test_build_components only groups when two or more members share the
build; a grouped member then skips its variants on that platform.
"""
if len(solo_seconds) <= 1:
return sum(solo_seconds)
return build_seconds(platform) + GROUPED_COMPONENT_SECONDS * (len(solo_seconds) - 1)
if len(members) <= 1:
return sum(share.solo for share in members)
return (
build_seconds(platform)
+ GROUPED_COMPONENT_SECONDS * (len(members) - 1)
+ heavy_seconds(set().union(*(share.heavy for share in members)))
)
def _make_item(
tests_dir: Path, component: str, signature: str, is_isolated: bool
) -> _BatchItem:
files_by_platform: dict[str, list[str]] = defaultdict(list)
# Per platform: the seconds of its test files, and the base test's heavy
# components; a platform without a base test cannot join a shared build
seconds_by_platform: dict[str, int] = defaultdict(int)
base_heavy: dict[str, frozenset[str]] = {}
for test_file in (tests_dir / component).glob("test[.-]*.yaml"):
test_name, platform = parse_test_filename(test_file)
files_by_platform[platform].append(test_name)
heavy = heavy_components(component, test_file)
seconds_by_platform[platform] += build_seconds(platform) + heavy_seconds(heavy)
if test_name == "test":
base_heavy[platform] = heavy
own_seconds = 0
grouped: dict[tuple[str, str], int] = {}
for platform, test_names in files_by_platform.items():
seconds = build_seconds(platform) * len(test_names)
if is_isolated or "test" not in test_names:
grouped: dict[tuple[str, str], _GroupedShare] = {}
for platform, seconds in seconds_by_platform.items():
if is_isolated or platform not in base_heavy:
own_seconds += seconds
else:
grouped[(signature, platform)] = seconds
grouped[(signature, platform)] = _GroupedShare(
seconds, base_heavy[platform]
)
return _BatchItem(component, own_seconds, grouped)
@@ -144,22 +237,24 @@ class _Batch:
def __init__(self) -> None:
self.components: list[str] = []
self.seconds = 0
self.grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list)
self.grouped_builds: dict[tuple[str, str], list[_GroupedShare]] = defaultdict(
list
)
def added_seconds(self, item: _BatchItem) -> int:
"""Return the seconds item would add; joining an existing build is cheap."""
added = item.own_seconds
for (signature, platform), solo in item.grouped_builds.items():
for (signature, platform), share in item.grouped_builds.items():
members = self.grouped_builds.get((signature, platform), [])
added += _grouped_build_seconds(
platform, [*members, solo]
platform, [*members, share]
) - _grouped_build_seconds(platform, members)
return added
def add(self, item: _BatchItem) -> None:
self.seconds += self.added_seconds(item)
for build, solo in item.grouped_builds.items():
self.grouped_builds[build].append(solo)
for build, share in item.grouped_builds.items():
self.grouped_builds[build].append(share)
self.components.append(item.component)
@@ -167,25 +262,36 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s
"""Spread items over enough runners to stay near target_seconds each.
The runner count comes from the total estimate with every grouped build
counted once. Heaviest items go first, each to the runner that ends up
lightest, so grouped components follow the builds they can join.
counted once. Heaviest items go first, each to the runner it adds the
fewest seconds to among those ending near the lightest, so grouped
components follow the builds they can join.
"""
if not items:
return []
grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list)
# One batch holding every item counts each grouped build once
whole = _Batch()
for item in items:
for build, solo in item.grouped_builds.items():
grouped_builds[build].append(solo)
total = sum(item.own_seconds for item in items) + sum(
_grouped_build_seconds(platform, solos)
for (_, platform), solos in grouped_builds.items()
)
whole.add(item)
total = whole.seconds
count = min(len(items), max(1, math.ceil(total / target_seconds)))
batches = [_Batch() for _ in range(count)]
# Among the runners ending within slack of the lightest, the one the item
# adds the fewest seconds to wins, so a component lands with the bus
# group partners it shares builds with; placed away from them it builds
# alone on every platform they only share there.
slack = target_seconds // 4
for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)):
min(batches, key=lambda b: (b.seconds + b.added_seconds(item), b.seconds)).add(
item
costs = [(batch, batch.added_seconds(item)) for batch in batches]
ceiling = min(batch.seconds + added for batch, added in costs) + slack
batch, _ = min(
(
(batch, added)
for batch, added in costs
if batch.seconds + added <= ceiling
),
key=lambda cost: (cost[1], cost[0].seconds + cost[1]),
)
batch.add(item)
return [batch.components for batch in batches if batch.components]
+121 -20
View File
@@ -14,7 +14,7 @@ from esphome.components.api import wizard
from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS
from esphome.config import load_config
from esphome.core import CORE
from esphome.helpers import fnv1_hash, fnv1a_32bit_hash
from esphome.helpers import fnv1_hash, fnv1a_32bit_hash, zstd_module
from tests.component_tests.helpers import get_define_value
ESP32_HEADER = """
@@ -490,6 +490,7 @@ WIZARD_DEFINES = {
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_MULTIPLE_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
}
@@ -550,9 +551,28 @@ WIZARD_DEFINES = {
),
pytest.param(
"- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]",
WIZARD_DEFINES,
WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"},
id="everything",
),
pytest.param(
"- inputs: [{id: calendars, multiple: true}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
"USE_API_WIZARD_MULTIPLE_INPUTS",
},
id="multiple-input",
),
pytest.param(
"- inputs: [{id: calendars, multiple: false}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
},
id="multiple-false",
),
],
)
def test_only_the_defines_the_wizard_needs_are_emitted(
@@ -613,7 +633,7 @@ def test_text_select_and_button_default_to_their_domains(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER))
document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp)))
document = json.loads(zstd_module().decompress(blob_in(main_cpp)))
filters = {
entry["key"]: entry.get("entity_filters")
for page in document["pages"]
@@ -733,7 +753,7 @@ def test_the_blob_is_the_exact_json_document(
)
blob = blob_in(main_cpp)
text = wizard.zstd_module().decompress(blob).decode("utf-8")
text = zstd_module().decompress(blob).decode("utf-8")
expected = {
"version": 1,
"pages": [
@@ -788,7 +808,7 @@ def test_the_blob_is_deterministic_and_the_same_on_every_platform(
)
# Compressing the same document again gives the same bytes
document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config)
again = wizard.zstd_module().compress(
again = zstd_module().compress(
json.dumps(
document, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8"),
@@ -859,21 +879,6 @@ def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> Non
assert any("has no name of its own" in error for error in errors), errors
def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None:
"""Before Python 3.14 the standard library has no zstd, so the backport is used."""
backport = object()
def import_module(name: str) -> object:
if name == "compression.zstd":
raise ImportError(name)
assert name == "backports.zstd"
return backport
monkeypatch.setattr(wizard.importlib, "import_module", import_module)
assert wizard.zstd_module() is backport
def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None:
# Random text does not compress, so this needs more than the limit even compressed
rng = random.Random(1)
@@ -940,3 +945,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid(
assert "api_wizard_input_ha_plain_sensor" not in main_cpp
# Every buffer starts empty
assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0]
@pytest.mark.parametrize(
"input_conf",
[
"{id: cals, multiple: true}",
"{id: cals, multiple: true, max_length: 255}",
"{id: cals, multiple: true, max_length: 4095}",
"{id: cals, multiple: false}",
],
)
def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None:
api = wizard_inputs(f"- {input_conf}")
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
@pytest.mark.parametrize(
("inputs", "message"),
[
pytest.param(
"- {entity: ha_sensor, multiple: true}",
"multiple is only for standalone inputs",
id="multiple-linked",
),
pytest.param(
"- {id: cals, max_length: 1023}",
"max_length only applies to an input with multiple: true",
id="max-length-without-multiple",
),
pytest.param(
"- {id: cals, multiple: false, max_length: 1023}",
"max_length only applies to an input with multiple: true",
id="max-length-multiple-false",
),
pytest.param(
"- {id: cals, multiple: true, max_length: 254}",
"value must be at least 255",
id="max-length-too-small",
),
pytest.param(
"- {id: cals, multiple: true, max_length: 4096}",
"value must be at most 4095",
id="max-length-too-big",
),
],
)
def test_invalid_multiple_inputs_are_rejected(
tmp_path: Path, inputs: str, message: str
) -> None:
errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs)))
assert any(message in error for error in errors), errors
def test_multiple_input_document_and_table(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
api = wizard_inputs(
"""
- {id: cals, multiple: true, max_length: 1023}
- {id: cals_default, multiple: true}
- {id: single}
"""
)
main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api))
document = json.loads(zstd_module().decompress(blob_in(main_cpp)))
inputs = document["pages"][0]["inputs"]
# multiple and max_length are only there when set
assert inputs == [
{"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023},
{"key": fnv1_hash("cals_default"), "multiple": True},
{"key": fnv1_hash("single")},
]
# Only a multiple entry names its buffer size
assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp
assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp
assert 'static char api_wizard_input_single[256] = "";' in main_cpp
assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp
assert (
f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}"
in main_cpp
)
assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp
def test_inputs_without_multiple_keep_the_two_field_table(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(
write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single"))
)
assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp
assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines}
@@ -0,0 +1,44 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
logger:
binary_sensor:
- platform: template
id: single_and
on_press:
- if:
condition:
- lambda: return true;
then:
- logger.log: single and
- platform: template
id: single_or
on_press:
- if:
any:
- lambda: return true;
then:
- logger.log: single or
- platform: template
id: double_and
on_press:
- if:
condition:
- lambda: return true;
- lambda: return false;
then:
- logger.log: double and
- platform: template
id: single_xor
on_press:
- if:
condition:
xor:
- lambda: return true;
then:
- logger.log: single xor
@@ -0,0 +1,25 @@
"""Tests for and/or condition group codegen."""
from collections.abc import Callable
from pathlib import Path
import re
def test_single_condition_groups_are_unwrapped(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A group of one condition is passed to the action directly."""
main_cpp = generate_main(component_config_path("condition_groups.yaml"))
assert "IfAction<false>(lambdacondition_id);" in main_cpp
assert "IfAction<false>(lambdacondition_id_2);" in main_cpp
group = re.search(
r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);",
main_cpp,
)
assert group is not None
assert f"IfAction<false>({group.group(1)});" in main_cpp
# xor of one condition equals that condition, so it is unwrapped too.
assert "IfAction<false>(lambdacondition_id_5);" in main_cpp
assert "XorCondition<" not in main_cpp
@@ -0,0 +1,149 @@
"""Tests for teleinfo sensor tag presets."""
import pytest
from esphome.components.teleinfo import CONF_TAG_NAME
from esphome.components.teleinfo.sensor import apply_tag_config
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@pytest.mark.parametrize(
("tag", "unit", "device_class", "state_class"),
[
("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
(
"ERQ1",
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
),
("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT),
(
"SINSTS",
UNIT_VOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
(
"PREF",
UNIT_KILOVOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT),
("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
],
)
def test_known_tag_preset(
tag: str, unit: str, device_class: str, state_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
assert config[CONF_STATE_CLASS] == state_class
assert CONF_ICON not in config
def test_unknown_tag_keeps_legacy_defaults() -> None:
config = apply_tag_config({CONF_TAG_NAME: "MYTAG"})
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_starting_with_u_is_not_voltage() -> None:
config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"})
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
def test_numeric_tag_name_does_not_crash() -> None:
config = apply_tag_config({CONF_TAG_NAME: 1234})
assert config[CONF_TAG_NAME] == 1234
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
@pytest.mark.parametrize(
("tag", "unit", "device_class"),
[
("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT),
],
)
def test_user_compatible_unit_keeps_preset_device_class(
tag: str, unit: str, device_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
def test_user_incompatible_unit_skips_preset_device_class() -> None:
config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"})
assert config[CONF_UNIT_OF_MEASUREMENT] == "W"
assert CONF_DEVICE_CLASS not in config
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
def test_user_incompatible_device_class_skips_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER}
)
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER
assert CONF_UNIT_OF_MEASUREMENT not in config
def test_user_same_device_class_keeps_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS
def test_user_state_class_is_kept() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT}
)
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
+2 -1
View File
@@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_MULTIPLE_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
):
cg.add_define(define)
cg.add_define("API_WIZARD_DATA_SIZE", 200)
cg.add_define("API_WIZARD_INPUT_COUNT", 2)
cg.add_define("API_WIZARD_INPUT_COUNT", 3)
cg.add_define("API_MAX_SEND_QUEUE", 8)
cg.add_define("MAX_API_CONNECTIONS", 1)
cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS")
+20
View File
@@ -41,6 +41,13 @@ api:
entity:
- domain: weather
- id: unset_input
- id: calendars_input
description: Calendars
multiple: true
max_length: 1023
target:
entity:
- domain: calendar
switch:
- platform: template
@@ -106,3 +113,16 @@ interval:
data:
type: daily
entity_id: !lambda 'return id(weather_input).entity_id().c_str();'
- if:
condition:
api.wizard.input_is_set: calendars_input
then:
- homeassistant.action:
action: calendar.get_events
data:
duration: "24:00:00"
entity_id: !lambda 'return id(calendars_input).entity_id().c_str();'
- lambda: |-
ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count());
id(calendars_input).for_each_entity_id(
[](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); });
+73
View File
@@ -23,8 +23,15 @@ static char *wizard_input_other() {
return buffer;
}
static constexpr size_t CALENDARS_SIZE = 64;
static char *wizard_input_calendars() {
static char buffer[CALENDARS_SIZE] = "";
return buffer;
}
static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d;
static constexpr uint32_t OTHER_KEY = 0x11223344;
static constexpr uint32_t CALENDARS_KEY = 0x55667788;
// The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two
// bytes as a varint.
@@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = {
const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = {
{WEATHER_KEY, wizard_input_weather()},
{OTHER_KEY, wizard_input_other()},
{CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE},
};
using Bytes = std::vector<uint8_t>;
@@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) {
EXPECT_EQ(input.entity_id(), "weather.home");
}
TEST(DeviceWizard, MultipleInputStoresTheList) {
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars());
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b");
// One id is a list of one
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars());
EXPECT_STREQ(wizard_input_calendars(), "calendar.c");
}
TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) {
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars());
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b");
EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars());
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c");
}
TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) {
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars());
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr);
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr);
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr);
EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr);
EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr);
EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr);
EXPECT_STREQ(wizard_input_calendars(), "calendar.keep");
}
TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) {
// Pad the last id so the list fills the buffer
std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x');
ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1);
EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars());
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
std::string too_long = fits + "x";
EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr);
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
// Spaces do not count towards the limit
std::string spaced = fits;
spaced.replace(spaced.find(','), 1, " , ");
EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars());
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
}
TEST(DeviceWizard, MultipleInputHelpersWalkTheList) {
strcpy(wizard_input_calendars(), "");
WizardInput input(wizard_input_calendars());
EXPECT_EQ(input.entity_id_count(), 0u);
std::vector<std::string> seen;
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
EXPECT_TRUE(seen.empty());
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars());
EXPECT_EQ(input.entity_id_count(), 1u);
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
EXPECT_EQ(seen, (std::vector<std::string>{"calendar.a"}));
seen.clear();
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars());
EXPECT_EQ(input.entity_id_count(), 3u);
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
EXPECT_EQ(seen, (std::vector<std::string>{"calendar.a", "calendar.b", "calendar.c"}));
// The raw list is what homeassistant.action data receives
EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c");
}
} // namespace esphome::api
#endif // USE_HOST
+1
View File
@@ -5,6 +5,7 @@ canbus:
tx_pin: 5
can_id: 4
bit_rate: 50kbps
max_frames_per_loop: 100
on_frame:
- can_id: 500
then:
@@ -0,0 +1,109 @@
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include "esphome/core/base_automation.h"
namespace esphome::testing {
class FlagCondition : public Condition<const std::string &> {
public:
bool check(const std::string & /*value*/) override { return this->ready; }
bool ready{false};
};
class RecordAction : public Action<const std::string &> {
public:
void play(const std::string &value) override { this->seen = value; }
std::string seen;
};
// A trigger argument passed as `const T &` must be copied while waiting: the
// referenced object is gone once the trigger has returned.
TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) {
FlagCondition condition;
WaitUntilAction<const std::string &> wait(&condition);
RecordAction record;
ActionList<const std::string &> actions;
actions.add_actions({&wait, &record});
{
auto value = std::make_unique<std::string>("argument longer than the small string buffer");
actions.play(*value);
}
EXPECT_TRUE(record.seen.empty());
condition.ready = true;
wait.loop();
EXPECT_EQ(record.seen, "argument longer than the small string buffer");
}
class Counter {
public:
int value{0};
};
class CounterCondition : public Condition<Counter &> {
public:
bool check(Counter & /*counter*/) override { return this->ready; }
bool ready{false};
};
class IncrementAction : public Action<Counter &> {
public:
void play(Counter &counter) override { this->seen = ++counter.value; }
int seen{0};
};
// A non-const reference argument used to fail to compile
TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) {
CounterCondition condition;
WaitUntilAction<Counter &> wait(&condition);
IncrementAction increment;
ActionList<Counter &> actions;
actions.add_actions({&wait, &increment});
Counter counter;
actions.play(counter);
condition.ready = true;
wait.loop();
EXPECT_EQ(increment.seen, 1);
// Like delay, the actions after the wait work on a copy
EXPECT_EQ(counter.value, 0);
}
class Entity {
public:
Entity() = default;
Entity(const Entity &) = delete;
int value{0};
};
class EntityCondition : public Condition<Entity &> {
public:
bool check(Entity & /*entity*/) override { return this->ready; }
bool ready{false};
};
class EntityIncrementAction : public Action<Entity &> {
public:
void play(Entity &entity) override { entity.value++; }
};
// A type that cannot be copied, such as an entity, is kept by reference
TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) {
EntityCondition condition;
WaitUntilAction<Entity &> wait(&condition);
EntityIncrementAction increment;
ActionList<Entity &> actions;
actions.add_actions({&wait, &increment});
Entity entity;
actions.play(entity);
condition.ready = true;
wait.loop();
EXPECT_EQ(entity.value, 1);
}
} // namespace esphome::testing
@@ -1,17 +0,0 @@
ethernet:
type: W5500
spi_id: spi_bus
cs_pin: 5
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -1 +1,6 @@
<<: !include common-ip101.yaml
packages:
ethernet: !include common-ip101.yaml
# IPv6 on the internal EMAC: compiles the IPv6 event handler and multicast filter
network:
enable_ipv6: true
@@ -1,12 +0,0 @@
network:
enable_ipv6: true
ethernet:
type: IP101
mdc_pin: 23
mdio_pin: 18
clk:
pin: 0
mode: CLK_EXT_IN
phy_addr: 1
power_pin: 5
@@ -1,39 +0,0 @@
ethernet:
id: eth
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
enable_on_boot: false
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
mac_address: "02:AA:BB:CC:DD:01"
interface: spi2
esphome:
on_boot:
priority: 200
then:
- if:
condition:
not:
ethernet.enabled:
then:
- ethernet.enable:
button:
- platform: template
name: "Disable Ethernet"
on_press:
- ethernet.disable:
binary_sensor:
- platform: template
name: "Ethernet Connected"
lambda: |-
return id(eth).is_connected();
@@ -1,3 +0,0 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
ethernet: !include common-w5500-spi-id.yaml
@@ -1,15 +0,0 @@
# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM
packages:
ethernet: !include common-w5500.yaml
psram:
mode: quad
wifi:
ssid: MySSID
password: password1
network:
priority:
- ethernet
- wifi
@@ -1,20 +1,47 @@
# W5500 on a shared SPI bus, started from an automation instead of on boot.
# Covers the spi_id attach path and the enable/disable lifecycle in one build.
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
ethernet:
id: eth
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
spi_id: spi_bus
cs_pin: 5
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
enable_on_boot: false
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
interface: spi2
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
esphome:
on_boot:
priority: 200
then:
- if:
condition:
not:
ethernet.enabled:
then:
- ethernet.enable:
button:
- platform: template
name: "Disable Ethernet"
on_press:
- ethernet.disable:
binary_sensor:
- platform: template
name: "Ethernet Connected"
lambda: |-
return id(eth).is_connected();
@@ -1 +1,15 @@
<<: !include common-w5500.yaml
# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM
packages:
ethernet: !include common-w5500.yaml
psram:
mode: quad
wifi:
ssid: MySSID
password: password1
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,12 @@
# Config only: a user supplied SPI interface on dedicated pins exercises the
# interface option in validation.
ethernet:
type: W5500
interface: spi3
clk_pin: 19
mosi_pin: 21
miso_pin: 17
cs_pin: 18
interrupt_pin: 36
reset_pin: 12
clock_speed: 10Mhz
-5
View File
@@ -1,11 +1,6 @@
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
port: 3286
on_begin:
then:
- logger.log: "OTA start"
@@ -1,2 +0,0 @@
packages:
ota: !include api_key_offer.yaml
@@ -1,2 +0,0 @@
packages:
ota: !include api_runtime_key.yaml
@@ -1,5 +0,0 @@
ota:
- platform: esphome
allow_partition_access: true
<<: !include common.yaml
+3 -1
View File
@@ -1 +1,3 @@
<<: !include common.yaml
packages:
wifi: !include wifi.yaml
ota: !include common.yaml
+3 -1
View File
@@ -1 +1,3 @@
<<: !include common.yaml
packages:
wifi: !include wifi.yaml
ota: !include common.yaml
+8 -1
View File
@@ -1 +1,8 @@
<<: !include common.yaml
packages:
wifi: !include wifi.yaml
ota: !include common.yaml
# Final validation merges this into the common block
ota:
- platform: esphome
allow_partition_access: true

Some files were not shown because too many files have changed in this diff Show More