mirror of
https://github.com/esphome/esphome.git
synced 2026-10-12 06:14:17 +00:00
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}>")',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ¶ms = 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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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_));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()); });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
wifi: !include wifi.yaml
|
||||
ota: !include common.yaml
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
wifi: !include wifi.yaml
|
||||
ota: !include common.yaml
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user