diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 89e9a2b8ea..36e3b6c7be 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -128,6 +128,11 @@ inline bool value_type_is_float(SensorValueType v) { return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; } +/// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers. +inline bool is_entity_type_binary(EntityType type) { + return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT; +} + inline FunctionCode modbus_register_read_function(EntityType reg_type) { switch (reg_type) { case EntityType::COIL: diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index ea01331be3..1ce1e38d16 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -42,22 +42,38 @@ AUTO_LOAD = ["modbus"] MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") -ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice -) +ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingComponent) SensorItem = modbus_controller_ns.struct("SensorItem") _LOGGER = logging.getLogger(__name__) +# Remove before 2027.2.0 +_REMOVED_OPTIONS = { + CONF_COMMAND_THROTTLE: "Command spacing is handled by the 'modbus' component - use 'turnaround_time' there instead.", + CONF_ALLOW_DUPLICATE_COMMANDS: "Polling commands are deduplicated by the modbus hub; one-shot commands (writes) are always transmitted.", +} + + +def _warn_removed_options(config: ConfigType) -> ConfigType: + """Warn about options that no longer do anything, but let the config compile.""" + for option, replacement in _REMOVED_OPTIONS.items(): + if option in config: + _LOGGER.warning( + "[modbus_controller] '%s' no longer has any effect and will be removed in 2027.2.0. %s", + option, + replacement, + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ModbusController), - cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS, default=False): cv.boolean, - cv.Optional( - CONF_COMMAND_THROTTLE, default="0ms" - ): cv.positive_time_period_milliseconds, + # Removed options: accepted (and ignored) until 2027.2.0 so existing configs keep building. + cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS): cv.boolean, + cv.Optional(CONF_COMMAND_THROTTLE): cv.positive_time_period_milliseconds, cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" ), @@ -74,7 +90,8 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(modbus.modbus_device_schema(0x01)) + .extend(modbus.modbus_device_schema(0x01)), + _warn_removed_options, ) ModbusItemBaseSchema = cv.Schema( @@ -198,8 +215,6 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) - cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE])) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) await register_modbus_device(var, config) diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 902a3ba8dd..62a7fe93d3 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -4,7 +4,7 @@ #include "esphome/components/modbus_controller/modbus_controller.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { @@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS this->skip_updates = skip_updates; this->force_new_range = force_new_range; - if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) { + if (modbus::helpers::is_entity_type_binary(register_type)) { this->register_count = offset + 1; } else { this->register_count = 1; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 15f36ce89b..c4161d454f 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -6,209 +6,221 @@ namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; -void ModbusController::setup() { this->create_register_ranges_(); } +void ModbusController::setup() { this->create_polling_commands_(); } -/* - To work with the existing modbus class and avoid polling for responses a command queue is used. - send_next_command will submit the command at the top of the queue and set the corresponding callback - to handle the response from the device. - Once the response has been processed it is removed from the queue and the next command is sent -*/ -bool ModbusController::send_next_command_() { - uint32_t last_send = millis() - this->last_command_timestamp_; +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range) + : modbus::ModbusClientDevice(parent, address), + sensors(std::move(range.sensors)), + skip_updates(range.skip_updates), + register_type_(range.register_type), + start_address_(range.start_address), + register_count_(range.register_count), + function_code_(modbus::helpers::modbus_register_read_function(range.register_type)), + controller_(&controller) {} - if ((last_send > this->command_throttle_) && this->ready_for_immediate_send() && !this->command_queue_.empty()) { - auto &command = this->command_queue_.front(); - - // remove from queue if command was sent too often - if (!command->should_retry(this->max_cmd_retries_)) { - if (!this->module_offline_) { - ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Update skip_updates_counter to stop flooding channel with timeouts - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = this->offline_skip_updates_; - } - } - - this->module_offline_ = true; - this->offline_callback_.call((int) command->function_code, command->register_address); - } - ESP_LOGD(TAG, "Modbus command to device=%d register=0x%02X no response received - removed from send queue", - this->address_, command->register_address); - this->command_queue_.pop_front(); - } else { - ESP_LOGV(TAG, "Sending next modbus command to device %d register 0x%02X count %d", this->address_, - command->register_address, command->register_count); - command->send(); - - this->last_command_timestamp_ = millis(); - - this->command_sent_callback_.call((int) command->function_code, command->register_address); - - // remove from queue if no handler is defined - if (!command->on_data_func) { - this->command_queue_.pop_front(); - } - } - } - return (!this->command_queue_.empty()); +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + SensorItem *sensor) + : modbus::ModbusClientDevice(parent, address), + skip_updates(sensor->skip_updates), + start_address_(sensor->start_address), + register_count_(sensor->register_count), + function_code_(FunctionCode::CUSTOM), + custom_data_(&sensor->custom_data), + controller_(&controller) { + this->sensors.insert(sensor); } -// Queue incoming response -void ModbusController::on_response(std::span request_pdu, std::span response_pdu) { - if (this->command_queue_.empty()) { - ESP_LOGW(TAG, "Received modbus data but command queue is empty"); - return; +// The base deletes copy/move; command items re-provide construction. The moved-from device must not +// unregister the hub slot we just took over, so its parent_ is cleared. The copy constructor exists +// only for callers that pass an lvalue to queue_command() (in-tree callers move); remove it when +// queue_command() is removed. +ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other) + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(other.sensors), + skip_updates(other.skip_updates), + on_data_func(other.on_data_func), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + // SmallInlineBuffer is move-only, so deep-copy the bytes explicitly. + this->payload.set(other.payload.data(), other.payload.size()); +} + +ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(std::move(other.sensors)), + skip_updates(other.skip_updates), + on_data_func(std::move(other.on_data_func)), + payload(std::move(other.payload)), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + other.parent_ = nullptr; +} + +// A valid response: the device is online. Dispatch the payload to the handler or the range's sensors. +void ModbusCommandItem::on_response(std::span request_pdu, std::span response_pdu) { + if (this->controller_ != nullptr) + this->controller_->set_online(true, static_cast(this->function_code_), this->start_address_); + auto data = modbus::helpers::server_pdu_payload(response_pdu); + if (this->on_data_func) { + this->on_data_func(this->register_type_, this->start_address_, data); + } else if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + // write acknowledgement - nothing to publish + } else { + for (auto *sensor : this->sensors) + sensor->parse_and_publish(data); } - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// An exception response is still a legitimate reply, so the device is considered online. +void ModbusCommandItem::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", function_code, this->start_address_, + static_cast(exception_code)); + if (this->controller_ != nullptr) { + this->controller_->set_online(true, function_code, this->start_address_); + this->controller_->unqueue_command(this); + } +} + +// Not being sent says nothing about online/offline status; just drop it from the pending list. +void ModbusCommandItem::on_not_sent(std::span request_pdu) { + // A dropped write is lost while the entity has already published optimistically, so surface it. + if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", static_cast(this->function_code_), + this->start_address_); + } + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent +// trigger reflects when the frame actually went out, not when it was queued. +void ModbusCommandItem::on_sent(std::span request_pdu) { + if (this->controller_ != nullptr) + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); +} + +bool ModbusCommandItem::on_no_response(std::span request_pdu) { + if (this->controller_ == nullptr) + return false; + this->controller_->increment_non_response_count(); + if (this->controller_->can_send()) { + // Have the hub re-queue the frame it is holding; on_sent fires again when it goes back out. + return true; + } + this->controller_->set_online(false, static_cast(this->function_code_), this->start_address_); + this->controller_->unqueue_command(this); + return false; +} + +void ModbusController::set_online(bool online, int function_code, int register_address) { + if (online) { + this->cmd_non_responses_ = 0; if (this->module_offline_) { ESP_LOGW(TAG, "Modbus device=%d back online", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Restore skip_updates_counter to restore commands updates - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = 0; - } - } - // Restore module online state this->module_offline_ = false; - this->online_callback_.call((int) current_command->function_code, current_command->register_address); + this->online_callback_.call(function_code, register_address); + } + } else { + // Offline is a property of the physical device, so drop every sender's queued frames for its + // address; retired frames get on_not_sent(), which reclaims one-shots through the normal path. + this->hub_->clear_tx_queue_for_address(this->address_); + if (!this->module_offline_) { + ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); + this->module_offline_ = true; + this->module_offline_at_ = this->update_counter_; + this->offline_callback_.call(function_code, register_address); } - - // Move the commandItem to the response queue. The span points into the hub's receive buffer, so - // copy the payload into the command for deferred processing in loop(). - auto data = modbus::helpers::server_pdu_payload(response_pdu); - current_command->payload.assign(data.begin(), data.end()); - this->incoming_queue_.push(std::move(current_command)); - ESP_LOGV(TAG, "Modbus response queued"); - this->command_queue_.pop_front(); } } -// Dispatch the response to the registered handler -void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { - ESP_LOGV(TAG, "Process modbus response for address 0x%X size: %zu", response->register_address, - response->payload.size()); - response->on_data_func(response->register_type, response->register_address, response->payload); +void ModbusController::queue_command(ModbusCommandItem command) { + this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one + // Duplicates are the caller's to manage; the controller only holds the item until its terminal callback. + this->one_shot_command_items_.push_back(make_unique(std::move(command))); + // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. + auto &item = this->one_shot_command_items_.back(); + if (!item->send()) { + // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. + ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), + item->register_address()); + item->pending_removal = true; + } } -void ModbusController::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { - // The request function code (request_pdu[0]) already carries what the log needs; the exception bit only - // ever appears on the response, so no masking is needed here. - const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; - ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, static_cast(exception_code)); - if (this->command_queue_.empty()) { +void ModbusController::unqueue_command(const ModbusCommandItem *command) { + // Called as the last action of the command's own callback, and from send() after send_pdu (which may + // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a + // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op + // for polling commands (they persist and are not in the one-shot list). + for (auto &item : this->one_shot_command_items_) { + if (item.get() == command) { + item->pending_removal = true; + return; + } + } +} + +void ModbusController::sweep_completed_one_shots_() { + this->one_shot_command_items_.remove_if( + [](const std::unique_ptr &item) { return item->pending_removal; }); +} + +void ModbusController::update_range_(ModbusCommandItem &cmd) { + if (this->update_counter_ % (cmd.skip_updates + 1) != 0) { + ESP_LOGVV(TAG, "Skipping update for range 0x%X", cmd.register_address()); return; } - // Remove pending command waiting for a response - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { - ESP_LOGE(TAG, - "Modbus error - last command: function code=0x%X register address = 0x%X " - "registers count=%d " - "payload size=%zu", - function_code, current_command->register_address, current_command->register_count, - current_command->payload.size()); - this->command_queue_.pop_front(); - } + // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. + if (!cmd.send()) + ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); } -SensorSet ModbusController::find_sensors_(modbus::EntityType register_type, uint16_t start_address) const { - auto reg_it = std::find_if( - std::begin(this->register_ranges_), std::end(this->register_ranges_), - [=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); }); - - if (reg_it == this->register_ranges_.end()) { - ESP_LOGE(TAG, "No matching range for sensor found - start_address : 0x%X", start_address); - } else { - return reg_it->sensors; - } - - // not found - return {}; -} -void ModbusController::on_register_data(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); - - // loop through all sensors in this range; each reads its own bytes from the position resolved for it. - auto sensors = find_sensors_(register_type, start_address); - for (auto *sensor : sensors) { - sensor->parse_and_publish(data); - } -} - -void ModbusController::queue_command(const ModbusCommandItem &command) { - if (!this->allow_duplicate_commands_) { - // check if this command is already qeued. - // not very effective but the queue is never really large - for (auto &item : this->command_queue_) { - if (item->is_equal(command)) { - ESP_LOGW(TAG, "Duplicate modbus command found: type=0x%x address=%u count=%u", - static_cast(command.register_type), command.register_address, command.register_count); - // update the payload of the queued command - // replaces a previous command - item->payload = command.payload; - return; - } - } - } - this->command_queue_.push_back(make_unique(command)); -} - -void ModbusController::update_range_(RegisterRange &r) { - ESP_LOGV(TAG, "Range : %X Size: %x (%d) skip: %d", r.start_address, r.register_count, (int) r.register_type, - r.skip_updates_counter); - if (r.skip_updates_counter == 0) { - // if a custom command is used the user supplied custom_data is only available in the SensorItem. - if (r.register_type == modbus::EntityType::CUSTOM) { - auto sensors = this->find_sensors_(r.register_type, r.start_address); - if (!sensors.empty()) { - auto sensor = sensors.cbegin(); - auto command_item = ModbusCommandItem::create_custom_command( - this, (*sensor)->custom_data, - [this](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->on_register_data(modbus::EntityType::CUSTOM, start_address, data); - }); - command_item.register_address = (*sensor)->start_address; - command_item.register_count = (*sensor)->register_count; - command_item.function_code = FunctionCode::CUSTOM; - queue_command(command_item); +void ModbusController::update() { + this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks + if (this->module_offline_) { + // Offline probing follows the offline cadence alone; per-range skip_updates resumes once the + // device is back online. Requiring both cadences to coincide would leave phase combinations + // where a probe never goes out. + if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) { + ESP_LOGV(TAG, "Module offline - retrying"); + this->cmd_non_responses_ = 0; // allow the probe through can_send() + for (auto &cmd : this->polling_command_items_) { + if (!cmd.send()) + ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); } } else { - queue_command(ModbusCommandItem::create_read_command(this, r.register_type, r.start_address, r.register_count)); + ESP_LOGV(TAG, "Module offline - skipping update"); } - r.skip_updates_counter = r.skip_updates; // reset counter to config value - } else { - r.skip_updates_counter--; - } -} -// -// Queue the modbus requests to be send. -// Once we get a response to the command it is removed from the queue and the next command is send -// -void ModbusController::update() { - if (!this->command_queue_.empty()) { - ESP_LOGV(TAG, "%zu modbus commands already in queue", this->command_queue_.size()); - } else { - ESP_LOGV(TAG, "Updating modbus component"); + this->update_counter_++; + return; } - for (auto &r : this->register_ranges_) { - ESP_LOGVV(TAG, "Updating range 0x%X", r.start_address); - update_range_(r); + if (this->can_send()) { + for (auto &cmd : this->polling_command_items_) { + ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address()); + this->update_range_(cmd); + } } + this->update_counter_++; } // walk through the sensors and determine the register ranges to read -size_t ModbusController::create_register_ranges_() { - this->register_ranges_.clear(); +void ModbusController::create_polling_commands_() { if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); - return 0; + return; } // Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then @@ -299,7 +311,7 @@ size_t ModbusController::create_register_ranges_() { if (!join) { if (have_range) { ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(std::move(r)); + this->create_polling_command_(std::move(r)); } r = {}; range_bytes = curr->get_register_size(); @@ -311,7 +323,6 @@ size_t ModbusController::create_register_ranges_() { r.register_count = curr->register_count; r.register_type = curr->register_type; r.skip_updates = curr->skip_updates; - r.skip_updates_counter = 0; have_range = true; } else if (curr->skip_updates != 0) { // use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded) @@ -326,10 +337,11 @@ size_t ModbusController::create_register_ranges_() { } if (have_range) { ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(std::move(r)); + this->create_polling_command_(std::move(r)); } - - return this->register_ranges_.size(); + // Reclaim growth slack; safe here because nothing has registered with the hub yet (see the + // lifetime note on polling_command_items_). + this->polling_command_items_.shrink_to_fit(); } void ModbusController::dump_config() { @@ -348,222 +360,163 @@ void ModbusController::dump_config() { it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); - for (auto &it : this->register_ranges_) { - ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), - it.start_address, it.register_count, it.skip_updates); + for (auto &it : this->polling_command_items_) { + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type()), + it.register_address(), it.register_count(), it.skip_updates); } #endif } -void ModbusController::loop() { - // Incoming data to process? - if (!this->incoming_queue_.empty()) { - auto &message = this->incoming_queue_.front(); - if (message != nullptr) - this->process_modbus_data_(message.get()); - this->incoming_queue_.pop(); - +void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, + std::span data) { + // A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span. + if (data.size() >= 3) { + ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data.data(), 0), + modbus::helpers::get_data(data.data(), 1)); } else { - // all messages processed send pending commands - this->send_next_command_(); - } -} - -void ModbusController::on_write_register_response(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), - modbus::helpers::get_data(data, 1)); -} - -void ModbusController::dump_sensors_() { - ESP_LOGV(TAG, "sensors"); - for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, - it->get_register_size(), it->offset); + ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size()); } } ModbusCommandItem ModbusCommandItem::create_read_command( - ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(modbus::helpers::modbus_register_read_function(register_type), register_type, start_address, + register_count); cmd.on_data_func = std::move(handler); return cmd; } -ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, - modbus::EntityType register_type, uint16_t start_address, - uint16_t register_count) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_register_data(register_type, start_address, data); - }; - return cmd; -} - ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t register_count, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::HOLDING; - cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS; - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_REGISTERS, EntityType::HOLDING, start_address, register_count); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { auto decoded_value = decode_value(v); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + *p++ = decoded_value[0]; + *p++ = decoded_value[1]; } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *modbusdevice, uint16_t address, bool value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::COIL; - cmd.function_code = FunctionCode::WRITE_SINGLE_COIL; - cmd.register_address = address; - cmd.register_count = 1; - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_COIL, EntityType::COIL, address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - cmd.payload.push_back(value ? 0xFF : 0); - cmd.payload.push_back(0); + uint8_t *p = cmd.payload.init(2); + p[0] = value ? 0xFF : 0; + p[1] = 0; return cmd; } ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusController *modbusdevice, uint16_t start_address, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::COIL; - cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS; - cmd.register_address = start_address; - cmd.register_count = values.size(); - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_COILS, EntityType::COIL, start_address, values.size()); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - uint8_t bitmask = 0; - int bitcounter = 0; + uint8_t *p = cmd.payload.init((values.size() + 7) / 8); + memset(p, 0, (values.size() + 7) / 8); + size_t bit = 0; for (auto coil : values) { if (coil) { - bitmask |= (1 << bitcounter); + p[bit / 8] |= (1 << (bit % 8)); } - bitcounter++; - if (bitcounter % 8 == 0) { - cmd.payload.push_back(bitmask); - bitmask = 0; - } - } - // add remaining bits - if (bitcounter % 8) { - cmd.payload.push_back(bitmask); + bit++; } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = modbus::EntityType::HOLDING; - cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER; - cmd.register_address = start_address; - cmd.register_count = 1; // not used here anyways - cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_REGISTER, EntityType::HOLDING, start_address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; auto decoded_value = decode_value(value); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + uint8_t *p = cmd.payload.init(2); + p[0] = decoded_value[0]; + p[1] = decoded_value[1]; return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.function_code = FunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } - cmd.payload = values; + cmd.payload.set(values.data(), values.size()); return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd = {}; - cmd.modbusdevice = modbusdevice; - cmd.function_code = FunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { - cmd.payload.push_back((v >> 8) & 0xFF); - cmd.payload.push_back(v & 0xFF); + *p++ = (v >> 8) & 0xFF; + *p++ = v & 0xFF; } return cmd; } bool ModbusCommandItem::send() { - if (this->function_code != FunctionCode::CUSTOM) { - modbusdevice->send_pdu( - modbus::helpers::create_client_pdu(this->function_code, this->register_address, this->register_count, - this->payload.empty() ? nullptr : &this->payload[0], this->payload.size())); + bool accepted; + if (this->function_code_ != FunctionCode::CUSTOM) { + accepted = this->send_pdu(modbus::helpers::create_client_pdu( + this->function_code_, this->start_address_, this->register_count_, + this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { - modbusdevice->send_raw(this->payload); + // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own + // address (which may differ from this controller's); the hub appends the CRC and routes the response + // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (frame.empty()) { + ESP_LOGW(TAG, "Empty custom command frame, not sent"); + accepted = false; + } else { + accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + } } - this->send_count_++; - ESP_LOGV(TAG, "Command sent %d 0x%X %d send_count: %d", uint8_t(this->function_code), this->register_address, - this->register_count, this->send_count_); - return true; -} - -bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { - // for custom commands we have to check for identical payloads, since - // address/count/type fields will be set to zero - return this->function_code == FunctionCode::CUSTOM - ? this->payload == other.payload - : other.register_address == this->register_address && other.register_count == this->register_count && - other.register_type == this->register_type && other.function_code == this->function_code; + // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. + if (accepted) { + ESP_LOGV(TAG, "Command queued %d 0x%X %d", uint8_t(this->function_code_), this->start_address_, + this->register_count_); + } + return accepted; } } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index b5ef707a74..fb0037a0e6 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -7,7 +7,6 @@ #include "esphome/core/automation.h" #include -#include #include #include #include @@ -17,6 +16,7 @@ namespace esphome::modbus_controller { class ModbusController; +using modbus::EntityType; using modbus::ExceptionCode; using modbus::FunctionCode; using modbus::helpers::SensorValueType; @@ -133,9 +133,7 @@ class SensorItem { virtual void parse_and_publish(std::span data) = 0; /// Coils and discrete inputs address individual bits; every other type addresses 16-bit registers. - bool addresses_bits() const { - return this->register_type == modbus::EntityType::COIL || this->register_type == modbus::EntityType::DISCRETE_INPUT; - } + bool addresses_bits() const { return modbus::helpers::is_entity_type_binary(this->register_type); } /// Address a write entity (switch/number/select) targets, derived from its resolved position within /// the range so that a write lands on the register the sensor reads from. @@ -193,7 +191,7 @@ class SensorItem { bool force_new_range{false}; }; -// ModbusController::create_register_ranges_ tries to optimize register range +// ModbusController::create_polling_commands_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { public: @@ -232,25 +230,64 @@ struct RegisterRange { uint16_t start_address; modbus::EntityType register_type; uint8_t register_count; - uint16_t skip_updates; // the config value - SensorSet sensors; // all sensors of this range - uint16_t skip_updates_counter; // the running value + uint16_t skip_updates; // the config value + SensorSet sensors; // all sensors of this range }; -class ModbusCommandItem { +/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub +/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no +/// longer has to match responses to a FIFO queue. +class ModbusCommandItem : public modbus::ModbusClientDevice { public: - static const size_t MAX_PAYLOAD_BYTES = 240; - ModbusController *modbusdevice{nullptr}; - uint16_t register_address{0}; - uint16_t register_count{0}; - FunctionCode function_code{FunctionCode::CUSTOM}; - modbus::EntityType register_type{modbus::EntityType::CUSTOM}; - std::function &data)> - on_data_func; - std::vector payload = {}; + /// Empty command with no controller connection (kept for source compatibility with value-type usage). + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address) + : modbus::ModbusClientDevice(parent, address), controller_(&controller) {} + /// Read command built from a range; the read PDU is rebuilt from these fields at send time. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range); + /// Custom polling command: the PDU bytes are referenced from the sensor (not copied); responses are + /// dispatched to that sensor. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, SensorItem *sensor); + + // The base deletes copy/move (its destructor unregisters the device from the hub queue), but command + // items are stored in value containers, so copy/move CONSTRUCTION is re-provided (copy only for the + // queue_command() path). Assignment stays deleted: the item's address-in-memory is its hub identity. + ModbusCommandItem(const ModbusCommandItem &other); + ModbusCommandItem(ModbusCommandItem &&other) noexcept; + ModbusCommandItem &operator=(ModbusCommandItem &&) = delete; + + SensorSet sensors; // sensors served by this command (empty for factory/write commands) + uint16_t skip_updates{0}; + std::function data)> on_data_func; + /// Write data bytes for the command (register/coil values), or the raw frame of a one-shot custom + /// command; reads leave it empty. Small-buffer optimized: fixed-size commands (single-register/coil + /// writes) fit in the 8-byte inline buffer with no heap; only large multi-register or custom frames + /// spill to a single one-time heap allocation. This keeps runtime one-shot writes off the heap without + /// reserving a max-size buffer per command item. + SmallInlineBuffer<8> payload; + // Set by unqueue_command() when this one-shot has completed. The controller erases flagged items at a + // safe point (update()/queue_command()), never from inside the command's own callback. + bool pending_removal{false}; + + /// called when a modbus response was parsed without errors + void on_response(std::span request_pdu, std::span response_pdu) override; + /// called when a modbus error (exception) response was received + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + /// called when the command could not be sent + void on_not_sent(std::span request_pdu) override; + /// called when the command's frame is actually written to the wire; fires the on_command_sent trigger + void on_sent(std::span request_pdu) override; + /// called on timeout; returns true to have the hub re-queue the frame for a retry + bool on_no_response(std::span request_pdu) override; + + uint16_t register_address() const { return this->start_address_; } + uint16_t register_count() const { return this->register_count_; } + EntityType register_type() const { return this->register_type_; } + + /// Queue this command's frame on the hub. Returns false when refused, in which case no callback ever comes. + /// The item is the hub device, so it must stay alive until its terminal callback; a destroyed item's + /// pending frame is silently retired. bool send(); - /// Check if the command should be retried based on the max_retries parameter - bool should_retry(uint8_t max_retries) { return this->send_count_ <= max_retries; }; /// factory methods /** Create modbus read command @@ -263,19 +300,8 @@ class ModbusCommandItem { * @return ModbusCommandItem with the prepared command */ static ModbusCommandItem create_read_command( - ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler); - /** Create modbus read command - * Function code 02-04 - * @param modbusdevice pointer to the device to execute the command - * @param function_code modbus function code for the read command - * @param start_address modbus address of the first register to read - * @param register_count number of registers to read - * @return ModbusCommandItem with the prepared command - */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, modbus::EntityType register_type, - uint16_t start_address, uint16_t register_count); + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler); /** Create modbus read command * Function code 02-04 * @param modbusdevice pointer to the device to execute the command @@ -324,8 +350,8 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); + std::function data)> &&handler = + nullptr); /** Create custom modbus command * @param modbusdevice pointer to the device to execute the command @@ -336,17 +362,33 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); - - bool is_equal(const ModbusCommandItem &other); + std::function data)> &&handler = + nullptr); protected: - // wrong commands (esp. custom commands) can block the send queue, limit the number of repeats. - /// How many times this command has been sent - uint8_t send_count_{0}; + void set_command_(FunctionCode function_code, EntityType register_type, uint16_t start_address, + uint16_t register_count) { + this->function_code_ = function_code; + this->register_type_ = register_type; + this->start_address_ = start_address; + this->register_count_ = register_count; + } + EntityType register_type_{EntityType::CUSTOM}; + uint16_t start_address_{0}; + uint16_t register_count_{0}; + FunctionCode function_code_{FunctionCode::CUSTOM}; + /// Custom polling commands reference the PDU bytes owned by their SensorItem instead of copying them. + const std::vector *custom_data_{nullptr}; + ModbusController *controller_{nullptr}; }; +/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles, +/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter +/// wraparound) can be unit tested; used by ModbusController::update(). +inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_at, uint16_t offline_skip_updates) { + return static_cast(update_counter + 1 - module_offline_at) % (offline_skip_updates + 1) == 0; +} + /** Modbus controller class. * Each instance handles the modbus commuinication for all sensors with the same modbus address * @@ -355,48 +397,46 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent { public: void dump_config() override; - void loop() override; + // No loop() override: the hub owns transmit/receive timing and each command routes its own + // response, so the controller never joins the looping components at all. void setup() override; void update() override; - /// queues a modbus command in the send queue - void queue_command(const ModbusCommandItem &command); - /// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller. - /// The payload carries its own address byte, which may differ from this controller's address. - /// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics: - /// controller-level raw sends stay supported until the command machinery is replaced. - void send_raw(const std::vector &payload) { - if (payload.empty()) - return; // refused at the door, like every invalid send; no callback follows - this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); - } + // The controller is not itself a modbus device - its commands and writer entities send as their own + // devices. It only owns the hub + address so those senders can be built against them. + void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; } + void set_address(uint8_t address) { this->address_ = address; } + + /// The hub and modbus address this controller talks to. Used to build commands/entities that send as + /// their own device. + modbus::ModbusClientHub *hub() const { return this->hub_; } + uint8_t device_address() const { return this->address_; } + + /// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy. + void queue_command(ModbusCommandItem command); + /// Flags a finished one-shot command for removal. Called by the command as the last action of its own + /// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later. + void unqueue_command(const ModbusCommandItem *command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// called when a modbus response was parsed without errors - void on_response(std::span request_pdu, std::span response_pdu) override; - /// called when a modbus error response was received - void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; - /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue - void on_register_data(modbus::EntityType register_type, uint16_t start_address, const std::vector &data); - /// default delegate called by process_modbus_data when a response for a write response has retrieved from the - /// incoming queue - void on_write_register_response(modbus::EntityType register_type, uint16_t start_address, - const std::vector &data); - /// Allow a duplicate command to be sent - void set_allow_duplicate_commands(bool allow_duplicate_commands) { - this->allow_duplicate_commands_ = allow_duplicate_commands; + /// Handles a write command acknowledgement (used by write command on_data_func handlers). + void on_write_register_response(EntityType register_type, uint16_t start_address, std::span data); + /// Update the online/offline state after a response or a run of timeouts, firing the callbacks. + void set_online(bool online, int function_code, int register_address); + /// Fire the on_command_sent trigger (called when a command's frame reaches the wire). + void command_sent(int function_code, int register_address) { + this->command_sent_callback_.call(function_code, register_address); } - /// get if a duplicate command can be sent - bool get_allow_duplicate_commands() { return this->allow_duplicate_commands_; } - /// called by esphome generated code to set the command_throttle period - void set_command_throttle(uint16_t command_throttle) { this->command_throttle_ = command_throttle; } + /// A command timed out; bump the consecutive-timeout counter used by can_send()/offline detection. + void increment_non_response_count() { this->cmd_non_responses_++; } + /// Whether more retries are allowed before the device is considered offline. Deliberately pooled + /// per device, not per command: online/offline is a property of the physical device. + bool can_send() { return this->cmd_non_responses_ <= this->max_cmd_retries_; } /// called by esphome generated code to set the offline_skip_updates void set_offline_skip_updates(uint16_t offline_skip_updates) { this->offline_skip_updates_ = offline_skip_updates; } - /// get the number of queued modbus commands (should be mostly empty) - size_t get_command_queue_length() { return command_queue_.size(); } /// get if the module is offline, didn't respond the last command bool get_module_offline() { return module_offline_; } /// Set callback for commands @@ -418,33 +458,48 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli protected: /// parse sensormap_ and create range of sequential addresses - size_t create_register_ranges_(); - // find register in sensormap. Returns iterator with all registers having the same start address - SensorSet find_sensors_(modbus::EntityType register_type, uint16_t start_address) const; - /// submit the read command for the address range to the send queue - void update_range_(RegisterRange &r); - /// parse incoming modbus data - void process_modbus_data_(const ModbusCommandItem *response); - /// send the next modbus command from the send queue - bool send_next_command_(); - /// dump the parsed sensormap for diagnostics - void dump_sensors_(); + /// Group the registered sensors into contiguous ranges and create one polling command per range. + void create_polling_commands_(); + /// build one persistent polling command from a range and add it to polling_command_items_ + void create_polling_command_(RegisterRange &&range) { + // A custom range polls the first sensor's custom_data (a ready-made raw frame); it needs the + // sensor constructor so the command references those bytes and decodes the real function code. + // The response still dispatches to every sensor in the range. + if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) { + auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin()); + cmd.sensors = std::move(range.sensors); + cmd.skip_updates = range.skip_updates; // the range's merged rate, not the first sensor's + } else { + this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range)); + } + } + /// send a range's polling command if it is due this update + void update_range_(ModbusCommandItem &cmd); + /// The hub this controller's commands/entities send through, and the modbus address they target. + modbus::ModbusClientHub *hub_{nullptr}; + uint8_t address_{0}; /// Collection of all sensors for this component SensorSet sensorset_; - /// Continuous range of modbus registers - std::vector register_ranges_{}; - /// Hold the pending requests to be sent - std::list> command_queue_; - /// modbus response data waiting to get processed - std::queue> incoming_queue_; - /// if duplicate commands can be sent - bool allow_duplicate_commands_{false}; - /// when was the last send operation - uint32_t last_command_timestamp_{0}; - /// min time in ms between sending modbus commands - uint16_t command_throttle_{0}; + /// One persistent command per register range, each its own ModbusClientDevice. Built once in setup() + /// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which + /// is safe because no command has registered with the hub yet) and never appended to afterward, so the + /// hub's device pointers stay valid once commands start sending. + std::vector polling_command_items_{}; + /// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses. + std::list> one_shot_command_items_; + /// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub + /// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the + /// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the + /// item running its callback is not flagged until that callback returns. + void sweep_completed_one_shots_(); /// if module didn't respond the last command bool module_offline_{false}; + /// update_counter_ value at which the module went offline (for offline_skip_updates timing) + uint16_t module_offline_at_{0}; + /// counts update() cycles; drives skip_updates and offline timing + uint16_t update_counter_{0}; + /// consecutive non-responses; drives can_send() and offline detection + uint8_t cmd_non_responses_{0}; /// how many updates to skip if module is offline uint16_t offline_skip_updates_{0}; /// How many times we will retry a command if we get no response @@ -462,7 +517,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(std::span data, const SensorItem &item, size_t offset) { +inline float payload_to_float(std::span data, const SensorItem &item, uint8_t offset) { int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, offset, item.bitmask).value_or(0); float float_value; diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index a2a49dcaf0..7903b2e317 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -29,7 +29,7 @@ void ModbusNumber::parse_and_publish(std::span data) { } void ModbusNumber::control(float value) { - ModbusCommandItem write_cmd; + optional write_cmd; std::vector data; float write_value = value; // Is there are lambda configured? @@ -55,11 +55,11 @@ void ModbusNumber::control(float value) { #endif ESP_LOGV(TAG, "Modbus Number write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - write_cmd = ModbusCommandItem::create_custom_command( + write_cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, write_cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { std::vector payload; modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type); @@ -70,20 +70,21 @@ void ModbusNumber::control(float value) { // Create and send the write command if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]); + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), - this->register_count, payload); + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), + this->register_count, payload)); } // publish new value - write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address, - const std::vector &data) { + write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address, + std::span data) { // gets called when the write command is ack'd from the device - this->parent_->on_write_register_response(write_cmd.register_type, start_address, data); + this->parent_->on_write_register_response(register_type, start_address, data); this->publish_state(value); }; } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); this->publish_state(value); } void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 95618a7505..48249f4387 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -58,15 +58,15 @@ void ModbusFloatOutput::write_state(float value) { } // Create and send the write command - ModbusCommandItem write_cmd; + optional write_cmd; if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd = - ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]); + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset, - data.size(), data); + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command( + this->parent_, this->start_address + this->offset, data.size(), data)); } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); } void ModbusFloatOutput::dump_config() { @@ -82,7 +82,7 @@ void ModbusFloatOutput::dump_config() { // ModbusBinaryOutput void ModbusBinaryOutput::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? @@ -105,11 +105,11 @@ void ModbusBinaryOutput::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus binary output write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state), (int) this->register_type, this->start_address, this->offset); @@ -117,12 +117,14 @@ void ModbusBinaryOutput::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state); + cmd.emplace( + ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state)); } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); } void ModbusBinaryOutput::dump_config() { diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index c2d87619a1..0a9383b1b0 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -86,14 +86,15 @@ void ModbusSelect::control(size_t index) { } const uint16_t write_address = this->write_address(); - ModbusCommandItem write_cmd; + optional write_cmd; if ((this->register_count == 1) && (!this->use_write_multiple_)) { - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]); + write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data); + write_cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data)); } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); if (this->optimistic_) this->publish_state(index); diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 61fdaacd10..9d66b2afa7 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index adbd812348..810d904d85 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -59,7 +59,7 @@ void ModbusSwitch::parse_and_publish(std::span data) { void ModbusSwitch::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? if (this->write_transform_func_.has_value()) { @@ -81,11 +81,11 @@ void ModbusSwitch::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus Switch write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), ONOFF(state), (int) this->register_type, this->start_address, this->offset); @@ -93,21 +93,22 @@ void ModbusSwitch::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states); + cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state); + cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state)); } } else { if (this->use_write_multiple_) { std::vector bool_states(1, state ? (0xFFFF & this->bitmask) : 0); - cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states)); } else { - cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), - state ? 0xFFFF & this->bitmask : 0u); + cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), + state ? 0xFFFF & this->bitmask : 0u)); } } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); this->publish_state(state); } // ModbusSwitch end diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index e8de46b55a..5bb16eb58a 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -4,7 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3a243289ae..155fa2f6ba 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,6 +184,11 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; + bool empty() const { return this->len_ == 0; } + + // Conversion to std::span for compatibility with span-based APIs + operator std::span() const { return std::span(this->data(), this->len_); } + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index 986d807dfb..67b022cdf5 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -158,8 +158,9 @@ sensor: address: 0x9020 value_type: U_WORD offset: 2 - # Raw-decode lambda in the documented style: `item->offset` locates this sensor's data in the range - # response, and the compatibility helpers accept the span the lambda is handed. + # Raw-decode lambda kept on the deprecated get_data() helper on purpose: `data` is a span now, so this + # pins that the compatibility overload still accepts one. The deprecation warning it raises is the + # point - it is what a user on the old helper sees. `item->offset` locates this sensor's data. - platform: modbus_controller modbus_controller_id: modbus_controller1 id: modbus_sensor_raw_lambda diff --git a/tests/components/modbus_controller/offline_cadence_test.cpp b/tests/components/modbus_controller/offline_cadence_test.cpp new file mode 100644 index 0000000000..1dade5dd98 --- /dev/null +++ b/tests/components/modbus_controller/offline_cadence_test.cpp @@ -0,0 +1,56 @@ +#include + +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// A probe must come due exactly once per offline_skip_updates + 1 cycles from the trip point, +// for every phase between the trip cycle and the update counter. Pins the regression where a +// probe additionally required a range's skip_updates cadence to coincide, which some phase +// combinations never satisfy - the device then never polled again. +TEST(OfflineRetryCadence, DueOncePerWindowForEveryPhase) { + for (uint16_t skip = 0; skip <= 5; skip++) { + const uint16_t period = skip + 1; + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t due_count = 0; + for (uint32_t counter = offline_at; counter < offline_at + 4u * period; counter++) { + if (offline_retry_due(static_cast(counter), offline_at, skip)) + due_count++; + } + EXPECT_EQ(due_count, 4) << "skip=" << skip << " offline_at=" << offline_at; + } + } +} + +// The first probe goes out within one window of going offline: after at most skip skipped cycles. +TEST(OfflineRetryCadence, FirstProbeWithinOneWindow) { + for (uint16_t skip = 0; skip <= 5; skip++) { + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t counter = offline_at; + uint16_t skipped = 0; + while (!offline_retry_due(counter, offline_at, skip)) { + counter++; + skipped++; + ASSERT_LE(skipped, skip) << "skip=" << skip << " offline_at=" << offline_at; + } + } + } +} + +// The cadence neither stretches nor collapses when update_counter_ wraps past 65535. +TEST(OfflineRetryCadence, SurvivesCounterWraparound) { + const uint16_t skip = 2; // period 3 + const uint16_t offline_at = 65530; + uint16_t counter = offline_at; + uint16_t due_count = 0; + for (int i = 0; i < 30; i++) { // crosses the wrap mid-run + if (offline_retry_due(counter, offline_at, skip)) + due_count++; + counter++; + } + EXPECT_EQ(due_count, 10); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml new file mode 100644 index 0000000000..738e691110 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml @@ -0,0 +1,87 @@ +esphome: + name: uart-mock-modbus-custom-command + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 259; + +sensor: + # Plain read to confirm the controller <-> server link is up. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "plain_read" + address: 0x01 + register_type: holding + value_type: U_WORD + # Custom command: a raw frame {device address, function code, address hi, address lo, + # count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001, + # count 1; the lambda parses the response payload (the register value, big-endian). + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "custom_read" + custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01] + lambda: |- + if (data.size() < 2) return {}; + return (float) ((data[0] << 8) | data[1]); + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_offline.yaml b/tests/integration/fixtures/uart_mock_modbus_offline.yaml new file mode 100644 index 0000000000..e4d2dfa294 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_offline.yaml @@ -0,0 +1,95 @@ +esphome: + name: uart-mock-modbus-offline + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Whether the mock device answers requests. Starts false so the controller +# runs through its retries and goes offline; the test flips it via the +# "Serve" button to exercise the offline retry/recovery path. +globals: + - id: serve + type: bool + initial_value: "false" + +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + on_tx: + # While serve is false every request times out; once true, answer the + # (only) request - read holding register 3 on device 1 - with value 259. + - uart_mock.inject_rx: + id: virtual_uart + data: !lambda |- + if (!id(serve)) + return {}; + return {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + +modbus: + - uart_id: virtual_uart + id: virtual_modbus_client + send_wait_time: 100ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + id: ctl + max_cmd_retries: 1 + # offline_skip_updates and the sensor's skip_updates deliberately share a period: offline + # probing must follow the offline cadence alone, or phase combinations like this one can + # leave the device never probing again. + offline_skip_updates: 1 + update_interval: never + on_offline: + then: + - lambda: id(link_state).publish_state(0); + on_online: + then: + - lambda: id(link_state).publish_state(1); + +sensor: + - platform: modbus_controller + modbus_controller_id: ctl + name: reg + id: reg + address: 0x03 + register_type: holding + value_type: U_WORD + skip_updates: 1 + # Mirrors the controller's online state so the test can await the transitions. + - platform: template + name: link_state + id: link_state + update_interval: never + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(ctl).set_update_interval(200); + id(ctl).start_poller(); + - platform: template + name: "Serve" + id: serve_btn + on_press: + - globals.set: + id: serve + value: "true" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index b7103b62dd..f1106f1c77 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -452,14 +452,80 @@ async def test_uart_mock_modbus_shared_address( _assert_no_modbus_errors(error_log_lines, warning_log_lines) -@pytest.mark.xfail( - strict=True, - reason="Fair bus scheduling across controllers sharing one client hub " - "requires the modbus_controller refactor in esphome#11781. On dev the " - "controllers each queue independently and contend for the bus, so the " - "request counts diverge. Expected to XPASS (and this marker removed) once " - "that refactor lands.", -) +@pytest.mark.asyncio +async def test_uart_mock_modbus_custom_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a custom_command sensor polling a register served by the mock server. + + The custom_command is a raw frame (device address + PDU); the hub appends the CRC and + routes the response back to the polling command, whose sensor lambda parses the payload. + Guards the custom polling wiring: the command must reference the sensor's custom_data and + decode the real function code, or nothing is ever transmitted. A plain read on the same + register anchors the bus. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"plain_read": 259, "custom_read": 259} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_offline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A silent device drives the controller offline; answering again recovers it. + + The mock answers nothing at first, so the controller burns through max_cmd_retries + (1 retry after the first timeout) and fires on_offline. While offline it keeps + retrying every offline_skip_updates+1 cycles. The test then flips the mock to + answering; the next retry gets a response, on_online fires, and the register value + publishes. This pins the pooled non-response counter, can_send() gating, the + offline retry cadence, and recovery - none of which the responding-path tests touch. + + The fixture gives offline_skip_updates and the sensor's skip_updates the same period + on purpose: offline probing must follow the offline cadence alone, since requiring + both cadences to coincide leaves phase combinations where no probe ever goes out. + """ + + tracker = SensorTracker(["link_state", "reg"]) + offline_future = tracker.expect("link_state", 0) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # The unanswered poll and its retry each time out (~100ms), then on_offline fires. + await tracker.await_change(offline_future, "link_state", timeout=5.0) + + # Register the recovery expectations before waking the device so no update is missed. + online_future = tracker.expect("link_state", 1) + value_future = tracker.expect("reg", 259) + serve_btn = find_entity(entities, "serve", ButtonInfo) + assert serve_btn is not None, "Serve button not found" + client.button_command(serve_btn.key) + + # The next offline-cadence retry gets an answer: back online, value published. + await tracker.await_change(online_future, "link_state", timeout=5.0) + await tracker.await_change(value_future, "reg", timeout=5.0) + + @pytest.mark.asyncio async def test_uart_mock_modbus_fairness( yaml_config: str,