Compare commits

...
Author SHA1 Message Date
J. Nick Koston c336736864 Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 08:37:18 -10:00
J. Nick Koston bdd5496273 Merge remote-tracking branch 'origin/dev' into apply-bytes 2026-10-21 08:37:12 -10:00
J. Nick Koston 9db6c11023 [uart] Cover the ESP8266 write_array_progmem path in the payload test 2026-10-21 08:24:10 -10:00
J. Nick Koston e3979f0bbe Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 08:24:06 -10:00
J. Nick Koston 34ebfe3ea3 Point the agent guide at ApplyBytes for payload actions 2026-10-21 08:24:04 -10:00
J. Nick Koston 69877018e2 Expect the empty udp payload to keep a stack buffer on ESP8266 2026-10-21 08:05:14 -10:00
J. Nick Koston 909ff7940a Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 08:05:10 -10:00
J. Nick Koston dee419f655 Keep a non-null buffer for an empty payload on ESP8266 2026-10-21 08:05:07 -10:00
J. Nick Koston 28b2754b21 Merge remote-tracking branch 'origin/apply-bytes-migrate' into apply-bytes-migrate 2026-10-21 08:00:09 -10:00
J. Nick Koston ecf13e47d3 Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 07:59:43 -10:00
J. Nick Koston 3843e41307 Count parsed placeholders so escaped braces do not fool the target check 2026-10-21 07:59:40 -10:00
pre-commit-ci-lite[bot] 67ce58bba4 apply automatic formatting fixes 2026-10-21 17:46:48 +00:00
J. Nick Koston fab5b21d0d Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 07:44:36 -10:00
J. Nick Koston 480edb193d Qualify the flash copy helper in the generated code 2026-10-21 07:44:27 -10:00
J. Nick Koston 9c0b53c4ae Expect the qualified flash copy helper 2026-10-21 07:43:53 -10:00
J. Nick Koston 950560f5e4 Expect the qualified payload tables 2026-10-21 07:36:28 -10:00
J. Nick Koston f85da6010e Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 07:36:25 -10:00
J. Nick Koston ff2c86ba7f Qualify reused payload tables and parenthesize the lambda payload 2026-10-21 07:36:22 -10:00
J. Nick Koston 9b0abf4882 Check the generated sends in the payload table tests 2026-10-21 07:18:06 -10:00
J. Nick Koston a6750951c4 Merge branch 'apply-bytes' into apply-bytes-migrate 2026-10-21 07:18:06 -10:00
J. Nick Koston 568ea8e111 Name the payload temporary so a trigger argument cannot shadow it 2026-10-21 07:18:04 -10:00
J. Nick Koston a1c4f9a8db [udp] Inline the stack payload size 2026-10-20 22:32:58 -10:00
J. Nick Koston b02da099a7 Size the ESP8266 copy from the known payload length and share the table helper 2026-10-20 22:32:57 -10:00
J. Nick Koston 53e75b6e01 [sx127x][sx126x][cc1101][udp][uart] Send byte payloads with ApplyBytes 2026-10-20 22:32:57 -10:00
J. Nick Koston f430582fe3 Expect the renamed payload tables in the tests 2026-10-20 22:31:05 -10:00
J. Nick Koston 0335e6d071 Name the payload tables so they cannot clash with SDK symbols 2026-10-20 22:30:51 -10:00
J. Nick Koston 616d20fd9e [core] Add ApplyBytes for apply actions that forward a byte payload 2026-10-20 22:27:10 -10:00
19 changed files with 362 additions and 226 deletions
+6 -3
View File
@@ -205,8 +205,9 @@ file does, and it is the authority when they disagree. The most useful starting
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`. An action that only forwards a byte payload is
registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash;
`TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
@@ -604,7 +605,9 @@ file does, and it is the authority when they disagree. The most useful starting
)
```
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
* **Conditions:**
+107 -14
View File
@@ -59,10 +59,15 @@ async def templatable_bytes(
if cg.is_template(value):
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
cg.add(set_template(fn))
elif len(value) > 0xFFFF:
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
else:
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
cg.add(set_static(*_static_bytes(table_name, value)))
def _static_bytes(table_name: str, value: bytes | list[int]) -> tuple[MockObj, int]:
"""The shared PROGMEM table for a constant payload and its length."""
if (size := len(value)) > 0xFFFF:
raise EsphomeError(f"Byte payload is {size} bytes; the maximum is 65535")
return progmem_bytes(table_name, value), size
def maybe_simple_id(*validators):
@@ -356,6 +361,33 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
@dataclass(frozen=True)
class ApplyBytes:
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
A constant payload is a shared PROGMEM table passed as is; on ESP8266 it is first copied
out, to the stack up to ``stack`` bytes and to the heap beyond, unless ``progmem_target``
names a call that reads flash itself. A lambda's ``std::vector<uint8_t>`` is passed as
its data and size. The template may name the parent as ``{parent}``. An absent key
emits nothing.
"""
conf_key: str | tuple[str, ...]
target: str
stack: int = 64
progmem_target: str | None = None
def __post_init__(self) -> None:
for target in (self.target, self.progmem_target):
if target is None:
continue
fields = [
f for _, f, _, _ in string.Formatter().parse(target) if f is not None
]
if fields.count("") != 1 or any(f not in ("", "parent") for f in fields):
raise ValueError(f"apply bytes target {target!r} needs one {{}}")
def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any:
if isinstance(key, str):
return config.get(key)
@@ -488,7 +520,7 @@ def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> li
def register_apply_action(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
*fields: ApplyField | ApplyCall | ApplyBytes,
call: str | None = None,
id_key: str = CONF_ID,
) -> None:
@@ -504,7 +536,7 @@ def register_apply_action(
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
*fields: ApplyField | ApplyCall | ApplyBytes,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
@@ -517,17 +549,20 @@ def apply_action_builder(
and ``apply_call.perform()`` is appended.
"""
# An action stores the value, so a std::string constant stays in flash on ESP8266.
statements_spec = [
(
c.target,
_default_std_string(c.members, flash_string),
)
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
]
_check_key_in_schema(name, schema, id_key)
for _, members in statements_spec:
statements_spec: list[ApplyBytes | tuple[str, list[tuple[Any, Any, Any]]]] = []
for f in fields:
if isinstance(f, ApplyBytes):
_check_key_in_schema(name, schema, f.conf_key)
statements_spec.append(f)
continue
c = f if isinstance(f, ApplyCall) else f.call()
members = _default_std_string(c.members, flash_string)
for conf_key, _, _ in members:
_check_key_in_schema(name, schema, conf_key)
statements_spec.append((c.target, members))
# Named like the generated function, so it cannot clash with an SDK symbol
table_name = f"esphome__{name.replace('.', '_')}__bytes"
async def builder(
config: ConfigType,
@@ -539,7 +574,15 @@ def apply_action_builder(
lambda_args = _apply_lambda_args(args)
receiver = "apply_call." if call else f"{parent}->"
statements: list[str] = []
for target, members in statements_spec:
for spec in statements_spec:
if isinstance(spec, ApplyBytes):
statements.extend(
await _render_bytes(
spec, config, receiver, parent, lambda_args, table_name
)
)
continue
target, members = spec
values = _apply_values(config, members)
if not _apply_call_active(members, values):
continue
@@ -560,6 +603,56 @@ def apply_action_builder(
return builder
async def _render_bytes(
spec: ApplyBytes,
config: ConfigType,
receiver: str,
parent: str,
lambda_args: TemplateArgsType,
table_name: str,
) -> list[str]:
"""The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined."""
value = _config_lookup(config, spec.conf_key)
if value is None:
return []
# The temporary carries the generated-name prefix so a trigger argument cannot shadow it
def send(target: str, data: str) -> str:
return f"{receiver}{target.format(data, parent=parent)};"
if isinstance(value, Lambda):
inner = await cg.process_lambda(
value, lambda_args, return_type=cg.std_vector.template(cg.uint8)
)
return [
"{",
f" const std::vector<uint8_t> esphome__bytes = ({call_lambda(inner)});",
f" {send(spec.target, 'esphome__bytes.data(), esphome__bytes.size()')}",
"}",
]
table, size = _static_bytes(table_name, value)
# Qualified so a trigger argument cannot shadow a reused table; an empty payload is nullptr
args = f"::{table}, {size}" if size else f"{table}, {size}"
if CORE.is_esp8266 and spec.progmem_target is not None:
return [send(spec.progmem_target, args)]
if not CORE.is_esp8266:
return [send(spec.target, args)]
# A PROGMEM table must be read with aligned loads on ESP8266, so copy it out first;
# the length is known here, so the copy is an exact array, on the heap past ``stack``.
# An empty payload still gets a one byte array: the raw lwIP socket rejects a null buffer.
if size <= spec.stack:
buffer, data = f"uint8_t esphome__bytes[{max(size, 1)}];", "esphome__bytes"
else:
buffer, data = (
f"auto esphome__bytes = std::make_unique<uint8_t[]>({size});",
"esphome__bytes.get()",
)
statements = ["{", f" {buffer}"]
if size:
statements.append(f" esphome::progmem_memcpy({data}, {args});")
return [*statements, f" {send(spec.target, f'{data}, {size}')}", "}"]
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
"""An ``ApplyCall`` is emitted unless it has keys and none of them is set."""
return not members or any(value is not None for value in values)
+2 -19
View File
@@ -295,11 +295,6 @@ async def to_code(config):
)
# Actions
SendPacketAction = ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(CC1101Component)
)
CC1101_ACTION_SCHEMA = cv.Schema(
maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)})
)
@@ -338,23 +333,11 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
)
@automation.register_action(
automation.register_apply_action(
"cc1101.send_packet",
SendPacketAction,
SEND_PACKET_ACTION_SCHEMA,
synchronous=True,
automation.ApplyBytes(CONF_DATA, "transmit_packet({})"),
)
async def send_packet_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
await automation.templatable_bytes(
config[CONF_DATA],
args,
var.set_data_template,
var.set_data_static,
"cc1101_data",
)
return var
_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()}
-9
View File
@@ -132,13 +132,4 @@ class CC1101Component final : public Component,
bool enter_tx_();
};
// Action Wrappers
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<CC1101Component> {
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
}
};
} // namespace esphome::cc1101
+5 -27
View File
@@ -11,10 +11,9 @@ from esphome.components.const import (
CONF_RST_PIN,
)
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID, TimePeriod
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY
from esphome.core import TimePeriod
from esphome.types import ConfigType
DOMAIN = "sx126x"
MULTI_CONF = True
@@ -133,10 +132,6 @@ SHAPING = {
"NONE": SX126xPulseShape.NO_FILTER,
}
SendPacketAction = sx126x_ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(SX126x)
)
def validate_raw_data(value: Any) -> bytes | list[int]:
if isinstance(value, str):
@@ -324,25 +319,8 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
)
@automation.register_action(
automation.register_apply_action(
"sx126x.send_packet",
SendPacketAction,
SEND_PACKET_ACTION_SCHEMA,
synchronous=True,
automation.ApplyBytes(CONF_DATA, "transmit_packet({})", stack=MAX_PACKET_SIZE),
)
async def send_packet_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
await automation.templatable_bytes(
config[CONF_DATA],
args,
var.set_data_template,
var.set_data_static,
"sx126x_data",
)
return var
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/sx126x/sx126x.h"
namespace esphome::sx126x {
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX126x> {
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
this->data_.template visit<SX126X_MAX_PACKET_SIZE>(
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
}
};
} // namespace esphome::sx126x
+3 -26
View File
@@ -11,9 +11,7 @@ from esphome.components.const import (
CONF_RST_PIN,
)
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.const import CONF_DATA, CONF_FREQUENCY
from esphome.types import ConfigType
DOMAIN = "sx127x"
@@ -117,10 +115,6 @@ SHAPING = {
"NONE": SX127xPaRamp.SHAPING_NONE,
}
SendPacketAction = sx127x_ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(SX127x)
)
def validate_raw_data(value: Any) -> bytes | list[int]:
if isinstance(value, str):
@@ -291,25 +285,8 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
)
@automation.register_action(
automation.register_apply_action(
"sx127x.send_packet",
SendPacketAction,
SEND_PACKET_ACTION_SCHEMA,
synchronous=True,
automation.ApplyBytes(CONF_DATA, "transmit_packet({})", stack=MAX_PACKET_SIZE),
)
async def send_packet_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
await automation.templatable_bytes(
config[CONF_DATA],
args,
var.set_data_template,
var.set_data_static,
"sx127x_data",
)
return var
-19
View File
@@ -1,19 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/sx127x/sx127x.h"
namespace esphome::sx127x {
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX127x> {
TEMPLATABLE_BYTES(data)
public:
void play(const Ts &...x) override {
this->data_.template visit<SX127X_MAX_PACKET_SIZE>(
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
}
};
} // namespace esphome::sx127x
+5 -11
View File
@@ -121,7 +121,6 @@ HOST_BAUD_RATES = [
]
UARTDevice = uart_ns.class_("UARTDevice")
UARTWriteAction = uart_ns.class_("UARTWriteAction", automation.Action)
UARTDebugger = uart_ns.class_("UARTDebugger", cg.Component, automation.Action)
UARTDummyReceiver = uart_ns.class_("UARTDummyReceiver", cg.Component)
MULTI_CONF = True
@@ -593,9 +592,8 @@ def new_uart_component(
return cg.new_bus_component(register_uart_device, config, *args)
@automation.register_action(
automation.register_apply_action(
"uart.write",
UARTWriteAction,
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(UARTComponent),
@@ -603,15 +601,11 @@ def new_uart_component(
},
key=CONF_DATA,
),
synchronous=True,
# A constant stays in flash: write_array_progmem copies it out in chunks on ESP8266
automation.ApplyBytes(
CONF_DATA, "write_array({})", progmem_target="write_array_progmem({})"
),
)
async def uart_write_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
await automation.templatable_bytes(
config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data"
)
return var
@coroutine_with_priority(CoroPriority.FINAL)
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "uart.h"
#include "esphome/core/automation.h"
#include <vector>
namespace esphome::uart {
template<typename... Ts> class UARTWriteAction final : public Action<Ts...>, public Parented<UARTComponent> {
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
if (this->data_.is_static()) {
this->parent_->write_array_progmem(this->data_.data(), this->data_.size());
} else {
this->parent_->write_array(this->data_.value(x...));
}
}
};
} // namespace esphome::uart
+17 -17
View File
@@ -29,7 +29,6 @@ DOMAIN = "udp"
MULTI_CONF = True
udp_ns = cg.esphome_ns.namespace("udp")
UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
trigger_argname = "data"
# Listener callback type (non-owning span from UDP component)
listener_args = cg.std_span.template(cg.uint8.operator("const"))
@@ -240,17 +239,23 @@ def validate_raw_data(value: Any) -> bytes | list[int]:
)
@automation.register_action(
_UDP_WRITE_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(UDPComponent),
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
},
key=CONF_DATA,
)
_udp_write_builder = automation.apply_action_builder(
"udp.write",
UDPWriteAction,
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(UDPComponent),
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
},
key=CONF_DATA,
),
synchronous=True,
_UDP_WRITE_SCHEMA,
# Constant payloads up to this size are sent from the stack on ESP8266
automation.ApplyBytes(CONF_DATA, "send_packet({})", stack=128),
)
@automation.register_action(
"udp.write", automation.ApplyAction, _UDP_WRITE_SCHEMA, synchronous=True
)
async def udp_write_to_code(
config: ConfigType,
@@ -258,11 +263,6 @@ async def udp_write_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
udp_var = await cg.get_variable(config[CONF_ID])
await cg.register_parented(var, udp_var)
cg.add(udp_var.set_should_broadcast())
await automation.templatable_bytes(
config[CONF_DATA], args, var.set_data_template, var.set_data_static, "udp_data"
)
return var
return await _udp_write_builder(config, action_id, template_arg, args)
-24
View File
@@ -1,24 +0,0 @@
#pragma once
#include "udp_component.h"
#ifdef USE_NETWORK
#include "esphome/core/automation.h"
namespace esphome::udp {
/// Constant payloads up to this size are sent from the stack on ESP8266.
static constexpr size_t STACK_PAYLOAD_SIZE = 128;
template<typename... Ts> class UDPWriteAction final : public Action<Ts...>, public Parented<UDPComponent> {
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
// One sendto per packet, so a failed allocation never sends a partial packet
this->data_.template visit<STACK_PAYLOAD_SIZE>(
[this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...);
}
};
} // namespace esphome::udp
#endif
@@ -14,7 +14,7 @@ def test_constant_payloads_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal send_packet payloads share one table; lambdas stay templates."""
"""Equal send_packet payloads share one table; a lambda is sent from its vector."""
main_cpp = generate_main(component_config_path("payload_tables.yaml"))
tables = re.findall(
@@ -23,8 +23,10 @@ def test_constant_payloads_share_progmem_tables(
assert [v for _, v in tables] == [
"{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}"
]
assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2
assert "set_data_template(" in main_cpp
assert main_cpp.count(f"->transmit_packet(::{tables[0][0]}, 8);") == 2
assert (
"->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
)
@pytest.mark.parametrize("size", [64, 65])
@@ -14,7 +14,7 @@ def test_constant_packets_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal constant packets share one table; lambdas stay templates."""
"""Equal constant packets share one table; a lambda is sent from its vector."""
main_cpp = generate_main(component_config_path("packet_tables.yaml"))
tables = dict(
@@ -26,8 +26,10 @@ def test_constant_packets_share_progmem_tables(
["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"]
)
shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}")
assert main_cpp.count(f"set_data_static({shared}, 4);") == 2
assert "set_data_template(" in main_cpp
assert main_cpp.count(f"->transmit_packet(::{shared}, 4);") == 2
assert (
"->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
)
assert "set_sx1261" not in main_cpp
@@ -14,7 +14,7 @@ def test_constant_packets_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal constant packets share one table; lambdas stay templates."""
"""Equal constant packets share one table; a lambda is sent from its vector."""
main_cpp = generate_main(component_config_path("packet_tables.yaml"))
tables = dict(
@@ -26,8 +26,10 @@ def test_constant_packets_share_progmem_tables(
["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"]
)
shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}")
assert main_cpp.count(f"set_data_static({shared}, 4);") == 2
assert "set_data_template(" in main_cpp
assert main_cpp.count(f"->transmit_packet(::{shared}, 4);") == 2
assert (
"->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
)
def test_packet_data_length_limit() -> None:
@@ -0,0 +1,34 @@
---
esphome:
name: test
on_boot:
then:
- uart.write: [0x01, 0x02, 0x03]
- uart.write: !lambda return {0x09};
- uart.write: ""
esp8266:
board: d1_mini
uart:
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 9600
switch:
- platform: uart
id: shared_switch
name: Shared Switch
data: [0x01, 0x02, 0x03]
- platform: uart
id: on_off_switch
name: On Off Switch
data:
turn_on: "ON"
turn_off: [0x04]
button:
- platform: uart
id: shared_button
name: Shared Button
data: [0x01, 0x02, 0x03]
@@ -23,6 +23,19 @@ def test_constant_payloads_share_progmem_tables(
)
assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp
assert f"shared_button->set_data({shared}, 3);" in main_cpp
assert f"set_data_static({shared}, 3);" in main_cpp
assert "set_data_static(nullptr, 0);" in main_cpp
assert "set_data_template(" in main_cpp
assert f"->write_array(::{shared}, 3);" in main_cpp
assert "->write_array(nullptr, 0);" in main_cpp
assert "->write_array(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
def test_esp8266_write_action_reads_flash_directly(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""On ESP8266 the write action hands the table to write_array_progmem instead of copying it."""
main_cpp = generate_main(component_config_path("payload_tables_esp8266.yaml"))
assert "->write_array_progmem(::uart_data, 3);" in main_cpp
assert "->write_array_progmem(nullptr, 0);" in main_cpp
assert "->write_array(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
assert "progmem_memcpy" not in main_cpp
+12 -5
View File
@@ -8,10 +8,17 @@ def test_write_payloads_share_a_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Identical constant payloads share one flash table; an empty one needs none."""
"""Identical constant payloads share one flash table and are copied out of it on
ESP8266 before sending; an empty one needs none; a lambda is sent from its vector."""
main_cpp = generate_main(component_config_path("udp_write.yaml"))
assert main_cpp.count("udp_data[] PROGMEM = {0xAA, 0x55, 0x01, 0x02};") == 1
assert main_cpp.count("->set_data_static(udp_data, 4);") == 2
assert "->set_data_static(nullptr, 0);" in main_cpp
assert "->set_data_template(" in main_cpp
table = "esphome__udp_write__bytes"
assert main_cpp.count(f"{table}[] PROGMEM = {{0xAA, 0x55, 0x01, 0x02}};") == 1
assert (
main_cpp.count(f"esphome::progmem_memcpy(esphome__bytes, ::{table}, 4);") == 2
)
assert main_cpp.count("->send_packet(esphome__bytes, 4);") == 2
assert "uint8_t esphome__bytes[1];" in main_cpp
assert "->send_packet(esphome__bytes, 0);" in main_cpp
assert "->send_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp
assert main_cpp.count("->set_should_broadcast();") == 4
+140
View File
@@ -11,6 +11,7 @@ from esphome.automation import (
CONF_CHOICE_ID,
CONF_CHOOSE,
ApplyAction,
ApplyBytes,
ApplyCall,
ApplyCondition,
ApplyField,
@@ -1281,6 +1282,145 @@ async def test_apply_string_ref_literal_stays_in_flash_on_esp8266(
assert f"::{PARENT_OBJ}->set_payload({rendered});" in _apply_definition(mock_cg)
@pytest.mark.asyncio
@pytest.mark.parametrize("platform", ["esp32", "esp8266"])
async def test_apply_bytes_lambda_is_a_vector_in_a_block(
registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str
) -> None:
"""A lambda payload is called once into a scoped vector and passed as data and size."""
fields = (ApplyBytes("data", "send({})"),)
config = {"data": Lambda("return {0x01, x};")}
await _run_apply_action(
registries, fields, config, args=[(cg.uint8, "x")], platform=platform
)
text = _apply_definition(mock_cg)
# Parenthesized so a reduced `return a, b;` stays one initializer
assert "const std::vector<uint8_t> esphome__bytes = (" in text
assert "-> std::vector<uint8_t>" in text
assert (
f"::{PARENT_OBJ}->send(esphome__bytes.data(), esphome__bytes.size());" in text
)
assert "progmem_memcpy" not in text
@pytest.mark.asyncio
async def test_apply_bytes_constant_is_a_shared_table(
registries: tuple[Registry, Registry], mock_cg: MockCodegen
) -> None:
"""A constant payload is the shared PROGMEM table and its length, named after the action."""
CORE.config = {}
fields = (ApplyBytes("data", "send({})"),)
await _run_apply_action(registries, fields, {"data": [0xA1, 0x02]})
assert f"::{PARENT_OBJ}->send(::esphome__my_apply__bytes, 2);" in _apply_definition(
mock_cg
)
mock_cg.new_pvariable.reset_mock()
await _run_apply_action(registries, fields, {"data": b""})
assert f"::{PARENT_OBJ}->send(nullptr, 0);" in _apply_definition(mock_cg)
mock_cg.new_pvariable.reset_mock()
await _run_apply_action(registries, fields, {})
assert "send(" not in _apply_definition(mock_cg)
with pytest.raises(EsphomeError, match="maximum is 65535"):
await _run_apply_action(registries, fields, {"data": bytes(0x10000)})
@pytest.mark.asyncio
async def test_apply_bytes_constant_is_copied_out_of_flash_on_esp8266(
registries: tuple[Registry, Registry], mock_cg: MockCodegen
) -> None:
"""On ESP8266 a constant goes through a stack buffer, or straight to a flash-aware target."""
CORE.config = {}
fields = (ApplyBytes("data", "send({})", stack=128),)
await _run_apply_action(
registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266"
)
text = _apply_definition(mock_cg)
lines = [
"uint8_t esphome__bytes[2];",
"esphome::progmem_memcpy(esphome__bytes, ::esphome__my_apply__bytes, 2);",
f"::{PARENT_OBJ}->send(esphome__bytes, 2);",
]
positions = [text.index(line) for line in lines]
assert positions == sorted(positions)
# Past the stack budget the copy goes to the heap
mock_cg.new_pvariable.reset_mock()
fields = (ApplyBytes("data", "send({})", stack=1),)
await _run_apply_action(
registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266"
)
text = _apply_definition(mock_cg)
assert "auto esphome__bytes = std::make_unique<uint8_t[]>(2);" in text
assert (
"esphome::progmem_memcpy(esphome__bytes.get(), ::esphome__my_apply__bytes, 2);"
in text
)
assert f"::{PARENT_OBJ}->send(esphome__bytes.get(), 2);" in text
mock_cg.new_pvariable.reset_mock()
fields = (ApplyBytes("data", "send({})", progmem_target="send_progmem({})"),)
await _run_apply_action(
registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266"
)
text = _apply_definition(mock_cg)
assert f"::{PARENT_OBJ}->send_progmem(::esphome__my_apply__bytes, 2);" in text
assert "progmem_memcpy" not in text
# An empty payload never touches flash but still passes a non-null buffer
mock_cg.new_pvariable.reset_mock()
fields = (ApplyBytes("data", "send({})"),)
await _run_apply_action(registries, fields, {"data": []}, platform="esp8266")
text = _apply_definition(mock_cg)
assert "uint8_t esphome__bytes[1];" in text
assert f"::{PARENT_OBJ}->send(esphome__bytes, 0);" in text
assert "progmem_memcpy" not in text
@pytest.mark.asyncio
async def test_apply_bytes_beside_other_fields_and_call_object(
registries: tuple[Registry, Registry], mock_cg: MockCodegen
) -> None:
CORE.config = {}
fields = (
ApplyField("channel", "set_channel", cg.uint8),
ApplyBytes("data", "write({})"),
)
await _run_apply_action(
registries, fields, {"channel": 3, "data": [1]}, call="make_call"
)
text = _apply_definition(mock_cg)
lines = [
f"auto apply_call = ::{PARENT_OBJ}->make_call();",
"apply_call.set_channel(3);",
"apply_call.write(::esphome__my_apply__bytes, 1);",
"apply_call.perform();",
]
positions = [text.index(line) for line in lines]
assert positions == sorted(positions)
def test_apply_bytes_checks_its_shape(registries: tuple[Registry, Registry]) -> None:
# Escaped braces are not placeholders
assert ApplyBytes("data", "send({{}}, {})").target == "send({{}}, {})"
with pytest.raises(ValueError, match="needs one {}"):
ApplyBytes("data", "send({{}})")
with pytest.raises(ValueError, match="needs one {}"):
ApplyBytes("data", "send()")
with pytest.raises(ValueError, match="needs one {}"):
ApplyBytes("data", "send({})", progmem_target="send_progmem({}, {})")
with pytest.raises(ValueError, match="'dat' is not in the schema"):
register_apply_action(
"my.bad",
cv.Schema(
{cv.Required(CONF_ID): cv.string, cv.Required("data"): cv.string}
),
ApplyBytes("dat", "send({})"),
)
@pytest.mark.asyncio
async def test_register_apply_condition_predicate(
registries: tuple[Registry, Registry], mock_cg: MockCodegen