Compare commits

...
Author SHA1 Message Date
J. Nick Koston b501e48ebd Merge branch 'dev' into uart-debug-event-flash 2026-10-12 17:33:23 -10:00
J. Nick Koston 0572454bed Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:50 -10:00
J. Nick Koston 8e50491ef9 Merge remote-tracking branch 'origin/dev' into uart-debug-event-flash 2026-10-04 18:06:45 -05:00
J. Nick Koston e3df568cd7 Cap the number of event types at 65535 2026-10-03 16:06:49 -05:00
J. Nick Koston 1a80e3faf4 Reject lambda delimiters at validation and encode event strings as UTF-8 2026-10-03 15:38:41 -05:00
J. Nick Koston 0a46c81770 Fix the flash read comment 2026-10-03 14:57:45 -05:00
J. Nick Koston 633cf73a9d [uart] Keep debug delimiters and event match data in shared PROGMEM tables 2026-10-03 14:56:07 -05:00
J. Nick Koston 565ee2a14b [core] Let shared_progmem_array emit an unshared array
Identical lambdas can carry separate static state, so callers need a way
to keep such tables apart while still getting a collision free name.
2026-10-03 14:51:31 -05:00
J. Nick Koston be0f3a32f9 [core] Emit shared PROGMEM arrays at namespace scope
A setup() local table could only be reused from the scope of its first
use. The arrays are now globals. A lambda in a table only references
variables that codegen has already declared, so writing the global
straight away keeps the order valid.
2026-10-03 14:51:31 -05:00
J. Nick Koston c7967c2601 [core] Add cg.shared_progmem_array for deduplicated PROGMEM tables
Emits one PROGMEM array per distinct type and contents and returns the
same variable to later callers. The array name is made unique against
every config id and registered variable, so a generated table can never
collide with a user id.
2026-10-03 14:51:31 -05:00
8 changed files with 160 additions and 49 deletions
+8 -7
View File
@@ -279,7 +279,9 @@ DEBUG_SCHEMA = cv.Schema(
cv.Optional(
CONF_TIMEOUT, default=AFTER_DEFAULTS[CONF_TIMEOUT]
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DELIMITER): cv.templatable(validate_raw_data),
cv.Optional(CONF_DELIMITER): cv.All(
validate_raw_data, cv.Length(max=255)
),
}
),
cv.Optional(
@@ -350,12 +352,11 @@ async def debug_to_code(config, parent):
after = config[CONF_AFTER]
cg.add(trigger.set_after_bytes(after[CONF_BYTES]))
cg.add(trigger.set_after_timeout(after[CONF_TIMEOUT]))
if CONF_DELIMITER in after:
data = after[CONF_DELIMITER]
if isinstance(data, bytes):
data = list(data)
for byte in after[CONF_DELIMITER]:
cg.add(trigger.add_delimiter_byte(byte))
if delimiter := after.get(CONF_DELIMITER):
table = cg.shared_progmem_array(
"uart_debug_delimiter", cg.uint8, [int(b) for b in delimiter]
)
cg.add(trigger.set_after_delimiter(table, len(delimiter)))
if config[CONF_DUMMY_RECEIVER]:
dummy = cg.new_Pvariable(config[CONF_DUMMY_RECEIVER_ID], parent)
await cg.register_component(dummy, {})
+26 -13
View File
@@ -1,8 +1,7 @@
import esphome.codegen as cg
from esphome.components import event, uart
import esphome.config_validation as cv
from esphome.const import CONF_EVENT_TYPES, CONF_ID
from esphome.core import ID
from esphome.const import CONF_EVENT_TYPES
from esphome.types import ConfigType
from .. import uart_ns
@@ -12,9 +11,10 @@ CODEOWNERS = ["@eoasmxd"]
DEPENDENCIES = ["uart"]
UARTEvent = uart_ns.class_("UARTEvent", event.Event, uart.UARTDevice, cg.Component)
UARTEventMatcher = uart_ns.struct("UARTEventMatcher")
def validate_event_types(value) -> list[tuple[str, str | list[int]]]:
def validate_event_types(value) -> list[tuple[str, list[int]]]:
if not isinstance(value, list):
raise cv.Invalid("Event type must be a list of key-value mappings.")
@@ -44,7 +44,7 @@ def validate_event_types(value) -> list[tuple[str, str | list[int]]]:
try:
# Try to validate as string
match_data_str = cv.string_strict(match_data)
processed.append((event_name, match_data_str))
processed.append((event_name, list(match_data_str.encode("utf-8"))))
continue
except cv.Invalid:
pass # Not string either
@@ -56,6 +56,10 @@ def validate_event_types(value) -> list[tuple[str, str | list[int]]]:
if not processed:
raise cv.Invalid("event_types must contain at least one event mapping.")
if len(processed) > 0xFFFF:
raise cv.Invalid("event_types can have at most 65535 entries.")
if any(len(data) > 0xFFFF for _, data in processed):
raise cv.Invalid("Event match data can be at most 65535 bytes.")
return processed
@@ -77,14 +81,23 @@ async def to_code(config: ConfigType) -> None:
var = await event.new_event(config, event_types=event_names)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for i, (event_name, match_data) in enumerate(config[CONF_EVENT_TYPES]):
if isinstance(match_data, str):
match_data = [ord(c) for c in match_data]
match_data_var_id = ID(
f"match_data_{config[CONF_ID]}_{i}", is_declaration=True, type=cg.uint8
matchers = []
max_len = 0
for event_name, match_data in config[CONF_EVENT_TYPES]:
match_data = [int(b) for b in match_data]
data = (
cg.shared_progmem_array("uart_event_match", cg.uint8, match_data)
if match_data
else cg.nullptr
)
match_data_var = cg.static_const_array(
match_data_var_id, cg.ArrayInitializer(*match_data)
matchers.append(
cg.StructInitializer(
UARTEventMatcher,
("event_name", event_name),
("data", data),
("data_len", len(match_data)),
)
)
cg.add(var.add_event_matcher(event_name, match_data_var, len(match_data)))
max_len = max(max_len, len(match_data))
table = cg.shared_progmem_array("uart_event_matchers", UARTEventMatcher, matchers)
cg.add(var.set_matchers(table, len(matchers), max_len))
+9 -10
View File
@@ -1,6 +1,6 @@
#include "uart_event.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::uart {
@@ -12,13 +12,6 @@ void UARTEvent::dump_config() { LOG_EVENT("", "UART Event", this); }
void UARTEvent::loop() { this->read_data_(); }
void UARTEvent::add_event_matcher(const char *event_name, const uint8_t *match_data, size_t match_data_len) {
this->matchers_.push_back({event_name, match_data, match_data_len});
if (match_data_len > this->max_matcher_len_) {
this->max_matcher_len_ = match_data_len;
}
}
void UARTEvent::read_data_() {
while (this->available()) {
uint8_t data;
@@ -26,12 +19,18 @@ void UARTEvent::read_data_() {
this->buffer_.push_back(data);
bool match_found = false;
for (const auto &matcher : this->matchers_) {
for (uint16_t i = 0; i < this->matcher_count_; i++) {
const UARTEventMatcher &matcher = this->matchers_[i];
if (this->buffer_.size() < matcher.data_len) {
continue;
}
if (std::equal(matcher.data, matcher.data + matcher.data_len, this->buffer_.end() - matcher.data_len)) {
const uint8_t *tail = this->buffer_.data() + this->buffer_.size() - matcher.data_len;
size_t pos = 0;
// The pattern is in flash; ESP8266 only allows word loads there, so read it with progmem_read_byte
while (pos < matcher.data_len && progmem_read_byte(matcher.data + pos) == tail[pos])
pos++;
if (pos == matcher.data_len) {
this->trigger(matcher.event_name);
this->buffer_.clear();
match_found = true;
+28 -9
View File
@@ -1,31 +1,50 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/components/event/event.h"
#include "esphome/components/uart/uart.h"
#include <type_traits>
#include <vector>
namespace esphome::uart {
/// An event type and the byte pattern that fires it; `data` may point to PROGMEM.
struct UARTEventMatcher {
const char *event_name;
const uint8_t *data;
size_t data_len;
};
// Codegen keeps the matchers in a flash table read with plain loads, which ESP8266 only allows for whole words.
static_assert(std::is_same_v<decltype(UARTEventMatcher::event_name), const char *>,
"UARTEventMatcher fields must stay word sized");
static_assert(std::is_same_v<decltype(UARTEventMatcher::data), const uint8_t *>,
"UARTEventMatcher fields must stay word sized");
static_assert(std::is_same_v<decltype(UARTEventMatcher::data_len), size_t>,
"UARTEventMatcher fields must stay word sized");
#ifdef USE_ESP8266
static_assert(sizeof(UARTEventMatcher) == 3 * sizeof(uint32_t), "UARTEventMatcher is read from flash with word loads");
#endif
class UARTEvent final : public event::Event, public UARTDevice, public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
void add_event_matcher(const char *event_name, const uint8_t *match_data, size_t match_data_len);
/// `matchers` is a codegen PROGMEM table that must outlive the component.
void set_matchers(const UARTEventMatcher *matchers, uint16_t count, uint16_t max_len) {
this->matchers_ = matchers;
this->matcher_count_ = count;
this->max_matcher_len_ = max_len;
}
protected:
struct EventMatcher {
const char *event_name;
const uint8_t *data;
size_t data_len;
};
void read_data_();
std::vector<EventMatcher> matchers_;
const UARTEventMatcher *matchers_{nullptr};
std::vector<uint8_t> buffer_;
size_t max_matcher_len_ = 0;
uint16_t matcher_count_{0};
uint16_t max_matcher_len_{0};
};
} // namespace esphome::uart
+4 -3
View File
@@ -3,6 +3,7 @@
#include <vector>
#include "uart_debugger.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -44,15 +45,15 @@ void UARTDebugger::store_byte_(UARTDirection direction, uint8_t byte) {
}
void UARTDebugger::trigger_after_delimiter_(uint8_t byte) {
if (this->after_delimiter_.empty() || !this->has_buffered_bytes_()) {
if (this->after_delimiter_len_ == 0 || !this->has_buffered_bytes_()) {
return;
}
if (this->after_delimiter_[this->after_delimiter_pos_] != byte) {
if (progmem_read_byte(this->after_delimiter_ + this->after_delimiter_pos_) != byte) {
this->after_delimiter_pos_ = 0;
return;
}
this->after_delimiter_pos_++;
if (this->after_delimiter_pos_ == this->after_delimiter_.size()) {
if (this->after_delimiter_pos_ == this->after_delimiter_len_) {
this->fire_trigger_();
this->after_delimiter_pos_ = 0;
}
+10 -7
View File
@@ -36,11 +36,13 @@ class UARTDebugger final : public Component, public Trigger<UARTDirection, std::
/// this timeout, logging will be triggered.
void set_after_timeout(uint32_t timeout) { this->after_timeout_ = timeout; }
/// Add a delimiter byte. This can be called multiple times to setup a
/// multi-byte delimiter (a typical example would be '\r\n').
/// When the constructed byte sequence is found in the data stream,
/// logging will be triggered.
void add_delimiter_byte(uint8_t byte) { this->after_delimiter_.push_back(byte); }
/// Set a delimiter byte sequence (a typical example would be '\r\n').
/// When the sequence is found in the data stream, logging will be triggered.
/// `delimiter` is a codegen PROGMEM table that must outlive the debugger.
void set_after_delimiter(const uint8_t *delimiter, uint8_t len) {
this->after_delimiter_ = delimiter;
this->after_delimiter_len_ = len;
}
void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); }
@@ -51,8 +53,9 @@ class UARTDebugger final : public Component, public Trigger<UARTDirection, std::
size_t after_bytes_;
uint32_t after_timeout_;
uint32_t last_time_{};
std::vector<uint8_t> after_delimiter_{};
size_t after_delimiter_pos_{};
const uint8_t *after_delimiter_{nullptr};
uint8_t after_delimiter_len_{0};
uint8_t after_delimiter_pos_{0};
bool is_triggering_{false};
StringRef debug_prefix_{};
@@ -0,0 +1,34 @@
esphome:
name: test
esp8266:
board: esp01_1m
uart:
- id: uart_a
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 9600
debug:
after:
delimiter: "\r\n"
- id: uart_b
tx_pin: GPIO2
baud_rate: 9600
debug:
after:
delimiter: [0x0D, 0x0A]
event:
- platform: uart
uart_id: uart_a
name: Event A
event_types:
- "start": "*A#"
- "stop": [0x2A, 0x42, 0x23]
- platform: uart
uart_id: uart_a
name: Event B
event_types:
- "start": "*A#"
- "stop": [0x2A, 0x42, 0x23]
@@ -0,0 +1,41 @@
"""uart debug delimiters and uart event patterns live in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.uart.event import validate_event_types
import esphome.config_validation as cv
def test_debug_delimiter_and_event_matchers_use_shared_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("debug_event_tables.yaml"))
# "\r\n" and [0x0D, 0x0A] are the same bytes, so both debuggers share one table
delimiters = re.findall(r"->set_after_delimiter\((\w+), 2\);", main_cpp)
assert len(delimiters) == 2
assert delimiters[0] == delimiters[1]
assert "add_delimiter_byte" not in main_cpp
# Both events have the same patterns, so they share the byte tables and the matcher table
patterns = re.findall(r"static constexpr uint8_t uart_event_match\w*\[\]", main_cpp)
assert len(patterns) == 2
calls = re.findall(r"->set_matchers\((\w+), 2, 3\);", main_cpp)
assert len(calls) == 2
assert calls[0] == calls[1]
assert main_cpp.count("static constexpr uart::UARTEventMatcher") == 1
assert "add_event_matcher" not in main_cpp
def test_event_string_patterns_are_utf8_bytes() -> None:
assert validate_event_types([{"euro": "€"}]) == [("euro", [0xE2, 0x82, 0xAC])]
def test_event_types_count_is_capped() -> None:
with pytest.raises(cv.Invalid, match="at most 65535 entries"):
validate_event_types([{f"e{i}": "x"} for i in range(0x10000)])