ESPHome 2026.10.0-dev
Loading...
Searching...
No Matches
modbus_controller.cpp
Go to the documentation of this file.
1#include "modbus_controller.h"
3#include "esphome/core/log.h"
4
5#include <cstring>
6#include <limits>
7
9
10static const char *const TAG = "modbus_controller";
11
13
14void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) {
16 return;
18 ESP_LOGW(TAG,
19 "Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / "
20 "queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0",
21 LOG_STR_ARG(platform), address);
22}
23
24bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
25 if (frame.empty())
26 return false;
27 return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
28}
29
31 this->controller_ = controller;
32 this->set_parent(controller->hub());
33 this->set_address(controller->device_address());
34}
35
36// A request whose layout carries no start address (a custom PDU) reports -1; 0 stays a real address.
37static int trigger_address(std::span<const uint8_t> request_pdu) {
38 const auto addr = modbus::helpers::client_pdu_start_address(request_pdu);
39 return addr.has_value() ? *addr : -1;
40}
41
42void ControllerDevice::notify_online_(std::span<const uint8_t> request_pdu) {
43 if (this->controller_ != nullptr) {
44 this->controller_->set_online(true, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
45 }
46}
47
48void ControllerDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
49 this->notify_online_(request_pdu);
50}
51
52void ControllerDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
53 ESP_LOGW(TAG, "Modbus error function code: 0x%X register %d exception: %d",
54 modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu),
55 static_cast<uint8_t>(exception_code));
56 this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
57}
58
59void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
60 ControllerDevice::on_response(request_pdu, response_pdu);
61 this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
62}
63
64void WriterDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
65 ControllerDevice::on_error(request_pdu, exception_code);
66 this->dispatch_response_(request_pdu, {}, exception_code);
67}
68
69// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger
70// reflects when the frame actually went out, not when it was queued.
71void ControllerDevice::on_sent(std::span<const uint8_t> request_pdu) {
72 if (this->controller_ != nullptr) {
73 this->controller_->command_sent(modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
74 }
75}
76
77void ControllerDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
78 const uint8_t fc = modbus::helpers::pdu_function_code(request_pdu);
79 const int addr = trigger_address(request_pdu);
80 // Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely
81 // lost; a dropped write was already published optimistically, so surface it.
83 ESP_LOGW(TAG, "Write not sent: function 0x%X register %d", fc, addr);
84 } else {
85 ESP_LOGD(TAG, "Request not sent: function 0x%X register %d", fc, addr);
86 }
87}
88
89bool ControllerDevice::on_no_response(std::span<const uint8_t> request_pdu) {
90 if (this->controller_ == nullptr)
91 return false;
93 if (this->controller_->can_send())
94 return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry
95 this->controller_->set_online(false, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
96 return false;
97}
98
100 : ControllerDevice(&controller), range_(std::move(range)) {}
101
103 bool accepted;
104 if (this->range_.custom_pdu != nullptr) {
105 accepted = this->queue_pdu(std::span<const uint8_t>(*this->range_.custom_pdu), options);
106 } else {
107 accepted = this->read_entities(this->range_.register_type, this->range_.start_address, this->range_.register_count,
108 options);
109 }
110 if (accepted) {
111 ESP_LOGV(TAG, "Poll queued type=%u 0x%X %d", static_cast<uint8_t>(this->range_.register_type),
112 this->range_.start_address, this->range_.register_count);
113 }
114 return accepted;
115}
116
117void PollingDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
118 this->notify_online_(request_pdu);
119 auto data = modbus::helpers::server_pdu_payload(response_pdu);
120 for (auto *sensor : this->range_.sensors)
121 sensor->parse_and_publish(data);
122}
123
124// ModbusCommandItem's machinery stays as-is until its removal in 2027.3.0; silence its self-references.
125#pragma GCC diagnostic push
126#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
127ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
129 : modbus::ModbusClientDevice(parent, address),
130 sensors(std::move(range.sensors)),
131 register_type_(range.register_type),
132 start_address_(range.start_address),
133 register_count_(range.register_count),
134 function_code_(modbus::helpers::modbus_register_read_function(range.register_type)),
135 controller_(&controller) {}
136
137ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
138 SensorItem *sensor)
139 : modbus::ModbusClientDevice(parent, address),
140 start_address_(sensor->start_address),
141 register_count_(sensor->entity_count()),
142 custom_pdu_(&sensor->custom_pdu),
143 controller_(&controller) {
144 // The PDU's first byte is its real function code; carry it so dump_config, the on_command_sent
145 // trigger and the response callbacks report the actual code instead of CUSTOM.
146 if (!sensor->custom_pdu.empty())
147 this->function_code_ = static_cast<FunctionCode>(sensor->custom_pdu.data()[0]);
148 this->sensors.insert(sensor);
149}
150
151// The base deletes copy/move; command items re-provide construction. The moved-from device must not
152// unregister the hub slot we just took over, so its parent_ is cleared. The copy constructor exists
153// only for callers that pass an lvalue to queue_command() (in-tree callers move); remove it when
154// queue_command() is removed.
155ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other)
156 : modbus::ModbusClientDevice(other.parent_, other.address_),
157 sensors(other.sensors),
158 on_data_func(other.on_data_func),
159 register_type_(other.register_type_),
160 start_address_(other.start_address_),
161 register_count_(other.register_count_),
162 function_code_(other.function_code_),
163 custom_pdu_(other.custom_pdu_),
164 controller_(other.controller_) {
165 // SmallInlineBuffer is move-only, so deep-copy the bytes explicitly.
166 this->payload.set(other.payload.data(), other.payload.size());
167}
168
169ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept
170 : modbus::ModbusClientDevice(other.parent_, other.address_),
171 sensors(std::move(other.sensors)),
172 on_data_func(std::move(other.on_data_func)),
173 payload(std::move(other.payload)),
174 register_type_(other.register_type_),
175 start_address_(other.start_address_),
176 register_count_(other.register_count_),
177 function_code_(other.function_code_),
178 custom_pdu_(other.custom_pdu_),
179 controller_(other.controller_) {
180 other.parent_ = nullptr;
181}
182
183// A valid response: the device is online. Dispatch the payload to the handler or the range's sensors.
184void ModbusCommandItem::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
185 if (this->controller_ != nullptr)
186 this->controller_->set_online(true, static_cast<int>(this->function_code_), this->start_address_);
187 auto data = modbus::helpers::server_pdu_payload(response_pdu);
188 if (this->on_data_func) {
189 this->on_data_func(this->register_type_, this->start_address_, data);
190 } else if (!this->sensors.empty()) {
191 // A polling command always has sensors; a factory/write command never does. Test this before the
192 // write-code branch so a custom_pdu whose function code is a write (e.g. 0x17, whose response
193 // carries read data) still reaches its sensor instead of being treated as a bare write ack.
194 for (auto *sensor : this->sensors)
195 sensor->parse_and_publish(data);
196 } else if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
197 // write acknowledgement - nothing to publish
198 }
199 if (this->controller_ != nullptr)
200 this->controller_->unqueue_command(this);
201}
202
203// An exception response is still a legitimate reply, so the device is considered online.
204void ModbusCommandItem::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
205 const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0];
206 ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", function_code, this->start_address_,
207 static_cast<uint8_t>(exception_code));
208 if (this->controller_ != nullptr) {
209 this->controller_->set_online(true, function_code, this->start_address_);
210 this->controller_->unqueue_command(this);
211 }
212}
213
214// Not being sent says nothing about online/offline status; just drop it from the pending list.
215void ModbusCommandItem::on_not_sent(std::span<const uint8_t> request_pdu) {
216 // A dropped write is lost while the entity has already published optimistically, so surface it.
217 if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
218 ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", static_cast<uint8_t>(this->function_code_),
219 this->start_address_);
220 }
221 if (this->controller_ != nullptr)
222 this->controller_->unqueue_command(this);
223}
224
225// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent
226// trigger reflects when the frame actually went out, not when it was queued.
227void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
228 if (this->controller_ == nullptr)
229 return;
230 this->controller_->command_sent(static_cast<int>(this->function_code_), this->start_address_);
231 // A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback.
232 // on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak.
233 // Test the address the frame went to, not address_: a custom command's frame carries its own address
234 // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
235 // A custom polling command sends its PDU to this controller's own address, so only a factory custom
236 // command (a raw frame staged in payload) can carry a different address byte.
237 uint8_t wire_address = this->address_;
238 if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
239 wire_address = this->payload.data()[0];
240 if (wire_address == modbus::BROADCAST_ADDRESS)
241 this->controller_->unqueue_command(this);
242}
243
244bool ModbusCommandItem::on_no_response(std::span<const uint8_t> request_pdu) {
245 if (this->controller_ == nullptr)
246 return false;
247 this->controller_->increment_non_response_count();
248 if (this->controller_->can_send()) {
249 // Have the hub re-queue the frame it is holding; on_sent fires again when it goes back out.
250 return true;
251 }
252 this->controller_->set_online(false, static_cast<int>(this->function_code_), this->start_address_);
253 this->controller_->unqueue_command(this);
254 return false;
255}
256
257#pragma GCC diagnostic pop
258
259void ModbusController::set_online(bool online, int function_code, int register_address) {
260 if (online) {
261 this->cmd_non_responses_ = 0;
262 if (this->module_offline_) {
263 ESP_LOGW(TAG, "Modbus device=%d back online", this->address_);
264 this->module_offline_ = false;
265 this->online_callback_.call(function_code, register_address);
266 }
267 } else {
268 // Offline is a property of the physical device, so drop every sender's queued frames for its
269 // address; retired frames get on_not_sent(), which reclaims one-shots through the normal path.
271 if (!this->module_offline_) {
272 ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_);
273 this->module_offline_ = true;
275 this->offline_callback_.call(function_code, register_address);
276 }
277 }
278}
279
280#pragma GCC diagnostic push
281#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
282void ModbusController::queue_command(ModbusCommandItem command) {
283 this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one
284 // Duplicates are the caller's to manage; the controller only holds the item until its terminal callback.
285 this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
286 // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
287 auto &item = this->one_shot_command_items_.back();
288 // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
289 if (!item->send()) {
290 // The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
291 ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
292 item->register_address());
293 item->pending_removal = true;
294 }
295}
296
297void ModbusController::unqueue_command(const ModbusCommandItem *command) {
298 // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/
299 // on_no_response), which the hub runs from inside its sweep while this entry is still live.
300 // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it;
301 // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands
302 // (they persist and are not in the one-shot list).
303 for (auto &item : this->one_shot_command_items_) {
304 if (item.get() == command) {
305 item->pending_removal = true;
306 return;
307 }
308 }
309}
310
312 this->one_shot_command_items_.remove_if(
313 [](const std::unique_ptr<ModbusCommandItem> &item) { return item->pending_removal; });
314}
315
316#pragma GCC diagnostic pop
317
319 this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks
320 if (this->module_offline_) {
321 // Offline probing follows the offline cadence alone; regular every-update polling resumes once
322 // the device is back online.
324 ESP_LOGV(TAG, "Module offline - retrying");
325 this->cmd_non_responses_ = 0; // allow the probe through can_send()
326 for (auto &poll : this->polling_devices_) {
327 // Probes carry the read-side options too, so a recovering device resumes streaming on the
328 // probe itself rather than waiting for the next update_interval.
329 if (!poll.queue(this->read_options_)) {
330 ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", poll.register_address());
331 }
332 }
333 } else {
334 ESP_LOGV(TAG, "Module offline - skipping update");
335 }
336 this->update_counter_++;
337 return;
338 }
339
340 if (this->can_send()) {
341 for (auto &poll : this->polling_devices_) {
342 ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
343 // read_options_ carries the controller's continuous flag (the offline probe above sends it too).
344 // A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
345 if (!poll.queue(this->read_options_)) {
346 ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
347 }
348 }
349 }
350 this->update_counter_++;
351}
352
353// walk through the sensors and determine the register ranges to read
354namespace {
355
356class RangeBuilder {
357 public:
358 explicit RangeBuilder(FixedVector<RegisterRange> &ranges) : ranges_(ranges) {}
359
360 bool can_join(const SensorItem *curr) const {
361 return this->have_range_ && curr->reuse_previous_range != RangeReuse::NEVER &&
362 this->r_.register_type == curr->register_type && curr->register_type != modbus::EntityType::CUSTOM;
363 }
364
365 // A sensor that joined mid-range must never anchor this - hence both address tests.
366 bool try_reuse_register(SensorItem *curr) {
367 const uint32_t range_end = this->range_end_();
368 if (curr->start_address != range_end - this->prev_->entity_count() ||
369 this->prev_->start_address + this->prev_->entity_count() != range_end ||
370 curr->entity_count() != this->prev_->entity_count() ||
371 curr->get_register_size() != this->prev_->get_register_size()) {
372 return false;
373 }
374 if (!place_offset(curr, static_cast<uint32_t>(this->prev_->offset) + curr->offset_from_start_address))
375 return false;
376 ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address);
377 return true;
378 }
379
380 bool try_extend(SensorItem *curr) {
381 const uint32_t range_end = this->range_end_();
382 const bool reachable =
383 curr->reuse_previous_range == RangeReuse::ALWAYS
384 ? curr->start_address >= range_end
385 : curr->start_address == range_end && (curr->addresses_bits() || !this->range_custom_size_);
386 if (!reachable)
387 return false;
388 const uint16_t gap = static_cast<uint16_t>(curr->start_address - range_end);
389 const uint32_t new_count = this->r_.register_count + gap + curr->entity_count();
390 const uint16_t max_quantity =
391 curr->addresses_bits() ? modbus::MAX_NUM_OF_COILS_TO_READ : modbus::MAX_NUM_OF_REGISTERS_TO_READ;
392 const uint32_t prospective_offset =
393 (curr->addresses_bits() ? static_cast<uint32_t>(curr->start_address - this->r_.start_address)
394 : static_cast<uint32_t>(this->range_bytes_) + gap * 2) +
395 curr->offset_from_start_address;
396 if (new_count > max_quantity || !place_offset(curr, prospective_offset)) {
397 return false;
398 }
399 if (!curr->addresses_bits())
400 this->range_bytes_ += static_cast<size_t>(gap) * 2;
401 this->range_bytes_ += curr->get_register_size();
402 this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
403 this->r_.register_count = static_cast<uint16_t>(new_count);
404 ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address);
405 return true;
406 }
407
408 bool try_cover(SensorItem *curr) {
409 if (!this->range_shared_ || this->range_forced_ || curr->start_address < this->r_.start_address ||
410 curr->start_address + curr->entity_count() > this->range_end_() || this->range_custom_size_ ||
411 has_custom_size(curr)) {
412 return false;
413 }
414 const uint32_t addr_delta = curr->start_address - this->r_.start_address;
415 if (!place_offset(curr, (curr->addresses_bits() ? addr_delta : addr_delta * 2) + curr->offset_from_start_address))
416 return false;
417 ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, this->r_.start_address);
418 return true;
419 }
420
421 // A response dispatches to a single range per (start address, register type), so same-address items
422 // must share - even reuse_previous_range: false and custom entities.
423 bool try_share(SensorItem *curr) {
424 if (!this->have_range_ || this->r_.register_type != curr->register_type ||
425 this->r_.start_address != curr->start_address) {
426 return false;
427 }
428 curr->offset = curr->offset_from_start_address;
429 this->r_.register_count = std::max(this->r_.register_count, curr->entity_count());
430 this->range_bytes_ = std::max(this->range_bytes_, curr->get_register_size());
431 this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
432 this->range_shared_ = true;
433 this->range_forced_ = this->range_forced_ || curr->reuse_previous_range == RangeReuse::NEVER;
434 ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address);
435 return true;
436 }
437
438 bool always_declined(const SensorItem *curr) const {
439 return this->have_range_ && curr->reuse_previous_range == RangeReuse::ALWAYS &&
440 this->r_.register_type == curr->register_type && curr->start_address != this->r_.start_address;
441 }
442
443 void open(SensorItem *curr) {
444 this->close();
445 this->r_ = {};
446 this->range_bytes_ = curr->get_register_size();
447 this->range_custom_size_ = has_custom_size(curr);
448 this->range_forced_ = curr->reuse_previous_range == RangeReuse::NEVER;
449 this->range_shared_ = false;
450 curr->offset = curr->offset_from_start_address;
451 this->r_.start_address = curr->start_address;
452 this->r_.register_count = curr->entity_count();
453 this->r_.register_type = curr->register_type;
454 if (curr->register_type == modbus::EntityType::CUSTOM)
455 this->r_.custom_pdu = &curr->custom_pdu;
456 this->have_range_ = true;
457 }
458
459 void record(SensorItem *curr) {
460 curr->range_start_address = this->r_.start_address;
461 this->r_.sensors.insert(curr);
462 this->prev_ = curr;
463 }
464
465 void close() {
466 if (!this->have_range_)
467 return;
468 ESP_LOGV(TAG, "Add range 0x%X %d", this->r_.start_address, this->r_.register_count);
469 this->ranges_.push_back(std::move(this->r_));
470 this->have_range_ = false;
471 }
472
473 private:
474 uint32_t range_end_() const { return this->r_.start_address + this->r_.register_count; }
475 // The resolved offset must fit its uint8_t field or the sensor would parse the wrong slice.
476 static bool place_offset(SensorItem *curr, uint32_t offset) {
477 if (offset > std::numeric_limits<uint8_t>::max())
478 return false;
479 curr->offset = static_cast<uint8_t>(offset);
480 return true;
481 }
482 static bool has_custom_size(const SensorItem *item) {
483 return item->get_register_size() != static_cast<size_t>(item->entity_count()) * 2;
484 }
485 FixedVector<RegisterRange> &ranges_;
486 RegisterRange r_ = {};
487 bool have_range_ = false;
488 bool range_forced_ = false; // a reuse: false member blocks the coverage join
489 bool range_shared_ = false; // only a share-widened range absorbs by coverage
490 size_t range_bytes_ = 0;
491 bool range_custom_size_ = false;
492 SensorItem *prev_ = nullptr;
493};
494
495} // namespace
496
498 if (this->sensorset_.empty()) {
499 ESP_LOGW(TAG, "No sensors registered");
500 return;
501 }
502
503 // At most one range closes per sensor plus one final close, so sensorset_.size() bounds the pushes
504 // (FixedVector silently drops past capacity).
506 ranges.init(this->sensorset_.size());
507 RangeBuilder builder(ranges);
508 for (SensorItem *curr : this->sensorset_) {
509 ESP_LOGV(TAG, "Register: 0x%X width=%u size=%zu offset=%u addr=%p", curr->start_address, curr->entity_count(),
510 curr->get_register_size(), curr->offset, curr);
511 bool join = builder.can_join(curr) &&
512 (builder.try_reuse_register(curr) || builder.try_extend(curr) || builder.try_cover(curr));
513 if (!join && builder.always_declined(curr)) {
514 ESP_LOGW(TAG, "reuse_previous_range on 0x%X cannot join the previous range; starting a new range",
515 curr->start_address);
516 }
517 join = join || builder.try_share(curr);
518 if (!join)
519 builder.open(curr);
520 builder.record(curr);
521 }
522 builder.close();
523
524 this->polling_devices_.init(ranges.size());
525 for (auto &range : ranges) {
526 this->polling_devices_.emplace_back(*this, std::move(range));
527 }
528}
529
531 ESP_LOGCONFIG(TAG,
532 "ModbusController:\n"
533 " Address: 0x%02X\n"
534 " Max Command Retries: %d\n"
535 " Offline Skip Updates: %d\n",
537
538#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
539 ESP_LOGCONFIG(TAG, "sensormap");
540 for (auto &it : this->sensorset_) {
541 ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X width=%u size=%zu",
542 static_cast<uint8_t>(it->register_type), it->start_address, it->offset, it->entity_count(),
543 it->get_register_size());
544 }
545 ESP_LOGCONFIG(TAG, "ranges");
546 for (auto &it : this->polling_devices_) {
547 ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast<uint8_t>(it.register_type()),
548 it.register_address(), it.register_count());
549 }
550#endif
551}
552
553#pragma GCC diagnostic push
554#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
555void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address,
556 std::span<const uint8_t> data) {
557 // A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span.
558 if (data.size() >= 3) {
559 ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data<uint16_t>(data.data(), 0),
561 } else {
562 ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size());
563 }
564}
565
566ModbusCommandItem ModbusCommandItem::create_read_command(
567 ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count,
568 std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
569 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
570 cmd.set_command_(modbus::helpers::modbus_register_read_function(register_type), register_type, start_address,
571 register_count);
572 cmd.on_data_func = std::move(handler);
573 return cmd;
574}
575
576ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusController *modbusdevice,
577 uint16_t start_address, uint16_t register_count,
578 const std::vector<uint16_t> &values) {
579 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
580 cmd.set_command_(FunctionCode::WRITE_MULTIPLE_REGISTERS, EntityType::HOLDING, start_address, register_count);
581 cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
582 modbusdevice->on_write_register_response(register_type, start_address, data);
583 };
584 uint8_t *p = cmd.payload.init(values.size() * 2);
585 for (auto v : values) {
586 auto decoded_value = decode_value(v);
587 *p++ = decoded_value[0];
588 *p++ = decoded_value[1];
589 }
590 return cmd;
591}
592
593ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *modbusdevice, uint16_t address,
594 bool value) {
595 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
597 cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
598 modbusdevice->on_write_register_response(register_type, start_address, data);
599 };
600 uint8_t *p = cmd.payload.init(2);
601 p[0] = value ? 0xFF : 0;
602 p[1] = 0;
603 return cmd;
604}
605
606ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusController *modbusdevice, uint16_t start_address,
607 const std::vector<bool> &values) {
608 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
609 cmd.set_command_(FunctionCode::WRITE_MULTIPLE_COILS, EntityType::COIL, start_address, values.size());
610 cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
611 modbusdevice->on_write_register_response(register_type, start_address, data);
612 };
613
614 // Pack through the shared bit view (MutablePackedBits) so the coil wire layout lives in one place
615 // instead of an open-coded loop.
616 const size_t byte_count = modbus::packed_bit_bytes(values.size());
617 uint8_t *p = cmd.payload.init(byte_count);
618 memset(p, 0, byte_count);
619 modbus::MutablePackedBits bits(std::span<uint8_t>(p, byte_count), static_cast<uint16_t>(values.size()));
620 for (size_t i = 0; i != values.size(); i++) {
621 if (values[i])
622 bits.set(i, true);
623 }
624 return cmd;
625}
626
627ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address,
628 uint16_t value) {
629 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
630 cmd.set_command_(FunctionCode::WRITE_SINGLE_REGISTER, EntityType::HOLDING, start_address, 1);
631 cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
632 modbusdevice->on_write_register_response(register_type, start_address, data);
633 };
634
635 auto decoded_value = decode_value(value);
636 uint8_t *p = cmd.payload.init(2);
637 p[0] = decoded_value[0];
638 p[1] = decoded_value[1];
639 return cmd;
640}
641
642ModbusCommandItem ModbusCommandItem::create_custom_command(
643 ModbusController *modbusdevice, const std::vector<uint8_t> &values,
644 std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
645 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
646 cmd.function_code_ = FunctionCode::CUSTOM;
647 if (handler == nullptr) {
648 cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
649 ESP_LOGI(TAG, "Custom Command sent");
650 };
651 } else {
652 cmd.on_data_func = handler;
653 }
654 cmd.payload.set(values.data(), values.size());
655
656 return cmd;
657}
658
659ModbusCommandItem ModbusCommandItem::create_custom_command(
660 ModbusController *modbusdevice, const std::vector<uint16_t> &values,
661 std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
662 ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
663 cmd.function_code_ = FunctionCode::CUSTOM;
664 if (handler == nullptr) {
665 cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
666 ESP_LOGI(TAG, "Custom Command sent");
667 };
668 } else {
669 cmd.on_data_func = handler;
670 }
671 uint8_t *p = cmd.payload.init(values.size() * 2);
672 for (auto v : values) {
673 *p++ = (v >> 8) & 0xFF;
674 *p++ = v & 0xFF;
675 }
676
677 return cmd;
678}
679
680bool ModbusCommandItem::send(modbus::CommandOptions options) {
681 // Options pass straight through to the hub
682 bool accepted;
683 if (this->custom_pdu_ != nullptr) {
684 // Custom polling command: send the sensor's ready-made PDU (function code + data, no address byte)
685 // to this controller's own device address; the hub prepends the address and appends the CRC.
686 accepted = modbus::ModbusClientDevice::queue_pdu(std::span<const uint8_t>(*this->custom_pdu_), options);
687 } else if (this->function_code_ != FunctionCode::CUSTOM) {
688 accepted = this->queue_pdu(modbus::helpers::create_client_pdu(
689 this->function_code_, this->start_address_, this->register_count_,
690 this->payload.empty() ? nullptr : this->payload.data(), this->payload.size()),
691 options);
692 } else {
693 // Factory custom command: payload holds a complete raw frame (address + PDU). Send the PDU to the
694 // frame's own address (which may differ from this controller's); the hub appends the CRC and routes
695 // the response back to this item by pointer.
696 std::span<const uint8_t> frame = this->payload;
697 if (frame.empty()) {
698 ESP_LOGW(TAG, "Empty custom command frame, not sent");
699 accepted = false;
700 } else {
701 accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this, options);
702 }
703 }
704 // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire.
705 if (accepted) {
706 ESP_LOGV(TAG, "Command queued %d 0x%X %d", uint8_t(this->function_code_), this->start_address_,
707 this->register_count_);
708 }
709 return accepted;
710}
711#pragma GCC diagnostic pop
712
713} // namespace esphome::modbus_controller
uint8_t address
Definition bl0906.h:4
Fixed-capacity vector - allocates once at runtime, never reallocates This avoids std::vector template...
Definition helpers.h:545
size_t size() const
Definition helpers.h:711
void init(size_t n)
Definition helpers.h:635
void set_parent(ModbusClientHub *parent)
Definition modbus.h:406
void set_address(uint8_t address)
Definition modbus.h:407
uint16_t uint16_t uint8_t const uint8_t * payload
Definition modbus.h:492
bool queue_pdu(uint8_t address, std::span< const uint8_t > pdu, ModbusClientDevice *device=nullptr, CommandOptions options={})
Queue a request.
Definition modbus.cpp:1058
void clear_tx_queue_for_address(uint8_t address)
Definition modbus.cpp:1151
The shared feedback half of a controller-owned hub device: online/offline tracking,...
void set_controller(ModbusController *controller)
void notify_online_(std::span< const uint8_t > request_pdu)
void on_error(std::span< const uint8_t > request_pdu, modbus::ExceptionCode exception_code) override
void on_sent(std::span< const uint8_t > request_pdu) override
bool on_no_response(std::span< const uint8_t > request_pdu) override
void on_not_sent(std::span< const uint8_t > request_pdu) override
void on_response(std::span< const uint8_t > request_pdu, std::span< const uint8_t > response_pdu) override
uint8_t cmd_non_responses_
consecutive non-responses; drives can_send() and offline detection
void command_sent(int function_code, int register_address)
Fire the on_command_sent trigger (called when a command's frame reaches the wire).
std::list< std::unique_ptr< ModbusCommandItem > > one_shot_command_items_
Dynamically queued one-shot commands (writes, custom commands).
void increment_non_response_count()
A command timed out; bump the consecutive-timeout counter used by can_send()/offline detection.
CallbackManager< void(int, int)> offline_callback_
Server offline callback.
SensorSet sensorset_
Collection of all sensors for this component.
FixedVector< PollingDevice > polling_devices_
One persistent PollingDevice per register range.
uint8_t max_cmd_retries_
How many times we will retry a command if we get no response.
bool can_send()
Whether more retries are allowed before the device is considered offline.
void sweep_completed_one_shots_()
Erases one-shot commands flagged by unqueue_command().
void create_polling_commands_()
Group the registered sensors into contiguous ranges and create one PollingDevice per range.
uint16_t module_offline_at_
update_counter_ value at which the module went offline (for offline_skip_updates timing)
bool module_offline_
if module didn't respond the last command
uint16_t offline_skip_updates_
how many updates to skip if module is offline
modbus::ModbusClientHub * hub() const
The hub and modbus address this controller talks to.
void set_online(bool online, int function_code, int register_address)
Update the online/offline state after a response or a run of timeouts, firing the callbacks.
uint16_t update_counter_
counts update() cycles; drives the offline-retry cadence
uint16_t std::span< const uint8_t > data
modbus::ModbusClientHub * hub_
The hub this controller's commands/entities send through, and the modbus address they target.
CallbackManager< void(int, int)> online_callback_
Server online callback.
void on_response(std::span< const uint8_t > request_pdu, std::span< const uint8_t > response_pdu) override
bool queue(modbus::CommandOptions options={})
Queue this range's read (or its sensor's custom PDU) on the hub. False = refused, no callback follows...
PollingDevice(ModbusController &controller, RegisterRange &&range)
void on_response(std::span< const uint8_t > request_pdu, std::span< const uint8_t > response_pdu) override
void on_error(std::span< const uint8_t > request_pdu, modbus::ExceptionCode exception_code) override
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address)
Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now ...
bool send_raw_frame_deprecated(std::span< const uint8_t > frame)
Send a legacy raw frame (address + function code + data) to the frame's own address.
uint8_t options
Range range
Definition msa3xx.h:0
T get_data(const uint8_t *data, size_t buffer_offset)
Extract data from modbus response buffer.
bool is_function_code_write(uint8_t function_code)
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values, size_t values_len)
Create a modbus client pdu for reading/writing single/multiple coils/register/inputs.
std::span< const uint8_t > server_pdu_payload(std::span< const uint8_t > pdu)
Returns the payload portion of a server response PDU: the bytes after the function code,...
uint8_t pdu_function_code(std::span< const uint8_t > pdu)
Function code of a PDU, exception flag masked; 0 for an empty PDU.
FunctionCode modbus_register_read_function(EntityType reg_type)
std::optional< uint16_t > client_pdu_start_address(std::span< const uint8_t > pdu)
Start address of a standard client request PDU ([fc, addr_hi, addr_lo, ...]).
const std::vector< uint8_t > & data
class ESPDEPRECATED("One-shot writes go through the entity write helpers (WriterDevice) or the modbus_client actions, and " "polling runs through PollingDevice. Removed in 2027.3.0", "2026.9.0") ModbusCommandItem bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_at, uint16_t offline_skip_updates)
A single modbus command.
constexpr size_t packed_bit_bytes(size_t bits)
Bits pack 8 per data byte, rounded up to whole bytes.
constexpr std::array< uint8_t, sizeof(T)> decode_value(T val)
Decode a value into its constituent bytes (from most to least significant).
Definition helpers.h:913
STL namespace.
static void uint32_t
const SmallInlineBuffer< 8 > * custom_pdu
A custom range polls this PDU, referenced from the sensor that opened the range.