Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch

This commit is contained in:
J. Nick Koston
2026-08-28 13:52:00 -05:00
23 changed files with 522 additions and 300 deletions
+15
View File
@@ -214,11 +214,13 @@ COMPILER_OPTIMIZATIONS = {
# builds that need them. # builds that need them.
DEFAULT_EXCLUDED_IDF_COMPONENTS = ( DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"app_trace", # CPU trace/SystemView support - unused by ESPHome "app_trace", # CPU trace/SystemView support - unused by ESPHome
"bt", # Bluetooth stack - re-included by request_bluetooth(); its REQUIRES pulls the WiFi stack back
"cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing
"console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured
"driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers
"esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf
"esp_adc", # ADC driver - only needed by adc component "esp_adc", # ADC driver - only needed by adc component
"esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back
"esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back
"esp_driver_dac", # DAC driver - only needed by esp32_dac component "esp_driver_dac", # DAC driver - only needed by esp32_dac component
"esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs
@@ -236,6 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component
"esp_eth", # Ethernet driver - only needed by ethernet component "esp_eth", # Ethernet driver - only needed by ethernet component
"esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back "esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back
"esp_hal_ieee802154", # 802.15.4 HAL - ieee802154 pulls it back
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component "esp_http_client", # HTTP client - only needed by http_request component
"esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server "esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server
@@ -243,8 +246,11 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_https_server", # HTTPS server - ESPHome has its own web server "esp_https_server", # HTTPS server - ESPHome has its own web server
"esp_lcd", # LCD controller drivers - only needed by display component "esp_lcd", # LCD controller drivers - only needed by display component
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
"esp_phy", # RF PHY - esp_wifi/bt/ieee802154 pull it back when they are in the build
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
"espcoredump", # Core dump support - ESPHome has its own debug component "espcoredump", # Core dump support - ESPHome has its own debug component
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back
"json", # cJSON library - ESPHome uses ArduinoJson instead "json", # cJSON library - ESPHome uses ArduinoJson instead
"mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation
"nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set "nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set
@@ -260,6 +266,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"unity", # Unit testing framework - ESPHome doesn't use IDF's testing "unity", # Unit testing framework - ESPHome doesn't use IDF's testing
"wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused
"wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation
"wpa_supplicant", # WPA supplicant - re-included by request_wifi() for esp_eap_client.h
) )
# Additional IDF managed components to exclude for Arduino framework builds # Additional IDF managed components to exclude for Arduino framework builds
@@ -709,6 +716,9 @@ def request_wifi(ap: bool = False) -> None:
net.wifi = True net.wifi = True
if ap: if ap:
net.wifi_ap = True net.wifi_ap = True
include_builtin_idf_component("esp_wifi")
# wifi_component.cpp includes esp_eap_client.h/esp_wpa2.h
include_builtin_idf_component("wpa_supplicant")
def request_ethernet() -> None: def request_ethernet() -> None:
@@ -720,11 +730,14 @@ def request_bluetooth() -> None:
"""Request the Bluetooth controller.""" """Request the Bluetooth controller."""
net = _network_sdkconfig() net = _network_sdkconfig()
net.bluetooth = True net.bluetooth = True
include_builtin_idf_component("bt")
def request_software_coexistence() -> None: def request_software_coexistence() -> None:
"""Request WiFi/BT software coexistence (only valid alongside WiFi).""" """Request WiFi/BT software coexistence (only valid alongside WiFi)."""
_network_sdkconfig().software_coexistence = True _network_sdkconfig().software_coexistence = True
# Callers include esp_coexist.h directly.
include_builtin_idf_component("esp_coex")
def add_idf_component( def add_idf_component(
@@ -2304,6 +2317,8 @@ async def _reconcile_network_sdkconfig() -> None:
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here. # relies on the IDF default (enabled), so it is never written True here.
# esp_wifi is excluded by default on IDF, so this only matters for Arduino
# or when bt pulls it back.
wifi_disabled = net.ethernet and not net.wifi wifi_disabled = net.ethernet and not net.wifi
if wifi_disabled: if wifi_disabled:
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False) set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False)
+5
View File
@@ -155,6 +155,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESPNOW") cg.add_define("USE_ESPNOW")
cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE])
if CORE.is_esp32:
from esphome.components.esp32 import include_builtin_idf_component
include_builtin_idf_component("esp_wifi")
if CONF_WIFI in CORE.config: if CONF_WIFI in CORE.config:
# Track the Wi-Fi channel via connect events instead of polling every loop # Track the Wi-Fi channel via connect events instead of polling every loop
wifi.request_wifi_connect_state_listener() wifi.request_wifi_connect_state_listener()
+8
View File
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_PROTOCOL, CONF_PROTOCOL,
CONF_SERVICE, CONF_SERVICE,
CONF_SERVICES, CONF_SERVICES,
CONF_WIFI,
PlatformFramework, PlatformFramework,
) )
from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.core import CORE, Lambda, coroutine_with_priority
@@ -211,6 +212,13 @@ async def to_code(config: ConfigType) -> None:
add_idf_component(name="espressif/mdns", ref="1.12.0") add_idf_component(name="espressif/mdns", ref="1.12.0")
# ESPHome only advertises; the browse APIs are unused # ESPHome only advertises; the browse APIs are unused
add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False)
# The mdns console CLI is never used by ESPHome
add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_CONSOLE_CLI", False)
if CONF_WIFI not in CORE.config:
# Without WiFi the predefined STA/AP interface handlers are dead
# code; disabling them lets mdns build without the WiFi stack.
add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_STA", False)
add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_AP", False)
cg.add_define("USE_MDNS") cg.add_define("USE_MDNS")
+2 -3
View File
@@ -89,9 +89,8 @@ _WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def is_function_code_write(function_code: int) -> bool: def is_function_code_write(function_code: int) -> bool:
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
so an exception-flagged code still classifies by its base code - stricter than the runtime hub, so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
whose classify() treats an exception-flagged code as a read. Keep in sync with queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
modbus::helpers::is_function_code_write()."""
return function_code & 0x7F in _WRITE_FUNCTION_CODES return function_code & 0x7F in _WRITE_FUNCTION_CODES
+22 -69
View File
@@ -10,17 +10,12 @@ namespace esphome::modbus {
static const char *const TAG = "modbus"; static const char *const TAG = "modbus";
// Maximum bytes to log for Modbus frames (truncated if larger)
static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; static constexpr size_t MODBUS_MAX_LOG_BYTES = 64;
// Approximate bits per character on the wire (depends on parity/stop bit config) // Approximate bits per character on the wire (depends on parity/stop bit config)
static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11;
// Milliseconds per second
static constexpr uint32_t MS_PER_SEC = 1000; static constexpr uint32_t MS_PER_SEC = 1000;
// Shortest gap between two "no device accepted broadcast" warnings
static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC;
void Modbus::setup() { void Modbus::setup() {
if (this->flow_control_pin_ != nullptr) { if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->setup(); this->flow_control_pin_->setup();
@@ -43,10 +38,7 @@ void Modbus::setup() {
} }
void Modbus::loop() { void Modbus::loop() {
// Receive any available bytes from UART
this->receive_bytes_(); this->receive_bytes_();
// Parse bytes into frames and process them
this->parse_modbus_frames(); this->parse_modbus_frames();
} }
@@ -55,7 +47,7 @@ void ModbusClientHub::loop() {
// never times out an entry whose pending count has not been drained. No-op when nothing is owed. // never times out an entry whose pending count has not been drained. No-op when nothing is owed.
this->sweep_(); this->sweep_();
this->Modbus::loop(); // receive bytes and parse frames this->Modbus::loop();
// Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the // Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the
// entry up and holds off if the response has started arriving. // entry up and holds off if the response has started arriving.
@@ -104,11 +96,8 @@ bool Modbus::timeout_() {
} }
int32_t Modbus::tx_delay_remaining() { int32_t Modbus::tx_delay_remaining() {
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // millis() here and everywhere in this component, never a cached loop timestamp: a cached "now" can
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // predate last_modbus_byte_, and the unsigned subtraction then wraps huge and forces a false timeout.
// If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
// So in this component we don't use any cached timestamp values to avoid these annoying bugs
const uint32_t now = millis(); const uint32_t now = millis();
return std::max({(int32_t) 0, return std::max({(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)),
@@ -124,22 +113,13 @@ int32_t ModbusClientHub::tx_delay_remaining() {
} }
bool Modbus::tx_blocked() { bool Modbus::tx_blocked() {
// We block transmission in any of these cases: // Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction
// 1. There are bytes in the UART Rx buffer // (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to
// 2. There are bytes in our Rx buffer // MODBUS_TX_MAX_DELAY_MS doesn't block - send_frame_ absorbs it instead of looping on small waits.
// 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
// 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
// N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by
// send_frame_.
return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS;
} }
bool ModbusClientHub::tx_blocked() { bool ModbusClientHub::tx_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); }
// We block transmission in any of these case:
// 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED)
// 2. Any of the base class tx_blocked conditions
return this->waiting_for_response_ || this->Modbus::tx_blocked();
}
bool ModbusClientHub::tx_buffer_empty() { bool ModbusClientHub::tx_buffer_empty() {
// "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in // "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in
@@ -219,10 +199,9 @@ void ModbusServerHub::parse_modbus_frames() {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
} }
// Scans forward from min_length to find a frame boundary by CRC match for unknown-length function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
// could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0]; const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size(); const size_t size = this->rx_buffer_.size();
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE))); const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
@@ -531,8 +510,7 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
return; return;
} }
// A broadcast is never answered, so a rejecting device has no other feedback channel: report the // A broadcast is never answered, so a rejecting device has no other feedback channel: report the
// per-device outcome at V, and warn if the write reached nobody at all. // per-device outcome at V.
bool accepted = false;
for (auto *device : this->devices_) { for (auto *device : this->devices_) {
// Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need // Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need
// to: the hub owns the difference, which is only that no reply is ever sent. // to: the hub owns the difference, which is only that no reply is ever sent.
@@ -542,24 +520,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
if (device_status.has_value()) { if (device_status.has_value()) {
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
static_cast<uint8_t>(device_status.value())); static_cast<uint8_t>(device_status.value()));
} else {
accepted = true;
}
}
if (!accepted && !this->devices_.empty()) {
const uint16_t entity_count = coils ? coil_count : static_cast<uint16_t>(registers.size());
const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers");
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
// repeats forever, so warning per frame would flood the log.
const uint32_t now = millis();
if (this->last_unaccepted_broadcast_warn_ == 0 ||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
this->last_unaccepted_broadcast_warn_ = now;
ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
} else {
ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
} }
} }
} }
@@ -783,8 +743,6 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
delay(tx_delay_remaining); delay(tx_delay_remaining);
} }
// The delay above can span several ms; a byte arriving in that window blocks transmission after the
// caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry.
if (this->tx_blocked()) { if (this->tx_blocked()) {
return false; return false;
} }
@@ -831,7 +789,7 @@ void ModbusClientHub::send_next_frame_() {
// reports the transmission, and the entry then retires with no terminal callback instead of // reports the transmission, and the entry then retires with no terminal callback instead of
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
// spaces the next frame; the following sweep erases the entry. // spaces the next frame; the following sweep erases the entry.
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)"); ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
cmd->complete_broadcast(); cmd->complete_broadcast();
this->sweep_needed_ = true; this->sweep_needed_ = true;
return; return;
@@ -983,6 +941,8 @@ bool ModbusDeviceCommand::timed_out() {
this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1) this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1)
if (this->device == nullptr) if (this->device == nullptr)
return false; // resolved, no one to tell return false; // resolved, no one to tell
// A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may
// call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner.
if (this->device->on_no_response(this->frame.pdu())) if (this->device->on_no_response(this->frame.pdu()))
this->increment_pending(); // granted retry = re-request (capped) this->increment_pending(); // granted retry = re-request (capped)
return true; return true;
@@ -1054,18 +1014,14 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size());
return false; return false;
} }
// classify() drives both the broadcast guard and the continuous check below; compute it once.
const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]);
// A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that if (helpers::is_function_code_exception(pdu[0])) {
// changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - ESP_LOGW(TAG, "Exception PDU refused for address %" PRIu8 ": function code 0x%X has the exception bit set", address,
// as it could never deliver a result, so the caller learns via the false return (and on_not_sent). pdu[0]);
// 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half return false;
// lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom }
// code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly
// here to match classify()'s exception-first handling of the write side. if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE &&
(!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) {
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
return false; return false;
} }
@@ -1073,7 +1029,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or // Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request. // merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended). // continuous is ignored for every mutating code (re-writing a value forever is never intended).
if (options.continuous && priority == CommandPriority::WRITE) { if (options.continuous && helpers::is_function_code_write(pdu[0])) {
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false; options.continuous = false;
} }
@@ -1089,9 +1045,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
continue; continue;
if (device == nullptr) { if (device == nullptr) {
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
const bool requeueable = if (helpers::is_function_code_read_only(pdu[0])) {
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
if (requeueable) {
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
} else { } else {
ESP_LOGW(TAG, ESP_LOGW(TAG,
@@ -1364,7 +1318,6 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
} }
} }
// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response
void ModbusClientDevice::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu, void ModbusClientDevice::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) { ResponseStatus status) {
// The dispatcher never calls this with an empty request, but this is a public virtual - stay safe. // The dispatcher never calls this with an empty request, but this is a public virtual - stay safe.
+71 -146
View File
@@ -16,26 +16,21 @@
namespace esphome::modbus { namespace esphome::modbus {
// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a // Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames
// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a // (e.g. a loop writing a changing value) could grow the heap unboundedly.
// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing.
// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus
// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one
// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266.
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes // Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes
// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation. // (address + 5-byte PDU + 2-byte CRC).
static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8; static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8;
struct ModbusFrame { struct ModbusFrame {
// Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger // Small-buffer-optimized: typical frames fit inline, keeping high-frequency tx traffic off the
// multi-register or custom frames spill to a single heap allocation. This keeps the common, // heap; only large multi-register or custom frames spill to a single heap allocation.
// high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data;
// The buffer tracks its own length, so no separate size field is needed.
SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data; // Modbus RTU max is 256 bytes
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) { ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) {
uint8_t *buf = this->data.init(pdu_len + 3); uint8_t *buf = this->data.init(pdu_len + 3);
buf[0] = address; buf[0] = address;
@@ -46,12 +41,9 @@ struct ModbusFrame {
} }
uint16_t size() const { return static_cast<uint16_t>(this->data.size()); } uint16_t size() const { return static_cast<uint16_t>(this->data.size()); }
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
uint8_t address() const { return this->data.data()[0]; } uint8_t address() const { return this->data.data()[0]; }
/// The PDU: function code + data, without address or CRC. Only valid while the frame is alive. /// A PDU is [function code][data...] without address or CRC. Only valid while the frame is alive.
/// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the /// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors)
/// subtraction would wrap on anything shorter.
std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); } std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); }
}; };
@@ -73,15 +65,9 @@ class Modbus : public uart::UARTDevice, public Component {
virtual int32_t tx_delay_remaining(); virtual int32_t tx_delay_remaining();
virtual void parse_modbus_frames() = 0; virtual void parse_modbus_frames() = 0;
bool parse_modbus_server_frame_(); bool parse_modbus_server_frame_();
// pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code.
virtual void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) = 0; virtual void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) = 0;
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
// Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms,
// so this re-checks after the delay and returns false without transmitting if a byte arrived in that
// window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted.
bool send_frame_(const ModbusFrame &frame); bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0}; uint32_t last_modbus_byte_{0};
@@ -99,8 +85,7 @@ class Modbus : public uart::UARTDevice, public Component {
class ModbusClientDevice; class ModbusClientDevice;
class ModbusServerDevice; class ModbusServerDevice;
// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived // Transmit ordering, highest first: writes before one-shot reads before continuous polls.
// at selection time, never caller-chosen or stored.
enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE }; enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE };
// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed // Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed
@@ -112,20 +97,15 @@ enum class FrameState : uint8_t {
RECEIVED_EXCEPTION, RECEIVED_EXCEPTION,
TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase
INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout
WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal WAITING_RETIRED, // retired while WAITING: a late response is still delivered as its usual terminal
INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response INTERRUPTED_RETIRED, // retired while INTERRUPTED: still distrusts late frames, ends in on_no_response
RETIRED, // cleared, off the wire RETIRED, // retired, off the wire
}; };
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing - // A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
// but it arrives inert. Every new field must define three rules before it does anything: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is // retire()/silent_retire().
// stripped for mutating codes),
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
// to erase the entry.
struct CommandOptions { struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false}; bool continuous{false};
@@ -135,17 +115,13 @@ struct ModbusDeviceCommand {
ModbusClientDevice *device; ModbusClientDevice *device;
ModbusFrame frame; ModbusFrame frame;
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin // Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs // fairness within a class. Meant to wrap.
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
uint16_t seq{0}; uint16_t seq{0};
FrameState state{FrameState::READY}; FrameState state{FrameState::READY};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each. // Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure. // A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
uint8_t pending{1}; uint8_t pending{1};
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes // The entry's LIVE effective options, not a record of the caller's request
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
// CommandOptions comment for the rules a new field must define.
CommandOptions options; CommandOptions options;
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options; // Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
@@ -154,28 +130,22 @@ struct ModbusDeviceCommand {
CommandOptions options = {}, uint16_t seq = 0) CommandOptions options = {}, uint16_t seq = 0)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {} : device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const { CommandPriority priority() const {
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]); if (this->options.continuous)
} return CommandPriority::CONTINUOUS;
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded. if (helpers::is_function_code_write(this->frame.pdu()[0])) {
static CommandPriority classify(uint8_t function_code) {
if (helpers::is_function_code_exception(function_code))
return CommandPriority::READ;
if (helpers::is_function_code_write(function_code)) {
return CommandPriority::WRITE; return CommandPriority::WRITE;
} }
return CommandPriority::READ; return CommandPriority::READ;
} }
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. // Requests this entry can serve
uint8_t max_pending() const { uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0]; const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc); return (helpers::is_function_code_read_only(fc) && !this->options.continuous) ? 2 : 1;
return (requeueable && !this->options.continuous) ? 2 : 1;
} }
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for // Device-scoped clear: detach with no callback. An entry still waiting for a response keeps its state as a
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED. // reply-ignoring shell that resolves silently; any other goes RETIRED.
void silent_retire() { void silent_retire() {
if (!this->waiting_state()) if (!this->waiting_state())
this->state = FrameState::RETIRED; this->state = FrameState::RETIRED;
@@ -183,28 +153,18 @@ struct ModbusDeviceCommand {
this->device = nullptr; this->device = nullptr;
} }
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
// callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing.
// A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such
// code caps pending at 1, so pending is always 1 here - clear it.
void complete_broadcast() { void complete_broadcast() {
this->state = FrameState::RETIRED; this->state = FrameState::RETIRED;
this->pending = 0; this->pending = 0;
} }
// Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). // Re-ready for another transmission, restamped to the tail of its class
void requeue(uint16_t seq) { void requeue(uint16_t seq) {
this->state = FrameState::READY; this->state = FrameState::READY;
this->seq = seq; this->seq = seq;
} }
// Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to // Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to
// a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter - // a one-shot.
// to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed.
// On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the
// single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run
// once to serve that request - so restore one first. While the flag is still set max_pending() is 1,
// so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op
// on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without
// retroactively inflating that no-op.
void make_continuous(bool continuous) { void make_continuous(bool continuous) {
if (continuous) { if (continuous) {
this->options.continuous = true; this->options.continuous = true;
@@ -214,13 +174,9 @@ struct ModbusDeviceCommand {
this->options.continuous = false; this->options.continuous = false;
} }
} }
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run // Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-delivered
// request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is // request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is
// still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED -> // still coming) and drains only its duplicates.
// INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other
// state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry:
// the clear is address-scoped (any device may call it) while the retry is the owning device's call
// via on_no_response - the bus obeys the owner.
void retire() { void retire() {
if (this->state == FrameState::WAITING) { if (this->state == FrameState::WAITING) {
this->state = FrameState::WAITING_RETIRED; this->state = FrameState::WAITING_RETIRED;
@@ -229,10 +185,10 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED; this->state = FrameState::RETIRED;
} }
this->options = {}; // reset every option so a future field is torn down without editing here this->options = {}; // reset every option
} }
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0. // True while the entry is still waiting for a response
bool waiting_state() const { bool waiting_state() const {
return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED || return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED ||
this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED; this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED;
@@ -245,7 +201,7 @@ struct ModbusDeviceCommand {
} }
return false; return false;
} }
// Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry).
bool increment_pending() { bool increment_pending() {
if (this->pending < this->max_pending()) { if (this->pending < this->max_pending()) {
this->pending++; this->pending++;
@@ -255,7 +211,7 @@ struct ModbusDeviceCommand {
} }
// Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and // Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and
// returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here. // returns whether a callback ran.
bool sent(); bool sent();
bool response(std::span<const uint8_t> response_pdu); bool response(std::span<const uint8_t> response_pdu);
bool error(ExceptionCode exception_code); bool error(ExceptionCode exception_code);
@@ -264,9 +220,6 @@ struct ModbusDeviceCommand {
bool notify_retired(); bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one. /// True if this command carries the same wire frame (address + PDU) as the given one.
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
/// cancel built from different argument values will not reach the polls it does not byte-match.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const { bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu(); const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address && return own_pdu.size() == pdu.size() && this->frame.address() == address &&
@@ -291,17 +244,13 @@ class ModbusClientHub : public Modbus {
payload_len), payload_len),
device); device);
}; };
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast,
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows.
/// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets /// Neither means anything reached the wire - on_sent() reports that.
/// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr, bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {}); CommandOptions options = {});
// Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: // Remove before 2027.2.0. Deliberately the void, no-options signature 2026.7.4 shipped: nothing
// the bool return and the options argument arrived after that release, so nothing external can be // external can rely on the later additions under this name.
// relying on them under this name. Callers who want the queued/refused answer move to queue_pdu().
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
"reports whether the request was accepted. Removed in 2027.2.0", "reports whether the request was accepted. Removed in 2027.2.0",
"2026.8.0") "2026.8.0")
@@ -310,9 +259,10 @@ class ModbusClientHub : public Modbus {
} }
ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0") ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr); void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the // Clear all commands matching the given address; each unsent request resolves via on_not_sent(), but a
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. // frame on the wire still runs to its usual terminal.
void clear_tx_queue_for_address(uint8_t address); void clear_tx_queue_for_address(uint8_t address);
// Clear all commands for a given device; no callbacks are delivered.
void clear_tx_queue_for_device(ModbusClientDevice *device); void clear_tx_queue_for_device(ModbusClientDevice *device);
protected: protected:
@@ -322,8 +272,7 @@ class ModbusClientHub : public Modbus {
void send_next_frame_(); void send_next_frame_();
// Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState. // Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState.
void sweep_(); void sweep_();
// The selection function: best READY entry (WRITE class first, then one-shot reads, then the // The selection function: best READY entry (ordered by priority; FIFO by seq within each group), or nullptr.
// least-recently-served continuous; FIFO by seq within each group), or nullptr.
ModbusDeviceCommand *select_next_ready_(); ModbusDeviceCommand *select_next_ready_();
// Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED). // Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED).
ModbusDeviceCommand *find_waiting_(); ModbusDeviceCommand *find_waiting_();
@@ -349,13 +298,10 @@ class ModbusClientHub : public Modbus {
// Transaction status: std::nullopt on success, otherwise a Modbus exception code // Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>; using ResponseStatus = std::optional<ExceptionCode>;
/// True when a transaction carried no exception. The optional holds the exception, so has_value() means /// True when a transaction carried no exception.
/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the
/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code
/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer.
inline bool succeeded(ResponseStatus status) { return !status.has_value(); } inline bool succeeded(ResponseStatus status) { return !status.has_value(); }
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // Register values exchanged with server handlers, in address order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type. // the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>; using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
@@ -373,59 +319,46 @@ class ModbusServerHub : public Modbus {
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data); void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered. // Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data); void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register // Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the address order register
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus // values, validating the register count and address range. Shared by unicast and broadcast writes.
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
// writes (which silently drop invalid frames).
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues &registers); ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues &registers);
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address, ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers); RegisterValues &registers);
// Appends the big-endian register values in values to registers, in host byte order. // Assembles host-order registers from the big-endian bytes in values and appends them to registers.
void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers); void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers);
ModbusServerDevice *find_device_(uint8_t address); ModbusServerDevice *find_device_(uint8_t address);
// Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space, // Returns std::nullopt if [start_address, start_address + count) fits in a 16-bit address space, otherwise
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast // ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. Shared by the
// write is never answered, so the check cannot send it itself. Shared by the register and // register/coil/discrete-input handlers, which all use a 16-bit address space.
// coil/discrete-input handlers, which all address the same 16-bit space.
ResponseStatus check_address_range_(uint16_t start_address, uint16_t count); ResponseStatus check_address_range_(uint16_t start_address, uint16_t count);
// Parses a read request PDU (start address(2) + quantity(2)), shared by the register and // Parses read request data. max_entities is the protocol ceiling for the function code; entity_name labels
// coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the // the rejection log.
// function code; entity_name only labels the rejection log.
ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name, ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name,
uint16_t &start_address, uint16_t &count); uint16_t &start_address, uint16_t &count);
// Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed // Parses single-coil write data
// bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take.
ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value); ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value);
// Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive // Parses write-multiple-coil data into a packed-bit view pointing straight into the receive buffer, so the
// buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and // coil values are never copied.
// broadcast paths so the two validate identically.
ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count, ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count,
std::span<const uint8_t> &packed_bytes); std::span<const uint8_t> &packed_bytes);
// Builds the body of a register read response (byte count followed by the big-endian register values) into // Builds the body of a register read response into response_buffer. Returns false once an exception has
// response_buffer. Shared by every function code that answers with register values, so the read reply stays // been sent: the one the handler reported via status, or SERVICE_DEVICE_FAILURE if it returned the wrong
// identical across them. Returns false once an exception has been sent: the one the handler reported via // number of registers, the count exceeds the protocol read limit, or the body does not fit.
// status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the
// protocol read limit, or the body does not fit.
bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers, uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len); std::span<uint8_t> response_buffer, uint16_t &response_len);
void send_raw_(const uint8_t *payload, uint16_t len); void send_raw_(const uint8_t *payload, uint16_t len);
// Sends and logs the exception reply when status holds one; returns true if the request was rejected. // Sends and logs the exception reply when status holds one; returns true if the request was rejected.
// Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart.
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status); bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0}; uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_; std::vector<ModbusServerDevice *> devices_;
// Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting
// on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries.
uint32_t last_unaccepted_broadcast_warn_{0};
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_; std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
@@ -555,10 +488,7 @@ class ModbusClientDevice {
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
this); this);
} }
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will /// See ModbusClientHub::queue_pdu() for the return contract.
/// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()),
/// false = refused at the door and nothing further happens. Neither means the frame is on the wire;
/// on_sent() reports that.
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) { bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
return this->parent_->queue_pdu(this->address_, pdu, this, options); return this->parent_->queue_pdu(this->address_, pdu, this, options);
} }
@@ -573,11 +503,8 @@ class ModbusClientDevice {
return; // too short to contain a PDU; refused at the door like any invalid send return; // too short to contain a PDU; refused at the door like any invalid send
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this); this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
} }
// The typed request builders below all queue through queue_pdu(), so they share its contract: true // The typed request builders below all queue through queue_pdu() and share its return contract.
// means the request is queued and will resolve in exactly one terminal callback (except a broadcast // Reads use the table-appropriate function code; an unreadable entity type maps to INVALID, which
// (address 0), which is never answered and so gets only on_sent()), false means it was refused outright
// with no callback. Neither says the frame has been transmitted - on_sent() does.
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
CommandOptions options = {}) { CommandOptions options = {}) {
@@ -607,6 +534,9 @@ class ModbusClientDevice {
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
} }
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) { bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
// Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's.
if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS)
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values));
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
} }
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed /// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
@@ -619,11 +549,9 @@ class ModbusClientDevice {
bool write_multiple_coils(uint16_t start_address, PackedBits bits) { bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
} }
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
/// read registers, the same wire shape as a holding-register read). A device exception - typically a /// (typically a rejected write half) arrives there too via its status - one callback handles both
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, /// outcomes with no on_error() override needed.
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
/// needs to also override on_error().
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
std::span<const uint16_t> write_values) { std::span<const uint16_t> write_values) {
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count, return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
@@ -644,12 +572,9 @@ class ModbusClientDevice {
bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE
}; };
// Compatibility shim for external components written against the pre-2026.8 API, which subclassed // Compatibility shim adapting the span-based hooks back to the pre-2026.8 on_modbus_data()/
// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree // on_modbus_error() signatures (the owning-vector heap copy exists only on this deprecated path).
// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old // Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0).
// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy
// exists only on this deprecated path) and on_modbus_error() the function code and exception code.
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0)
class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0", class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0",
"2026.8.0") ModbusDevice : public ModbusClientDevice { "2026.8.0") ModbusDevice : public ModbusClientDevice {
public: public:
@@ -47,7 +47,6 @@ enum class FunctionCode : uint8_t {
using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0", using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0",
"2026.8.0") = FunctionCode; "2026.8.0") = FunctionCode;
/*Allow direct comparison operators between FunctionCode and uint8_t*/
inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; } inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); } inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); } inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
@@ -117,6 +116,9 @@ static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) =
static constexpr uint16_t READ_PDU_SIZE = 5; static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2) // A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
// A multiple-write PDU starts with function code(1) + start address(2) + quantity(2) + byte count(1),
// followed by two bytes per register.
static constexpr uint16_t WRITE_MULTIPLE_HEADER_SIZE = 6;
static constexpr uint16_t MAX_FRAME_SIZE = 256; static constexpr uint16_t MAX_FRAME_SIZE = 256;
// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered. // 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered.
+26 -9
View File
@@ -30,9 +30,11 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
switch (static_cast<FunctionCode>(frame[0])) { switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS: case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_DISCRETE_INPUTS:
// function(1) + byte count(1) + packed coil bytes
return 2 + (size > 1 ? std::min(frame[1], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ))) : 0);
case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS:
// function(1) + byte count(1) + data // function(1) + byte count(1) + register data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case FunctionCode::WRITE_SINGLE_COIL: case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_SINGLE_REGISTER:
@@ -60,6 +62,9 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
uint16_t client_pdu_length(const uint8_t *frame, size_t size) { uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
if (size < MIN_PDU_SIZE) if (size < MIN_PDU_SIZE)
return MIN_PDU_SIZE; return MIN_PDU_SIZE;
if (is_function_code_exception(frame[0])) {
return 2; // never a valid request; sized like the exception reply so the CRC fails at once
}
switch (static_cast<FunctionCode>(frame[0])) { switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS: case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_DISCRETE_INPUTS:
@@ -381,8 +386,6 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities,
const uint8_t *values, size_t values_len) { const uint8_t *values, size_t values_len) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
if (is_function_code_read_only(static_cast<uint8_t>(function_code))) { if (is_function_code_read_only(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) { if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
@@ -445,9 +448,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
return pdu; return pdu;
} }
// The quantity is spec-bounded above, so the data length just has to agree with it exactly // The quantity is spec-bounded above, so the data length just has to agree with it exactly
// (registers are 2 bytes each, coils pack 8 per byte). This is the same consistency the response // (registers are 2 bytes each, coils pack 8 per byte).
// dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified
// non-standard on reply, and the spec bound keeps the PDU within capacity by construction.
// Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one. // Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one.
const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS;
const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast<size_t>(number_of_entities) * 2; const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast<size_t>(number_of_entities) * 2;
@@ -484,9 +485,12 @@ static bool register_block_in_range(const LogString *role, uint16_t start_addres
return true; return true;
} }
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) { // The ceiling comes from the buffer itself: push_back() drops silently, so a bound wider than the buffer
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) // would put a truncated frame on the wire.
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) { template<typename Pdu> static Pdu build_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
constexpr auto max_registers = static_cast<uint16_t>((Pdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2);
Pdu pdu; // declared before every return so NRVO fires (all paths return the same object)
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), max_registers)) {
return pdu; return pdu;
} }
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
@@ -497,6 +501,19 @@ PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uin
return pdu; return pdu;
} }
static_assert((PduBuffer::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_NUM_OF_REGISTERS_TO_WRITE,
"a full-frame PDU must hold exactly MAX_NUM_OF_REGISTERS_TO_WRITE registers");
static_assert((WriteFewRegistersPdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_FEW_REGISTERS,
"the small write buffer must hold exactly MAX_FEW_REGISTERS registers");
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<PduBuffer>(start_address, values);
}
WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<WriteFewRegistersPdu>(start_address, values);
}
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address, uint16_t write_start_address,
std::span<const uint16_t> write_values) { std::span<const uint16_t> write_values) {
+35 -17
View File
@@ -60,14 +60,11 @@ inline bool is_function_code_custom(uint8_t function_code) {
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined /// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes /// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. /// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - /// Exception-flagged codes (0x80 set) are always the 2-byte spec exception shape, so never unknown.
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
/// pays (recovery by timeout instead of an immediate CRC failure).
inline bool is_function_code_unknown_length(uint8_t function_code) { inline bool is_function_code_unknown_length(uint8_t function_code) {
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) { if (is_function_code_exception(function_code))
return false;
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::READ_COILS: case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS: case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_HOLDING_REGISTERS:
@@ -87,6 +84,17 @@ inline bool is_function_code_unknown_length(uint8_t function_code) {
} }
} }
/// True when the underlying function code (exception bit masked off) may be broadcast (address 0).
/// Refused: the reads (including read-write), plus every other code whose response length the parser
/// knows (file record, FIFO). Allowed: the writes, and any code the parser does not know, since the
/// hub cannot tell one of those apart from a vendor write.
inline bool is_function_code_broadcastable(uint8_t function_code) {
uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK;
if (is_function_code_read(masked_function_code))
return false;
return is_function_code_write(masked_function_code) || is_function_code_unknown_length(masked_function_code);
}
// Returns the expected length of a server response PDU based on the function code. // Returns the expected length of a server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
@@ -205,7 +213,7 @@ enum class SensorValueType : uint8_t {
S_DWORD = 0x4, // 2 Registers signed S_DWORD = 0x4, // 2 Registers signed
BIT = 0x5, BIT = 0x5,
U_DWORD_R = 0x6, // 2 Registers unsigned U_DWORD_R = 0x6, // 2 Registers unsigned
S_DWORD_R = 0x7, // 2 Registers unsigned S_DWORD_R = 0x7, // 2 Registers signed
U_QWORD = 0x8, U_QWORD = 0x8,
S_QWORD = 0x9, S_QWORD = 0x9,
U_QWORD_R = 0xA, U_QWORD_R = 0xA,
@@ -280,7 +288,7 @@ inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10
* byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34
* byte_from_hex_str("1122", 0) returns 0x11 * byte_from_hex_str("1122", 0) returns 0x11
* @param value string containing hex encoding * @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * @param pos offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* the hex string is byte_pos * 2 * the hex string is byte_pos * 2
* @return byte value * @return byte value
*/ */
@@ -292,8 +300,7 @@ inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a word from a hex string /** Get a word from a hex string
* @param value string containing hex encoding * @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * @param pos offset in bytes (see byte_from_hex_str)
* the hex string is byte_pos * 2
* @return word value * @return word value
*/ */
inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
@@ -302,8 +309,7 @@ inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a dword from a hex string /** Get a dword from a hex string
* @param value string containing hex encoding * @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * @param pos offset in bytes (see byte_from_hex_str)
* the hex string is byte_pos * 2
* @return dword value * @return dword value
*/ */
inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
@@ -312,8 +318,7 @@ inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a qword from a hex string /** Get a qword from a hex string
* @param value string containing hex encoding * @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * @param pos offset in bytes (see byte_from_hex_str)
* the hex string is byte_pos * 2
* @return qword value * @return qword value
*/ */
inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
@@ -328,9 +333,9 @@ template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_
* Responses for coil are packed into bytes . * Responses for coil are packed into bytes .
* coil 3 is bit 3 of the first response byte * coil 3 is bit 3 of the first response byte
* coil 9 is bit 2 of the second response byte * coil 9 is bit 2 of the second response byte
* @param coil number of the cil * @param bit index of the bit to extract
* @param data modbus response buffer (uint8_t) * @param data modbus response buffer (uint8_t)
* @return content of coil register * @return value of the requested bit
*/ */
inline bool bit_from_packed(int bit, std::span<const uint8_t> data) { inline bool bit_from_packed(int bit, std::span<const uint8_t> data) {
auto data_byte = bit / 8; auto data_byte = bit / 8;
@@ -468,11 +473,15 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
*/ */
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
/// The widest standard numeric value (a QWORD) spans 4 registers, so one entity value never writes more.
static constexpr uint16_t MAX_FEW_REGISTERS = 4;
// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector, // Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector,
// right-sized per shape) can be swapped in one place without touching every signature. // right-sized per shape) can be swapped in one place without touching every signature.
using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>; using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>;
using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>; using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>;
using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>; using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>;
using WriteFewRegistersPdu = StaticVector<uint8_t, WRITE_MULTIPLE_HEADER_SIZE + 2 * MAX_FEW_REGISTERS>;
/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. /// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum.
using CoilPackBuffer = StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)>; using CoilPackBuffer = StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)>;
@@ -516,6 +525,15 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
*/ */
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values); PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus write multiple registers command (function 0x10) on a right-sized stack buffer.
* Identical wire bytes to create_write_registers_pdu() for any accepted input.
* @param start_address modbus address of the first register to write
* @param values register values to write, at most MAX_FEW_REGISTERS (an over-long or empty set is
* rejected and an empty PDU is returned)
* @return PDU (function code + data, no address, no CRC)
*/
WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus read/write multiple registers command /** Create modbus read/write multiple registers command
* Function 0x17 Read/Write Multiple Registers * Function 0x17 Read/Write Multiple Registers
* Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17);
@@ -9,6 +9,7 @@ from esphome.components.esp32 import (
add_idf_component, add_idf_component,
add_idf_sdkconfig_option, add_idf_sdkconfig_option,
add_partition, add_partition,
include_builtin_idf_component,
require_vfs_select, require_vfs_select,
) )
import esphome.config_validation as cv import esphome.config_validation as cv
@@ -288,6 +289,10 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
ref="2.0.4", ref="2.0.4",
) )
if CONF_WIFI in CORE.config:
# zigbee_esp32.cpp uses esp_coexist.h when WiFi is present
include_builtin_idf_component("esp_coex")
# add sdkconfigs later so they can overwrite esp32 defaults # add sdkconfigs later so they can overwrite esp32 defaults
CORE.add_job(_zigbee_add_sdkconfigs, config) CORE.add_job(_zigbee_add_sdkconfigs, config)
+1
View File
@@ -290,6 +290,7 @@ template<typename T, size_t N> class StaticVector {
} }
size_t size() const { return count_; } size_t size() const { return count_; }
static constexpr size_t capacity() { return N; }
bool empty() const { return count_ == 0; } bool empty() const { return count_ == 0; }
// Direct access to underlying data // Direct access to underlying data
+16 -1
View File
@@ -1053,6 +1053,19 @@ def _check_esp_idf_python_env_install(
constraint_file_path, constraint_file_path,
) )
# uv (much faster than pip) when available, e.g. in the docker image
if uv_path := shutil.which("uv"):
cmd_pip_install = [
uv_path,
"pip",
"install",
"--python",
str(env_python_path),
"--upgrade",
"--constraint",
str(constraint_file_path),
]
else:
cmd_pip_install = [ cmd_pip_install = [
str(env_python_path), str(env_python_path),
"-m", "-m",
@@ -1060,7 +1073,7 @@ def _check_esp_idf_python_env_install(
"install", "install",
"--upgrade", "--upgrade",
"--constraint", "--constraint",
constraint_file_path, str(constraint_file_path),
] ]
_LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version) _LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version)
@@ -1135,6 +1148,8 @@ def check_esp_idf_install(
env = {} env = {}
env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_TOOLS_PATH"] = str(get_idf_tools_path())
env["IDF_PATH"] = "" env["IDF_PATH"] = ""
# uv defaults to 3 HTTP retries; match the pioarduino penv's bump to 10
env["UV_HTTP_RETRIES"] = os.environ.get("UV_HTTP_RETRIES", "10")
# An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's # An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's
# per-variant request (builder-image pre-warm); otherwise the caller's # per-variant request (builder-image pre-warm); otherwise the caller's
+39 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable, MutableMapping from collections.abc import Callable, Iterable, MutableMapping
from contextlib import suppress from contextlib import suppress
import ipaddress import ipaddress
import logging import logging
@@ -456,23 +456,55 @@ def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) ->
env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts)
def rmtree(path: Path | str) -> None: # Deletion attempts when a directory keeps being repopulated mid-delete
"""Remove a directory tree, handling read-only files on Windows. RMTREE_MAX_ATTEMPTS = 3
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail. This handles that by removing the def rmtree(path: Path | str) -> None:
read-only flag and retrying. """Remove a directory tree, tolerating common filesystem races.
Read-only files (e.g. git pack files on Windows) get the read-only flag
removed and are retried. Paths that are already gone, whether the target
itself or entries vanishing mid-delete, are treated as removed.
Directories repopulated mid-delete (e.g. Finder recreating .DS_Store on
macOS) are retried a few times.
""" """
import errno
import shutil import shutil
import time
def _onexc(func, path, exc): def _onexc(func: Callable[..., object], path: str | Path, exc: OSError) -> None:
if isinstance(exc, FileNotFoundError):
_LOGGER.debug("rmtree: %s already gone", path)
return
if os.access(path, os.W_OK): if os.access(path, os.W_OK):
raise exc raise exc
Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR)
func(path) func(path)
last_err: OSError | None = None
for attempt in range(RMTREE_MAX_ATTEMPTS - 1):
try:
shutil.rmtree(path, onexc=_onexc) shutil.rmtree(path, onexc=_onexc)
return
except OSError as err:
if err.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
_LOGGER.debug(
"rmtree: %s repopulated mid-delete (attempt %d): %s",
path,
attempt + 1,
err,
)
last_err = err
# Give the racing writer (e.g. Finder) time to settle
time.sleep(0.05 * (attempt + 1))
try:
shutil.rmtree(path, onexc=_onexc)
except OSError as err:
# Keep the earlier races visible in the traceback
raise err from last_err
def walk_files(path: Path): def walk_files(path: Path):
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
espnow:
channel: 1
auto_add_peer: true
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
esp32_ble_tracker:
@@ -6,6 +6,8 @@ esp32:
framework: framework:
type: esp-idf type: esp-idf
mdns:
ethernet: ethernet:
type: W5500 type: W5500
clk_pin: 19 clk_pin: 19
@@ -6,6 +6,8 @@ esp32:
framework: framework:
type: esp-idf type: esp-idf
mdns:
wifi: wifi:
ssid: "test_ssid" ssid: "test_ssid"
password: "test_password" password: "test_password"
+29 -4
View File
@@ -24,6 +24,7 @@ from esphome.components.esp32 import (
) )
from esphome.components.esp32.const import ( from esphome.components.esp32.const import (
KEY_ESP32, KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
KEY_NETWORK_SDKCONFIG, KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS, KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT, KEY_VARIANT,
@@ -298,6 +299,20 @@ def test_esp32_configuration_errors(
("esp-tls", "esp_http_client"), ("esp-tls", "esp_http_client"),
id="nextion", id="nextion",
), ),
pytest.param(
# esp_wifi/wpa_supplicant from request_wifi(), bt from
# request_bluetooth(), esp_coex from esp32_ble_tracker's software
# coexistence (defaults on with wifi). esp_phy stays excluded;
# IDF requirement expansion pulls it back via esp_wifi.
"exclusion_reincludes_wifi_ble.yaml",
("esp_wifi", "wpa_supplicant", "bt", "esp_coex"),
id="wifi_ble",
),
pytest.param(
"exclusion_reincludes_espnow.yaml",
("esp_wifi",),
id="espnow",
),
], ],
) )
def test_default_exclusions_reincluded_by_owning_components( def test_default_exclusions_reincluded_by_owning_components(
@@ -309,8 +324,6 @@ def test_default_exclusions_reincluded_by_owning_components(
"""Components whose IDF driver is excluded by default must re-include it """Components whose IDF driver is excluded by default must re-include it
during codegen; a dropped include_builtin_idf_component() call would only during codegen; a dropped include_builtin_idf_component() call would only
surface as a missing-header failure in a full compile job.""" surface as a missing-header failure in a full compile job."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path(config_file)) generate_main(component_config_path(config_file))
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
@@ -329,8 +342,6 @@ def test_nvs_sec_provider_stays_excluded_when_encryption_is_off(
component_config_path: Callable[[str], Path], component_config_path: Callable[[str], Path],
) -> None: ) -> None:
"""An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded.""" """An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml")) generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
@@ -939,6 +950,14 @@ def test_network_wifi_only_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# request_wifi() also puts the WiFi components back in the build set;
# esp_phy stays excluded, IDF requirement expansion pulls it back.
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
assert "esp_wifi" not in excluded
assert "wpa_supplicant" not in excluded
assert "esp_phy" in excluded
# With wifi present mdns keeps its predefined interfaces.
assert "CONFIG_MDNS_PREDEF_NETIF_STA" not in sdkconfig
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested. # WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_BT_ENABLED" not in sdkconfig assert "CONFIG_BT_ENABLED" not in sdkconfig
@@ -954,6 +973,12 @@ def test_network_ethernet_only_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
# The whole radio stack stays out of the build set as well.
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded
# Without wifi, mdns drops its predefined STA/AP interfaces.
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_STA") is False
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_AP") is False
def test_network_wifi_ble_coexistence_reconciles_end_to_end( def test_network_wifi_ble_coexistence_reconciles_end_to_end(
@@ -775,7 +775,7 @@ TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) {
// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be // A broadcast is only meaningful for a command that changes state; a broadcast READ could never be
// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently // answered, so the hub refuses it at the door (false return, no entry queued) rather than silently
// retiring it. Writes, 0x17, and custom codes still go through (covered above). // retiring it. Writes and custom/unknown codes still go through (covered in the neighboring tests).
TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
NullUART uart; NullUART uart;
NoResponseProbeHub hub; NoResponseProbeHub hub;
@@ -814,9 +814,8 @@ TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
EXPECT_EQ(hub.entries(), 0u); // the entry is gone EXPECT_EQ(hub.entries(), 0u); // the entry is gone
} }
// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks // An exception-flagged code (0x80 bit set) is never a valid request - that bit is response-only - so
// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling // queue_pdu refuses it up front, before the broadcast guard, whatever its base code.
// of an exception-flagged write.
TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
NullUART uart; NullUART uart;
NoResponseProbeHub hub; NoResponseProbeHub hub;
@@ -833,6 +832,50 @@ TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
EXPECT_EQ(device.sent_count_, 0); // never transmitted EXPECT_EQ(device.sent_count_, 0); // never transmitted
} }
// FC23 (read/write multiple) has a read half that expects a reply, so the Modbus spec does not allow it
// as a broadcast. is_function_code_read() covers it, so the broadcast guard refuses it despite its write
// half.
TEST(ModbusClientHubBroadcast, RefusesReadWriteMultipleBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
// fc, read start+qty, write start+qty, byte count, one data word.
const uint8_t read_write_multiple[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x01, 0x02, 0xBE, 0xEF};
EXPECT_FALSE(device.queue_pdu(read_write_multiple)); // its read half could never be answered
EXPECT_EQ(hub.entries(), 0u);
}
// FC 0x18 (read FIFO queue) is not a "read" by is_function_code_read(), but the hub has an explicit
// response-length rule for it - it demonstrably expects a reply, so it cannot broadcast.
TEST(ModbusClientHubBroadcast, RefusesKnownLengthNonWriteBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read_fifo[] = {0x18, 0x00, 0x10}; // fc, FIFO pointer address
EXPECT_FALSE(device.queue_pdu(read_fifo));
EXPECT_EQ(hub.entries(), 0u);
}
// A code that is neither a read nor exception-flagged (here 0x63, unassigned) is fire-and-forget on a
// broadcast: the hub can't know it isn't a vendor write, so it is accepted and delivered to all devices.
TEST(ModbusClientHubBroadcast, AcceptsNonReadUnknownBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t unknown[] = {0x63, 0x00, 0x01};
EXPECT_TRUE(device.queue_pdu(unknown)); // not a read, so not refused
EXPECT_EQ(hub.entries(), 1u);
}
namespace { namespace {
// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. // tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check.
class RejectPostDelayHub : public NoResponseProbeHub { class RejectPostDelayHub : public NoResponseProbeHub {
@@ -1882,30 +1925,20 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
} }
// An exception-flagged function code is never silently re-sendable, even though the read check // The exception bit marks a response, so a request carrying it is refused outright.
// masks the exception bit: its duplicate takes the drop path like any other non-read. TEST(ModbusClientHubPriority, ExceptionFlaggedPduRefused) {
TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) {
NoResponseProbeHub hub; NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02); SentCountingDevice device(&hub, 0x02);
const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged // The 0x80 exception flag is a response-only bit; a request must never set it. queue_pdu refuses an
EXPECT_TRUE(device.queue_pdu(weird)); // exception-flagged PDU up front - nothing is queued - whether its base code reads (0x83 = 0x03 | 0x80)
EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused // or writes (0x86 = 0x06 | 0x80).
const uint8_t read_shaped[] = {0x83, 0x01, 0x00, 0x00, 0x02};
const uint8_t write_shaped[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
EXPECT_FALSE(device.queue_pdu(read_shaped));
EXPECT_FALSE(device.queue_pdu(write_shaped));
hub.sweep_for_test(); hub.sweep_for_test();
EXPECT_EQ(hub.queued_frames(), 0u);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 1u);
EXPECT_EQ(device.not_sent_count_, 0);
// The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class
// ordering either: exception-flagged codes are excluded from the mutates classification.
const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(weird_write);
ASSERT_EQ(hub.queued_frames(), 2u);
EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry
} }
namespace { namespace {
@@ -63,6 +63,12 @@ TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE); EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
} }
TEST(ModbusClientFrameLength, ExceptionFlaggedIsTheExceptionShape) {
// Sized at 2 so an exception-flagged request fails its CRC at once instead of being scanned for.
const uint8_t exception_request[] = {0x83, 0x02};
EXPECT_EQ(client_pdu_length(exception_request, sizeof(exception_request)), 2);
}
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) { TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
// basic_register request fixture is a read-holding request -> 8 bytes // basic_register request fixture is a read-holding request -> 8 bytes
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A}; const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
@@ -483,6 +489,28 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
} }
TEST(ModbusTypedBuilders, WriteFewRegistersPduMatchesFullSizeBuilder) {
static_assert(sizeof(WriteFewRegistersPdu) < sizeof(PduBuffer) / 4,
"WriteFewRegistersPdu must be meaningfully smaller");
const uint16_t values[] = {0x000B, 0x0016, 0xABCD, 0xFF00};
for (size_t count = 1; count <= MAX_FEW_REGISTERS; count++) {
auto small = create_write_few_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
auto full = create_write_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
EXPECT_EQ(std::vector<uint8_t>(small.begin(), small.end()), std::vector<uint8_t>(full.begin(), full.end()))
<< count << " registers";
EXPECT_EQ(small.size(), 6u + 2 * count);
EXPECT_TRUE(is_client_pdu_standard(small.data(), small.size()));
}
}
TEST(ModbusTypedBuilders, WriteFewRegistersPduRejectsInvalidInput) {
const uint16_t values[MAX_FEW_REGISTERS + 1] = {0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA};
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, values).empty());
EXPECT_FALSE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>(values, MAX_FEW_REGISTERS)).empty());
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>()).empty());
EXPECT_TRUE(create_write_few_registers_pdu(0xFFFF, std::span<const uint16_t>(values, 2)).empty());
}
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) {
const uint16_t write_values[] = {0x000B, 0x0016}; const uint16_t write_values[] = {0x000B, 0x0016};
// Read 2 registers at 0x0010, write 2 registers at 0x0020. // Read 2 registers at 0x0010, write 2 registers at 0x0020.
@@ -54,7 +54,8 @@ class TestServerHub : public ModbusServerHub {
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the // The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - // assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
// must classify as unknown length. The exception flag masks off first. // must classify as unknown length. Exception replies are always the 2-byte spec shape, so every
// 0x80-set code is known length.
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
@@ -62,11 +63,13 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
} }
// Exception replies classify by their base code. // Every exception-flagged code is known length (the 2-byte spec exception shape), whatever its base.
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); EXPECT_FALSE(helpers::is_function_code_unknown_length(0x87));
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. EXPECT_FALSE(helpers::is_function_code_unknown_length(0xC9));
for (int fc = 0; fc <= 0xFF; fc++) { // Strictly wider than the user-defined ranges below 0x80: every non-exception custom code is
// unknown-length, but not vice versa.
for (int fc = 0; fc <= 0x7F; fc++) {
if (helpers::is_function_code_custom(fc)) if (helpers::is_function_code_custom(fc))
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
} }
@@ -75,10 +78,10 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall // Derived contract check: the helper must say "unknown" exactly when both length parsers fall
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2 // through to default. With a zero-filled max-size PDU every explicit case returns at least 2
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. Both
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() // parsers early-return the 2-byte exception shape above 0x7F, which the helper's own exception
// switches on the unmasked byte and server_pdu_length() early-returns the exception length. // early-return mirrors, so the whole byte range is covered.
for (int fc = 0; fc <= 0x7F; fc++) { for (int fc = 0; fc <= 0xFF; fc++) {
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
EXPECT_EQ(helpers::is_function_code_unknown_length(fc), EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
@@ -89,6 +92,17 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
} }
} }
// Broadcastable = writes plus unknown codes (possible vendor writes); everything known to expect a
// reply is not. Classifies the underlying code: the exception bit masks off first (0x85 as 0x05).
TEST(ModbusUnknownFunction, BroadcastableClassification) {
for (uint8_t fc : {0x05, 0x06, 0x0F, 0x10, 0x16, 0x49, 0x63, 0x6E, 0x85, 0xC9}) {
EXPECT_TRUE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
}
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18, 0x83, 0x97}) {
EXPECT_FALSE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
}
}
// A response with a function code outside the user-defined ranges (0x49) has no length case in // A response with a function code outside the user-defined ranges (0x49) has no length case in
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already // server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the // handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
+27
View File
@@ -520,6 +520,33 @@ def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) ->
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
def test_python_deps_use_uv_when_available(
espidf_mocks: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The python env installs go through uv when on the PATH, pip otherwise."""
monkeypatch.delenv("UV_HTTP_RETRIES", raising=False)
with patch(
"esphome.espidf.framework.shutil.which",
# Keyed on the name: the same which() also probes the default tools
side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None,
):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call, feature_call = espidf_mocks.run_ok.call_args_list[1:3]
upgrade_cmd, feature_cmd = upgrade_call.args[0], feature_call.args[0]
assert upgrade_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert "--python" in upgrade_cmd
assert feature_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "10"
espidf_mocks.run_ok.reset_mock()
monkeypatch.setenv("UV_HTTP_RETRIES", "3") # an explicit user value wins
with patch("esphome.espidf.framework.shutil.which", return_value=None):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call = espidf_mocks.run_ok.call_args_list[1]
assert upgrade_call.args[0][1:4] == ["-m", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "3"
def _mark_installed() -> None: def _mark_installed() -> None:
"""Create the extracted marker and python-env interpreter so the install """Create the extracted marker and python-env interpreter so the install
check takes the already-installed path rather than force-installing.""" check takes the already-installed path rather than force-installing."""
+73 -1
View File
@@ -1,3 +1,4 @@
import errno
import io import io
import logging import logging
import os import os
@@ -5,7 +6,7 @@ from pathlib import Path
import socket import socket
import stat import stat
import types import types
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, call, patch
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
from hypothesis import given, settings from hypothesis import given, settings
@@ -966,6 +967,77 @@ def test_copy_file_if_changed_nonexistent_source(tmp_path: Path) -> None:
helpers.copy_file_if_changed(src, dst) helpers.copy_file_if_changed(src, dst)
def test_rmtree_removes_tree(tmp_path: Path) -> None:
"""Test rmtree removes a populated directory tree."""
target = tmp_path / "target"
(target / "sub").mkdir(parents=True)
(target / "sub" / "file.txt").write_text("content")
helpers.rmtree(target)
assert not target.exists()
def test_rmtree_nonexistent_path(tmp_path: Path) -> None:
"""Test rmtree on an already-removed path is a no-op."""
helpers.rmtree(tmp_path / "gone")
def test_rmtree_retries_when_directory_repopulated(tmp_path: Path) -> None:
"""Test rmtree retries when a file appears mid-delete (Finder .DS_Store race)."""
target = tmp_path / "target"
(target / "sub").mkdir(parents=True)
real_rmdir = os.rmdir
repopulated = False
def racy_rmdir(path, **kwargs):
nonlocal repopulated
if not repopulated and Path(path).name == "target":
repopulated = True
(target / ".DS_Store").write_text("x") # Finder wins the race
real_rmdir(path, **kwargs)
with patch("os.rmdir", side_effect=racy_rmdir), patch("time.sleep"):
helpers.rmtree(target)
assert repopulated
assert not target.exists()
def test_rmtree_raises_after_retries_exhausted(tmp_path: Path) -> None:
"""Test rmtree gives up on a persistent ENOTEMPTY once attempts run out."""
target = tmp_path / "target"
target.mkdir()
errs = [
OSError(errno.ENOTEMPTY, "Directory not empty", str(target))
for _ in range(helpers.RMTREE_MAX_ATTEMPTS)
]
with (
patch("shutil.rmtree", side_effect=errs) as mock_rmtree,
patch("time.sleep") as mock_sleep,
pytest.raises(OSError, match="Directory not empty") as excinfo,
):
helpers.rmtree(target)
assert mock_rmtree.call_count == helpers.RMTREE_MAX_ATTEMPTS
assert mock_sleep.call_args_list == [call(0.05), call(0.1)]
# Final failure chains to the last retried race
assert excinfo.value is errs[-1]
assert excinfo.value.__cause__ is errs[-2]
def test_rmtree_does_not_retry_other_oserror(tmp_path: Path) -> None:
"""Test rmtree raises non-ENOTEMPTY errors immediately."""
target = tmp_path / "target"
target.mkdir()
err = OSError(errno.EACCES, "Permission denied", str(target))
with (
patch("shutil.rmtree", side_effect=err) as mock_rmtree,
pytest.raises(OSError, match="Permission denied"),
):
helpers.rmtree(target)
assert mock_rmtree.call_count == 1
def test_resolve_ip_address_sorting() -> None: def test_resolve_ip_address_sorting() -> None:
"""Test that results are sorted by preference.""" """Test that results are sorted by preference."""
# Create multiple address infos with different preferences # Create multiple address infos with different preferences