diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bc91f29a42..48bb7bf6a1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -214,11 +214,13 @@ COMPILER_OPTIMIZATIONS = { # builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "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 "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 "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "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_dac", # DAC driver - only needed by esp32_dac component "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_eth", # Ethernet driver - only needed by ethernet component "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_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 @@ -243,8 +246,11 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_https_server", # HTTPS server - ESPHome has its own web server "esp_lcd", # LCD controller drivers - only needed by display component "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 "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 "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 @@ -260,6 +266,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused "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 @@ -709,6 +716,9 @@ def request_wifi(ap: bool = False) -> None: net.wifi = True if ap: 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: @@ -720,11 +730,14 @@ def request_bluetooth() -> None: """Request the Bluetooth controller.""" net = _network_sdkconfig() net.bluetooth = True + include_builtin_idf_component("bt") def request_software_coexistence() -> None: """Request WiFi/BT software coexistence (only valid alongside WiFi).""" _network_sdkconfig().software_coexistence = True + # Callers include esp_coexist.h directly. + include_builtin_idf_component("esp_coex") 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 # 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 if wifi_disabled: set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index ee3732c406..5541a6ee97 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -155,6 +155,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESPNOW") 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: # Track the Wi-Fi channel via connect events instead of polling every loop wifi.request_wifi_connect_state_listener() diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 64d7b9adc5..f039bb69f0 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_PROTOCOL, CONF_SERVICE, CONF_SERVICES, + CONF_WIFI, PlatformFramework, ) 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") # ESPHome only advertises; the browse APIs are unused 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") diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 769858e72a..76cfdbed70 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -89,9 +89,8 @@ _WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) 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, - so an exception-flagged code still classifies by its base code - stricter than the runtime hub, - whose classify() treats an exception-flagged code as a read. Keep in sync with - modbus::helpers::is_function_code_write().""" + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" return function_code & 0x7F in _WRITE_FUNCTION_CODES diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index e83ffb2708..aa998d283a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -10,17 +10,12 @@ namespace esphome::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; // Approximate bits per character on the wire (depends on parity/stop bit config) static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; -// Milliseconds per second 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() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); @@ -43,10 +38,7 @@ void Modbus::setup() { } void Modbus::loop() { - // Receive any available bytes from UART this->receive_bytes_(); - - // Parse bytes into frames and process them 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. 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 // entry up and holds off if the response has started arriving. @@ -104,11 +96,8 @@ bool Modbus::timeout_() { } int32_t Modbus::tx_delay_remaining() { - // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps - // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time - // 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 + // millis() here and everywhere in this component, never a cached loop timestamp: a cached "now" can + // predate last_modbus_byte_, and the unsigned subtraction then wraps huge and forces a false timeout. const uint32_t now = millis(); return std::max({(int32_t) 0, (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() { - // We block transmission in any of these cases: - // 1. There are bytes in the UART Rx buffer - // 2. There are bytes in our Rx buffer - // 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_. + // Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction + // (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to + // MODBUS_TX_MAX_DELAY_MS doesn't block - send_frame_ absorbs it instead of looping on small waits. return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; } -bool ModbusClientHub::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_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); } bool ModbusClientHub::tx_buffer_empty() { // "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); } +// 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 { - // 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 size_t size = this->rx_buffer_.size(); const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); @@ -531,8 +510,7 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< return; } // 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. - bool accepted = false; + // per-device outcome at V. for (auto *device : this->devices_) { // 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. @@ -542,24 +520,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< if (device_status.has_value()) { ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), static_cast(device_status.value())); - } else { - accepted = true; - } - } - if (!accepted && !this->devices_.empty()) { - const uint16_t entity_count = coils ? coil_count : static_cast(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); } - // 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()) { 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 // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already // 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(); this->sweep_needed_ = true; return; @@ -983,6 +941,8 @@ bool ModbusDeviceCommand::timed_out() { this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1) if (this->device == nullptr) 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())) this->increment_pending(); // granted retry = re-request (capped) return true; @@ -1054,18 +1014,14 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); 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 - // changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - - // as it could never deliver a result, so the caller learns via the false return (and on_not_sent). - // 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half - // 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 && priority != CommandPriority::WRITE && - (!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) { + if (helpers::is_function_code_exception(pdu[0])) { + ESP_LOGW(TAG, "Exception PDU refused for address %" PRIu8 ": function code 0x%X has the exception bit set", address, + pdu[0]); + return false; + } + + if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); return false; } @@ -1073,7 +1029,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M // 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. // 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); options.continuous = false; } @@ -1089,9 +1045,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M continue; if (device == nullptr) { // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). - const bool requeueable = - !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]); - if (requeueable) { + if (helpers::is_function_code_read_only(pdu[0])) { ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); } else { ESP_LOGW(TAG, @@ -1364,7 +1318,6 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu } } -// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response void ModbusClientDevice::on_custom_response(std::span request_pdu, std::span response_pdu, ResponseStatus status) { // The dispatcher never calls this with an empty request, but this is a public virtual - stay safe. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index e766ff04cc..69a7eb82e3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -16,26 +16,21 @@ namespace esphome::modbus { -// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a -// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a -// 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. +// Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames +// (e.g. a loop writing a changing value) could grow the heap unboundedly. static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; // 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; struct ModbusFrame { - // Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger - // multi-register or custom frames spill to a single heap allocation. This keeps the common, - // high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. - // The buffer tracks its own length, so no separate size field is needed. - SmallInlineBuffer data; // Modbus RTU max is 256 bytes + // Small-buffer-optimized: typical frames fit inline, keeping high-frequency tx traffic off the + // heap; only large multi-register or custom frames spill to a single heap allocation. + SmallInlineBuffer data; + // 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) { uint8_t *buf = this->data.init(pdu_len + 3); buf[0] = address; @@ -46,12 +41,9 @@ struct ModbusFrame { } uint16_t size() const { return static_cast(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]; } - /// The PDU: 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 - /// subtraction would wrap on anything shorter. + /// 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) std::span pdu() const { return std::span(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 void parse_modbus_frames() = 0; 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 pdu) = 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); - // 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; uint32_t last_modbus_byte_{0}; @@ -99,8 +85,7 @@ class Modbus : public uart::UARTDevice, public Component { class ModbusClientDevice; class ModbusServerDevice; -// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived -// at selection time, never caller-chosen or stored. +// Transmit ordering, highest first: writes before one-shot reads before continuous polls. 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 @@ -112,20 +97,15 @@ enum class FrameState : uint8_t { RECEIVED_EXCEPTION, 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 - WAITING_RETIRED, // cleared 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 - RETIRED, // cleared, off the wire + WAITING_RETIRED, // retired while WAITING: a late response is still delivered as its usual terminal + INTERRUPTED_RETIRED, // retired while INTERRUPTED: still distrusts late frames, ends in on_no_response + RETIRED, // retired, off the wire }; // 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 - -// but it arrives inert. Every new field must define three rules before it does anything: -// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is -// 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. +// A new field reaches the queue with no plumbing but arrives inert until it defines three rules: +// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in +// retire()/silent_retire(). struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. bool continuous{false}; @@ -135,17 +115,13 @@ struct ModbusDeviceCommand { ModbusClientDevice *device; ModbusFrame frame; // 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 - // densely and a growing CommandOptions eats trailing padding before enlarging the struct. + // fairness within a class. Meant to wrap. uint16_t seq{0}; FrameState state{FrameState::READY}; // 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. uint8_t pending{1}; - // The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes - // 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. + // The entry's LIVE effective options, not a record of the caller's request CommandOptions 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) : device(device), frame(address, pdu.data(), static_cast(pdu.size())), seq(seq), options(options) {} - // Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot. CommandPriority priority() const { - return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]); - } - // Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded. - 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)) { + if (this->options.continuous) + return CommandPriority::CONTINUOUS; + if (helpers::is_function_code_write(this->frame.pdu()[0])) { return CommandPriority::WRITE; } 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 { 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 (requeueable && !this->options.continuous) ? 2 : 1; + return (helpers::is_function_code_read_only(fc) && !this->options.continuous) ? 2 : 1; } - // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for - // a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED. + // Device-scoped clear: detach with no callback. An entry still waiting for a response keeps its state as a + // reply-ignoring shell that resolves silently; any other goes RETIRED. void silent_retire() { if (!this->waiting_state()) this->state = FrameState::RETIRED; @@ -183,28 +153,18 @@ struct ModbusDeviceCommand { this->device = nullptr; } // 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 - // 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. + // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { this->state = FrameState::RETIRED; 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) { this->state = FrameState::READY; this->seq = seq; } // 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 - - // 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. + // a one-shot. void make_continuous(bool continuous) { if (continuous) { this->options.continuous = true; @@ -214,13 +174,9 @@ struct ModbusDeviceCommand { 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 - // still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED -> - // 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. + // still coming) and drains only its duplicates. void retire() { if (this->state == FrameState::WAITING) { 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 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 { return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED || this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED; @@ -245,7 +201,7 @@ struct ModbusDeviceCommand { } return false; } - // Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry). + bool increment_pending() { if (this->pending < this->max_pending()) { this->pending++; @@ -255,7 +211,7 @@ struct ModbusDeviceCommand { } // 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 response(std::span response_pdu); bool error(ExceptionCode exception_code); @@ -264,9 +220,6 @@ struct ModbusDeviceCommand { bool notify_retired(); /// 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 pdu) const { const auto own_pdu = this->frame.pdu(); return own_pdu.size() == pdu.size() && this->frame.address() == address && @@ -291,17 +244,13 @@ class ModbusClientHub : public Modbus { payload_len), device); }; - /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and - /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in - /// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets - /// 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. + /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, + /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. + /// Neither means anything reached the wire - on_sent() reports that. bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, CommandOptions options = {}); - // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: - // the bool return and the options argument arrived after that release, so nothing external can be - // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + // Remove before 2027.2.0. Deliberately the void, no-options signature 2026.7.4 shipped: nothing + // external can rely on the later additions under this name. 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", "2026.8.0") @@ -310,9 +259,10 @@ class ModbusClientHub : public Modbus { } ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); - // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the - // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. + // Clear all commands matching the given address; each unsent request resolves via on_not_sent(), but a + // frame on the wire still runs to its usual terminal. 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); protected: @@ -322,8 +272,7 @@ class ModbusClientHub : public Modbus { void send_next_frame_(); // Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState. void sweep_(); - // The selection function: best READY entry (WRITE class first, then one-shot reads, then the - // least-recently-served continuous; FIFO by seq within each group), or nullptr. + // The selection function: best READY entry (ordered by priority; FIFO by seq within each group), or nullptr. ModbusDeviceCommand *select_next_ready_(); // Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED). ModbusDeviceCommand *find_waiting_(); @@ -349,13 +298,10 @@ class ModbusClientHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; -/// True when a transaction carried no exception. The optional holds the exception, so has_value() means -/// 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. +/// True when a transaction carried no exception. 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 // the capacity of this type. using RegisterValues = StaticVector; @@ -373,59 +319,46 @@ class ModbusServerHub : public Modbus { void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span data); // Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered. void process_broadcast_frame_(uint8_t function_code, std::span data); - // Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register - // values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus - // exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast - // writes (which silently drop invalid frames). + // 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. Shared by unicast and broadcast writes. ResponseStatus parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters); ResponseStatus parse_write_multiple_(std::span data, uint16_t &start_address, RegisterValues ®isters); - // 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 values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space, - // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast - // write is never answered, so the check cannot send it itself. Shared by the register and - // coil/discrete-input handlers, which all address the same 16-bit space. + // Returns std::nullopt if [start_address, start_address + count) fits in a 16-bit address space, otherwise + // ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. Shared by the + // register/coil/discrete-input handlers, which all use a 16-bit address space. 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 - // coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the - // function code; entity_name only labels the rejection log. + // Parses read request data. max_entities is the protocol ceiling for the function code; entity_name labels + // the rejection log. ResponseStatus parse_read_request_(std::span data, uint16_t max_entities, const LogString *entity_name, 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 - // bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take. + // Parses single-coil write data ResponseStatus parse_write_single_coil_(std::span 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 - // buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and - // broadcast paths so the two validate identically. + // Parses write-multiple-coil data into a packed-bit view pointing straight into the receive buffer, so the + // coil values are never copied. ResponseStatus parse_write_multiple_coils_(std::span data, uint16_t &start_address, uint16_t &count, std::span &packed_bytes); - // Builds the body of a register read response (byte count followed by the big-endian register values) into - // response_buffer. Shared by every function code that answers with register values, so the read reply stays - // identical across them. Returns false once an exception has been sent: the one the handler reported via - // 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. + // Builds the body of a register read response into response_buffer. Returns false once an exception has + // been sent: the one the handler reported via 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, uint16_t number_of_registers, const RegisterValues ®isters, std::span response_buffer, uint16_t &response_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. - // 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); 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); uint8_t expecting_peer_response_{0}; std::vector 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. // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. std::array deferred_payload_; @@ -555,10 +488,7 @@ class ModbusClientDevice { helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will - /// 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. + /// See ModbusClientHub::queue_pdu() for the return contract. bool queue_pdu(std::span pdu, CommandOptions 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 this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } - // The typed request builders below all queue through queue_pdu(), so they share its contract: true - // means the request is queued and will resolve in exactly one terminal callback (except a broadcast - // (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 + // The typed request builders below all queue through queue_pdu() and share its return contract. + // Reads use 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. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, CommandOptions options = {}) { @@ -607,6 +534,9 @@ class ModbusClientDevice { return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); } bool write_multiple_registers(uint16_t start_address, std::span 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)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed @@ -619,11 +549,9 @@ class ModbusClientDevice { bool write_multiple_coils(uint16_t start_address, PackedBits 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 - /// read registers, the same wire shape as a holding-register read). A device exception - typically a - /// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, - /// exactly as success does, so a subclass overriding that one callback handles both outcomes and never - /// needs to also override on_error(). + /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception + /// (typically a rejected write half) arrives there too via its status - one callback handles both + /// outcomes with no on_error() override needed. bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, std::span write_values) { 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 }; -// Compatibility shim for external components written against the pre-2026.8 API, which subclassed -// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree -// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old -// 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) +// Compatibility shim adapting the span-based hooks back to the pre-2026.8 on_modbus_data()/ +// on_modbus_error() signatures (the owning-vector heap copy exists only on this deprecated path). +// 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", "2026.8.0") ModbusDevice : public ModbusClientDevice { public: diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 83f314352f..0939f9e76c 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -47,7 +47,6 @@ enum class FunctionCode : uint8_t { using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0", "2026.8.0") = FunctionCode; -/*Allow direct comparison operators between FunctionCode and uint8_t*/ inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast(rhs); } inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast(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; // A single-write PDU is always function code(1) + address(2) + value(2) 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; // 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered. diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index db21b6e6fd..92bd06cdf5 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -30,9 +30,11 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) { switch (static_cast(frame[0])) { case FunctionCode::READ_COILS: 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_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); case FunctionCode::WRITE_SINGLE_COIL: 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) { if (size < 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(frame[0])) { case FunctionCode::READ_COILS: 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, const uint8_t *values, size_t values_len) { 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(function_code))) { if (values != nullptr || values_len > 0) { 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; } // 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 - // 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. + // (registers are 2 bytes each, coils pack 8 per byte). // 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 size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast(number_of_entities) * 2; @@ -484,9 +485,12 @@ static bool register_block_in_range(const LogString *role, uint16_t start_addres return true; } -PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { - PduBuffer 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_NUM_OF_REGISTERS_TO_WRITE)) { +// The ceiling comes from the buffer itself: push_back() drops silently, so a bound wider than the buffer +// would put a truncated frame on the wire. +template static Pdu build_write_registers_pdu(uint16_t start_address, std::span values) { + constexpr auto max_registers = static_cast((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; } 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 values) { + return build_write_registers_pdu(start_address, values); +} + +WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span values) { + return build_write_registers_pdu(start_address, values); +} + PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, std::span write_values) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 76056ed3e8..a070ce250c 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -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 /// 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. -/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - -/// 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). +/// Exception-flagged codes (0x80 set) are always the 2-byte spec exception shape, so never unknown. inline bool is_function_code_unknown_length(uint8_t function_code) { - switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + if (is_function_code_exception(function_code)) + return false; + switch (static_cast(function_code)) { case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: 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. // 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 @@ -205,7 +213,7 @@ enum class SensorValueType : uint8_t { S_DWORD = 0x4, // 2 Registers signed BIT = 0x5, U_DWORD_R = 0x6, // 2 Registers unsigned - S_DWORD_R = 0x7, // 2 Registers unsigned + S_DWORD_R = 0x7, // 2 Registers signed U_QWORD = 0x8, S_QWORD = 0x9, 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", 0) returns 0x11 * @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 * @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 * @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 - * the hex string is byte_pos * 2 + * @param pos offset in bytes (see byte_from_hex_str) * @return word value */ 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 * @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 - * the hex string is byte_pos * 2 + * @param pos offset in bytes (see byte_from_hex_str) * @return dword value */ 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 * @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 - * the hex string is byte_pos * 2 + * @param pos offset in bytes (see byte_from_hex_str) * @return qword value */ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { @@ -328,9 +333,9 @@ template T get_data(const std::vector &data, size_t buffer_ * Responses for coil are packed into bytes . * coil 3 is bit 3 of the first 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) - * @return content of coil register + * @return value of the requested bit */ inline bool bit_from_packed(int bit, std::span data) { auto data_byte = bit / 8; @@ -468,11 +473,15 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy */ std::optional 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, // right-sized per shape) can be swapped in one place without touching every signature. using PduBuffer = StaticVector; using ReadPdu = StaticVector; using WriteSinglePdu = StaticVector; +using WriteFewRegistersPdu = StaticVector; /// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. using CoilPackBuffer = StaticVector; @@ -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 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 values); + /** Create modbus read/write multiple registers command * Function 0x17 Read/Write Multiple Registers * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index ade45e8cc3..57fa3b2a00 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,6 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, + include_builtin_idf_component, require_vfs_select, ) import esphome.config_validation as cv @@ -288,6 +289,10 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": 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 CORE.add_job(_zigbee_add_sdkconfigs, config) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 1ccc833048..a0afb03124 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -290,6 +290,7 @@ template class StaticVector { } size_t size() const { return count_; } + static constexpr size_t capacity() { return N; } bool empty() const { return count_ == 0; } // Direct access to underlying data diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6c2a285360..9373b5f569 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1053,15 +1053,28 @@ def _check_esp_idf_python_env_install( constraint_file_path, ) - cmd_pip_install = [ - str(env_python_path), - "-m", - "pip", - "install", - "--upgrade", - "--constraint", - 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 = [ + str(env_python_path), + "-m", + "pip", + "install", + "--upgrade", + "--constraint", + str(constraint_file_path), + ] _LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version) cmd = cmd_pip_install + [ @@ -1135,6 +1148,8 @@ def check_esp_idf_install( env = {} env["IDF_TOOLS_PATH"] = str(get_idf_tools_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 # per-variant request (builder-image pre-warm); otherwise the caller's diff --git a/esphome/helpers.py b/esphome/helpers.py index a38fcaf821..3ccf0fe65a 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable, MutableMapping +from collections.abc import Callable, Iterable, MutableMapping from contextlib import suppress import ipaddress 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) -def rmtree(path: Path | str) -> None: - """Remove a directory tree, handling read-only files on Windows. +# Deletion attempts when a directory keeps being repopulated mid-delete +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 - read-only flag and retrying. + +def rmtree(path: Path | str) -> None: + """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 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): raise exc Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) - shutil.rmtree(path, onexc=_onexc) + last_err: OSError | None = None + for attempt in range(RMTREE_MAX_ATTEMPTS - 1): + try: + 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): diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_espnow.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_espnow.yaml new file mode 100644 index 0000000000..2ede6c42df --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_espnow.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +espnow: + channel: 1 + auto_add_peer: true diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_wifi_ble.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_wifi_ble.yaml new file mode 100644 index 0000000000..883abdb5fa --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_wifi_ble.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +esp32_ble_tracker: diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml index 73d11e0a13..4f357e40e6 100644 --- a/tests/component_tests/esp32/config/network_ethernet_only.yaml +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -6,6 +6,8 @@ esp32: framework: type: esp-idf +mdns: + ethernet: type: W5500 clk_pin: 19 diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml index 61dfde3e03..3abc17e324 100644 --- a/tests/component_tests/esp32/config/network_wifi_only.yaml +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -6,6 +6,8 @@ esp32: framework: type: esp-idf +mdns: + wifi: ssid: "test_ssid" password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 297844b4e6..c72c4c3a6b 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -24,6 +24,7 @@ from esphome.components.esp32 import ( ) from esphome.components.esp32.const import ( KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_NETWORK_SDKCONFIG, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, @@ -298,6 +299,20 @@ def test_esp32_configuration_errors( ("esp-tls", "esp_http_client"), 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( @@ -309,8 +324,6 @@ def test_default_exclusions_reincluded_by_owning_components( """Components whose IDF driver is excluded by default must re-include it during codegen; a dropped include_builtin_idf_component() call would only 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)) 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], ) -> None: """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")) 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] assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") 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. assert "CONFIG_ESP_WIFI_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] assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") 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( diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 026df34bcc..18c04f32d5 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -775,7 +775,7 @@ TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) { // 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 -// 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) { NullUART uart; NoResponseProbeHub hub; @@ -814,9 +814,8 @@ TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { 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 -// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling -// of an exception-flagged write. +// An exception-flagged code (0x80 bit set) is never a valid request - that bit is response-only - so +// queue_pdu refuses it up front, before the broadcast guard, whatever its base code. TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { NullUART uart; NoResponseProbeHub hub; @@ -833,6 +832,50 @@ TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { 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 { // tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. 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 } -// An exception-flagged function code is never silently re-sendable, even though the read check -// masks the exception bit: its duplicate takes the drop path like any other non-read. -TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { +// The exception bit marks a response, so a request carrying it is refused outright. +TEST(ModbusClientHubPriority, ExceptionFlaggedPduRefused) { NoResponseProbeHub hub; SentCountingDevice device(&hub, 0x02); - const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged - EXPECT_TRUE(device.queue_pdu(weird)); - EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + // The 0x80 exception flag is a response-only bit; a request must never set it. queue_pdu refuses an + // exception-flagged PDU up front - nothing is queued - whether its base code reads (0x83 = 0x03 | 0x80) + // 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(); - - 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 + EXPECT_EQ(hub.queued_frames(), 0u); } namespace { diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 53f51b016b..87af49710f 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -63,6 +63,12 @@ TEST(ModbusClientFrameLength, TooShortReturnsMinimum) { 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) { // basic_register request fixture is a read-holding request -> 8 bytes 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()); } +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(values, count)); + auto full = create_write_registers_pdu(0x0102, std::span(values, count)); + EXPECT_EQ(std::vector(small.begin(), small.end()), std::vector(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(values, MAX_FEW_REGISTERS)).empty()); + EXPECT_TRUE(create_write_few_registers_pdu(0x0000, std::span()).empty()); + EXPECT_TRUE(create_write_few_registers_pdu(0xFFFF, std::span(values, 2)).empty()); +} + TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { const uint16_t write_values[] = {0x000B, 0x0016}; // Read 2 registers at 0x0010, write 2 registers at 0x0020. diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp index 8b91d088b8..33f0cd24df 100644 --- a/tests/components/modbus/modbus_unknown_function_test.cpp +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -54,7 +54,8 @@ class TestServerHub : public ModbusServerHub { // 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 - -// 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) { 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); @@ -62,11 +63,13 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { 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); } - // 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_TRUE(helpers::is_function_code_unknown_length(0x87)); - // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. - for (int fc = 0; fc <= 0xFF; fc++) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x87)); + EXPECT_FALSE(helpers::is_function_code_unknown_length(0xC9)); + // 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)) 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 // 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 - // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The - // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() - // switches on the unmasked byte and server_pdu_length() early-returns the exception length. - for (int fc = 0; fc <= 0x7F; fc++) { + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. Both + // parsers early-return the 2-byte exception shape above 0x7F, which the helper's own exception + // early-return mirrors, so the whole byte range is covered. + for (int fc = 0; fc <= 0xFF; fc++) { const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields EXPECT_EQ(helpers::is_function_code_unknown_length(fc), 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 // 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 diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index afa4433aa1..3eeace9914 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -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"]) +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: """Create the extracted marker and python-env interpreter so the install check takes the already-installed path rather than force-installing.""" diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 53c326e0d0..ff82fa3c80 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,3 +1,4 @@ +import errno import io import logging import os @@ -5,7 +6,7 @@ from pathlib import Path import socket import stat import types -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr 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) +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: """Test that results are sorted by preference.""" # Create multiple address infos with different preferences