[modbus_client] Keep constant write values in shared flash tables

This commit is contained in:
J. Nick Koston
2026-10-08 11:13:08 -04:00
committed by GitHub
parent 9066ad6cd0
commit 1bd16ba14a
4 changed files with 106 additions and 24 deletions
+12 -9
View File
@@ -13,7 +13,7 @@ from esphome.const import (
CONF_ON_RESPONSE,
CONF_VALUE,
)
from esphome.core import ID, Lambda
from esphome.core import Lambda
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@exciton"]
@@ -487,9 +487,10 @@ async def write_multiple_registers_to_code(config, action_id, template_arg, args
templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16))
cg.add(var.set_values_template(templ))
else:
# A static list goes to flash, so play() sends straight from there without allocating.
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
# Shared flash table; play() sends without allocating (ESP8266 copies it to the stack first).
arr = cg.shared_progmem_array(
"modbus_client_registers", cg.uint16, cg.ArrayInitializer(*values)
)
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(
var, config, args, [], command_direction="write"
@@ -514,8 +515,9 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args):
for i, coil in enumerate(values):
if coil:
packed[i // 8] |= 1 << (i % 8)
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed))
arr = cg.shared_progmem_array(
"modbus_client_coils", cg.uint8, cg.ArrayInitializer(*packed)
)
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(
var, config, args, [], command_direction="write"
@@ -581,8 +583,9 @@ async def read_write_multiple_registers_to_code(config, action_id, template_arg,
templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16))
cg.add(var.set_values_template(templ))
else:
# A static list goes to flash, so play() sends straight from there without allocating.
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
# Shared flash table; play() sends without allocating (ESP8266 copies it to the stack first).
arr = cg.shared_progmem_array(
"modbus_client_registers", cg.uint16, cg.ArrayInitializer(*values)
)
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")])
@@ -3,6 +3,8 @@
#include "esphome/components/modbus/modbus.h"
#include "esphome/components/modbus/modbus_helpers.h"
#include "esphome/core/automation.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <span>
#include <vector>
@@ -161,6 +163,22 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch
/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an
/// exception as a successful reply.
// Static lists up to this size are staged on the stack on ESP8266; longer ones use the heap.
static constexpr size_t STATIC_REGISTERS_ON_STACK = 16;
static constexpr size_t STATIC_COIL_BYTES_ON_STACK = 16;
/// Calls fn with a RAM-readable span of a static values table. On ESP8266 the table is in PROGMEM, which the
/// PDU builders cannot read directly, so it is copied to the stack first (the heap above N elements).
template<size_t N, typename T, typename F> void with_static_values(const T *data, size_t len, F &&fn) {
#ifdef USE_ESP8266
SmallBufferWithHeapFallback<N, T> buf(len);
progmem_memcpy(buf.get(), data, len * sizeof(T));
fn(std::span<const T>(buf.get(), len));
#else
fn(std::span<const T>(data, len));
#endif
}
template<typename... Ts> class TypedClientActionBase : public ClientActionBase<Ts...> {
public:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_custom_response_trigger() {
@@ -290,7 +308,7 @@ class WriteSingleCoilAction : public TypedClientActionBase<Ts...>, public WriteC
};
/// modbus_client.write_multiple_registers: on_response is the acknowledgement (no arguments).
/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a
/// A `values:` list is a shared flash table (staged on the stack on ESP8266); only a lambda builds a
/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static
/// list must not allocate on every play().
template<typename... Ts>
@@ -298,7 +316,7 @@ class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
/// Static config: the registers live in flash, so play() neither allocates nor copies.
/// Static config: the registers are a shared flash table.
void set_values_static(const uint16_t *values, size_t len) {
this->values_.data = values;
this->len_ = static_cast<ssize_t>(len);
@@ -317,9 +335,11 @@ class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public
// An empty or over-long set rejects into an empty PDU inside the builder, which logs the reason;
// the empty PDU then resolves via on_not_sent like any refused send.
if (this->len_ >= 0) {
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
this->write_command_options_(x...));
with_static_values<STATIC_REGISTERS_ON_STACK>(
this->values_.data, static_cast<size_t>(this->len_), [&](std::span<const uint16_t> values) {
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, values),
this->write_command_options_(x...));
});
return;
}
const std::vector<uint16_t> values = this->values_.func(x...);
@@ -367,11 +387,11 @@ class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...>, public Wri
const uint16_t start = this->start_address_.value(x...);
if (this->count_ >= 0) {
const auto count = static_cast<uint16_t>(this->count_);
this->send_or_resolve_(
modbus::helpers::create_write_coils_pdu(
start, modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)),
count)),
this->write_command_options_(x...));
with_static_values<STATIC_COIL_BYTES_ON_STACK>(
this->values_.packed, modbus::packed_bit_bytes(count), [&](std::span<const uint8_t> packed) {
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, modbus::PackedBits(packed, count)),
this->write_command_options_(x...));
});
return;
}
// The builder packs and bound-checks; an over-long set is rejected and logged there.
@@ -402,7 +422,7 @@ class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, pu
TEMPLATABLE_VALUE(uint16_t, read_count)
TEMPLATABLE_VALUE(uint16_t, write_address)
/// Static config: the write registers live in flash, so play() neither allocates nor copies.
/// Static config: the write registers are a shared flash table.
void set_values_static(const uint16_t *values, size_t len) {
this->values_.data = values;
this->len_ = static_cast<ssize_t>(len);
@@ -421,10 +441,12 @@ class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, pu
const uint16_t write_start = this->write_address_.value(x...);
// An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent.
if (this->len_ >= 0) {
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
read_start, read_count, write_start,
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
this->command_options_(x...));
with_static_values<STATIC_REGISTERS_ON_STACK>(
this->values_.data, static_cast<size_t>(this->len_), [&](std::span<const uint16_t> values) {
this->send_or_resolve_(
modbus::helpers::create_read_write_multiple_registers_pdu(read_start, read_count, write_start, values),
this->command_options_(x...));
});
return;
}
const std::vector<uint16_t> values = this->values_.func(x...);
@@ -0,0 +1,35 @@
esphome:
name: test
on_boot:
then:
- modbus_client.write_multiple_registers:
address: 0x01
start_address: 0x10
values: [0x1234, 0xABCD]
- modbus_client.read_write_multiple_registers:
address: 0x01
write_address: 0x20
values: [0x1234, 0xABCD]
read_address: 0x30
- modbus_client.write_multiple_coils:
address: 0x01
start_address: 0x40
values: [true, false, true, true, false, false, true, false, true]
- modbus_client.write_multiple_coils:
address: 0x01
start_address: 0x50
values: [true, false, true, true, false, false, true, false, true]
- modbus_client.write_multiple_registers:
address: 0x01
start_address: 0x60
values: !lambda return {1};
esp32:
board: esp32dev
uart:
tx_pin: 17
rx_pin: 16
baud_rate: 9600
modbus:
@@ -0,0 +1,22 @@
"""Tests for modbus_client constant write values in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
def test_constant_values_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal value lists share one table across actions; lambdas stay templates."""
main_cpp = generate_main(component_config_path("write_values_tables.yaml"))
tables = re.findall(
r"static constexpr (uint16_t|uint8_t) \w+\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
# Coils pack LSB first: 0b01001101, then bit 8 in the second byte
assert tables == [("uint16_t", "{0x1234, 0xABCD}"), ("uint8_t", "{77, 1}")]
assert main_cpp.count("set_values_static(modbus_client_registers, 2);") == 2
assert main_cpp.count("set_values_static(modbus_client_coils, 9);") == 2
assert "set_values_template(" in main_cpp