mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 01:58:39 +00:00
[modbus] Finalize unreleased API surface before 2026.7 (#17434)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
731e9fda03
commit
731486d9b0
@@ -56,8 +56,7 @@ void ModbusClientHub::loop() {
|
||||
(this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) {
|
||||
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address,
|
||||
this->last_receive_check_ - this->last_send_);
|
||||
if (wfr.device)
|
||||
wfr.device->on_modbus_no_response();
|
||||
this->notify_no_response_(wfr);
|
||||
this->waiting_for_response_.reset();
|
||||
}
|
||||
}
|
||||
@@ -278,11 +277,10 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct
|
||||
"ms after last send",
|
||||
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
|
||||
this->last_modbus_byte_ - this->last_send_);
|
||||
// Invalidate the waiting device so it won't process this response.
|
||||
if (wfr.device)
|
||||
wfr.device->on_modbus_no_response();
|
||||
// Invalidate the device; the entry survives as an interrupted shell so the late response is ignored.
|
||||
// A retry requested here stays queued behind the shell until the send-wait timeout clears it.
|
||||
this->notify_no_response_(wfr);
|
||||
wfr.interrupted = true;
|
||||
wfr.device = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,6 +562,30 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo
|
||||
}
|
||||
|
||||
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
|
||||
void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) {
|
||||
if (wfr.device == nullptr)
|
||||
return;
|
||||
const bool retry = wfr.device->on_modbus_no_response();
|
||||
// The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach
|
||||
// over the retry request rather than re-queueing a frame that can no longer be routed.
|
||||
if (retry && wfr.device != nullptr)
|
||||
this->requeue_waiting_frame_(wfr);
|
||||
// The old transaction is over either way; never deliver anything else to the device through it.
|
||||
wfr.device = nullptr;
|
||||
}
|
||||
|
||||
void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) {
|
||||
const ModbusFrame &frame = wfr.frame;
|
||||
if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
|
||||
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]);
|
||||
if (wfr.device != nullptr)
|
||||
wfr.device->on_modbus_not_sent();
|
||||
return;
|
||||
}
|
||||
// Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell.
|
||||
this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3);
|
||||
}
|
||||
|
||||
void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
|
||||
if (pdu_len == 0) {
|
||||
if (device)
|
||||
|
||||
@@ -108,7 +108,7 @@ class ModbusClientHub : public Modbus {
|
||||
payload, payload_len),
|
||||
device);
|
||||
};
|
||||
void send_pdu(uint8_t address, const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu, ModbusClientDevice *device = nullptr) {
|
||||
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
|
||||
this->queue_raw_(address, pdu.data(), pdu.size(), device);
|
||||
}
|
||||
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
|
||||
@@ -121,6 +121,10 @@ class ModbusClientHub : public Modbus {
|
||||
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
|
||||
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
|
||||
void send_next_frame_();
|
||||
// Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true.
|
||||
// wfr is the caller's checked reference to waiting_for_response_.
|
||||
void notify_no_response_(ModbusDeviceCommand &wfr);
|
||||
void requeue_waiting_frame_(ModbusDeviceCommand &wfr);
|
||||
void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr);
|
||||
|
||||
uint16_t send_wait_time_{2000};
|
||||
@@ -179,7 +183,10 @@ class ModbusClientDevice {
|
||||
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
|
||||
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
|
||||
virtual void on_modbus_not_sent() {}
|
||||
virtual void on_modbus_no_response() {}
|
||||
/// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry.
|
||||
/// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and
|
||||
/// return false when exhausted), or an unresponsive peer will starve other traffic on the bus.
|
||||
virtual bool on_modbus_no_response() { return false; }
|
||||
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
|
||||
const uint8_t *payload = nullptr) {
|
||||
this->parent_->send_pdu(this->address_,
|
||||
@@ -187,7 +194,7 @@ class ModbusClientDevice {
|
||||
payload, payload_len),
|
||||
this);
|
||||
}
|
||||
void send_pdu(const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
|
||||
void send_pdu(std::span<const uint8_t> pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
|
||||
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
|
||||
inline void clear_tx_queue_for_address(bool clear_sent = true) {
|
||||
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
|
||||
|
||||
@@ -82,7 +82,7 @@ static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0
|
||||
|
||||
// 6.3 03 (0x03) Read Holding Registers
|
||||
// 6.4 04 (0x04) Read Input Registers
|
||||
static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
|
||||
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
|
||||
|
||||
// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2)
|
||||
static constexpr uint16_t MIN_FRAME_SIZE = 4;
|
||||
|
||||
@@ -105,8 +105,8 @@ void log_unsupported_value_type(SensorValueType value_type) {
|
||||
ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast<uint16_t>(value_type));
|
||||
}
|
||||
|
||||
int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset,
|
||||
uint32_t bitmask, bool *error_return) {
|
||||
std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type,
|
||||
uint8_t offset, uint32_t bitmask) {
|
||||
int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits
|
||||
|
||||
// Validate offset against the buffer for all types, including RAW/unsupported, so
|
||||
@@ -114,9 +114,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
|
||||
if (static_cast<size_t>(offset) > size) {
|
||||
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type),
|
||||
static_cast<unsigned int>(offset), size);
|
||||
if (error_return)
|
||||
*error_return = true;
|
||||
return value;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const size_t required_size = required_payload_size(sensor_value_type);
|
||||
@@ -127,9 +125,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
|
||||
if (size - offset < required_size) {
|
||||
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu",
|
||||
static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), size, required_size);
|
||||
if (error_return)
|
||||
*error_return = true;
|
||||
return value;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
switch (sensor_value_type) {
|
||||
@@ -179,8 +175,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
|
||||
return value;
|
||||
}
|
||||
|
||||
int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type,
|
||||
bool *error_return) {
|
||||
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) {
|
||||
const size_t required_size = required_payload_size(sensor_value_type);
|
||||
if (required_size == 0) {
|
||||
return 0; // RAW/unsupported: nothing to read
|
||||
@@ -189,9 +184,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue
|
||||
if (required_words > count) {
|
||||
ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu",
|
||||
static_cast<unsigned int>(sensor_value_type), count, required_words);
|
||||
if (error_return)
|
||||
*error_return = true;
|
||||
return 0;
|
||||
return std::nullopt;
|
||||
}
|
||||
// Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the
|
||||
// sign-extension behaviour stays identical to the wire path.
|
||||
@@ -201,7 +194,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue
|
||||
bytes[i * 2] = static_cast<uint8_t>(reg >> 8);
|
||||
bytes[i * 2 + 1] = static_cast<uint8_t>(reg & 0xFF);
|
||||
}
|
||||
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return);
|
||||
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/modbus/modbus_definitions.h"
|
||||
@@ -197,11 +199,15 @@ template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_
|
||||
* @param data modbus response buffer (uint8_t)
|
||||
* @return content of coil register
|
||||
*/
|
||||
inline bool coil_from_vector(int coil, const std::vector<uint8_t> &data) {
|
||||
auto data_byte = coil / 8;
|
||||
return (data[data_byte] & (1 << (coil % 8))) > 0;
|
||||
inline bool bit_from_packed(int bit, std::span<const uint8_t> data) {
|
||||
auto data_byte = bit / 8;
|
||||
return (data[data_byte] & (1 << (bit % 8))) > 0;
|
||||
}
|
||||
|
||||
// Remove before 2027.2.0
|
||||
ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0")
|
||||
inline bool coil_from_vector(int coil, std::span<const uint8_t> data) { return bit_from_packed(coil, data); }
|
||||
|
||||
/** Extract bits from value and shift right according to the bitmask
|
||||
* if the bitmask is 0x00F0 we want the values frrom bit 5 - 8.
|
||||
* the result is then shifted right by the position if the first right set bit in the mask
|
||||
@@ -276,13 +282,21 @@ template<typename Container> void number_to_payload(Container &data, int64_t val
|
||||
* @param bitmask bitmask used for masking and shifting
|
||||
* @return 64-bit number of the payload
|
||||
*/
|
||||
int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset,
|
||||
uint32_t bitmask, bool *error_return = nullptr);
|
||||
std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type,
|
||||
uint8_t offset, uint32_t bitmask);
|
||||
|
||||
/** Convert vector<uint8_t> response payload to number. */
|
||||
/** Convert a response payload span to number; std::nullopt if the payload is too short. */
|
||||
inline std::optional<int64_t> payload_to_number(std::span<const uint8_t> data, SensorValueType sensor_value_type,
|
||||
uint8_t offset, uint32_t bitmask) {
|
||||
return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask);
|
||||
}
|
||||
|
||||
// Remove before 2027.2.0
|
||||
ESPDEPRECATED("Use the std::span overload returning std::optional<int64_t> instead. Removed in 2027.2.0", "2026.8.0")
|
||||
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
|
||||
uint32_t bitmask, bool *error_return = nullptr) {
|
||||
return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return);
|
||||
uint32_t bitmask) {
|
||||
// Released behavior: a too-short payload logs an error and decodes to 0.
|
||||
return payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask).value_or(0);
|
||||
}
|
||||
|
||||
/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload.
|
||||
@@ -292,8 +306,7 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
|
||||
* @param sensor_value_type defines if 16/32/64 bits or FP32 is used
|
||||
* @return 64-bit number of the registers
|
||||
*/
|
||||
int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type,
|
||||
bool *error_return = nullptr);
|
||||
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
|
||||
|
||||
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
|
||||
* @param function_code the modbus function code to use. One of:
|
||||
|
||||
@@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector<uint8_t> &data) {
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
case ModbusRegisterType::COIL:
|
||||
// offset for coil is the actual number of the coil not the byte offset
|
||||
value = modbus::helpers::coil_from_vector(this->offset, data);
|
||||
value = modbus::helpers::bit_from_packed(this->offset, data);
|
||||
break;
|
||||
default:
|
||||
value = modbus::helpers::get_data<uint16_t>(data, this->offset) & this->bitmask;
|
||||
|
||||
@@ -64,9 +64,10 @@ T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
|
||||
return modbus::helpers::get_data<T>(data, buffer_offset);
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed())
|
||||
ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0")
|
||||
inline bool coil_from_vector(int coil, const std::vector<uint8_t> &data) {
|
||||
return modbus::helpers::coil_from_vector(coil, data);
|
||||
return modbus::helpers::bit_from_packed(coil, data);
|
||||
}
|
||||
|
||||
template<typename N>
|
||||
@@ -83,7 +84,8 @@ inline void number_to_payload(std::vector<uint16_t> &data, int64_t value, Sensor
|
||||
ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
|
||||
uint32_t bitmask) {
|
||||
return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask);
|
||||
return modbus::helpers::payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask)
|
||||
.value_or(0);
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
@@ -377,8 +379,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
|
||||
* @param item SensorItem object
|
||||
* @return float value of data
|
||||
*/
|
||||
inline float payload_to_float(const std::vector<uint8_t> &data, const SensorItem &item) {
|
||||
int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask);
|
||||
inline float payload_to_float(std::span<const uint8_t> data, const SensorItem &item) {
|
||||
int64_t number =
|
||||
modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0);
|
||||
|
||||
float float_value;
|
||||
if (modbus::helpers::value_type_is_float(item.sensor_value_type)) {
|
||||
|
||||
@@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select";
|
||||
void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); }
|
||||
|
||||
void ModbusSelect::parse_and_publish(const std::vector<uint8_t> &data) {
|
||||
int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask);
|
||||
int64_t value = modbus::helpers::payload_to_number(std::span<const uint8_t>(data), this->sensor_value_type,
|
||||
this->offset, this->bitmask)
|
||||
.value_or(0);
|
||||
|
||||
ESP_LOGD(TAG, "New select value %lld from payload", value);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector<uint8_t> &data) {
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
case ModbusRegisterType::COIL:
|
||||
// offset for coil is the actual number of the coil not the byte offset
|
||||
value = modbus::helpers::coil_from_vector(this->offset, data);
|
||||
value = modbus::helpers::bit_from_packed(this->offset, data);
|
||||
break;
|
||||
default:
|
||||
value = modbus::helpers::get_data<uint16_t>(data, this->offset) & this->bitmask;
|
||||
|
||||
@@ -137,10 +137,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
if (server_register->write_lambda == nullptr) {
|
||||
return false; // unwritable -> ILLEGAL_DATA_ADDRESS
|
||||
}
|
||||
bool error = false;
|
||||
registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
|
||||
server_register->value_type, &error);
|
||||
if (error) {
|
||||
if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
|
||||
server_register->value_type)
|
||||
.has_value()) {
|
||||
precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value
|
||||
return false;
|
||||
}
|
||||
@@ -154,7 +153,8 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
// rejecting the value at runtime -- which cannot be rolled back.
|
||||
if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) {
|
||||
int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
|
||||
server_register->value_type);
|
||||
server_register->value_type)
|
||||
.value_or(0);
|
||||
return server_register->write_lambda(number);
|
||||
})) {
|
||||
ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied.");
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Exposes the protected tx queue and waiting-for-response slot so tests can drive the
|
||||
// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the
|
||||
// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling.
|
||||
class NoResponseProbeHub : public ModbusClientHub {
|
||||
public:
|
||||
size_t queued_frames() const { return this->tx_buffer_.size(); }
|
||||
const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); }
|
||||
bool waiting() const { return this->waiting_for_response_.has_value(); }
|
||||
const ModbusDeviceCommand &waiting_command() const {
|
||||
EXPECT_TRUE(this->waiting_for_response_.has_value());
|
||||
return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access)
|
||||
}
|
||||
|
||||
void force_send_front() {
|
||||
this->waiting_for_response_ = std::move(this->tx_buffer_.front());
|
||||
this->tx_buffer_.pop_front();
|
||||
}
|
||||
// Drives the real unexpected-frame branch in process_modbus_server_frame().
|
||||
void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) {
|
||||
this->process_modbus_server_frame(address, function_code, data, len);
|
||||
}
|
||||
void timeout_waiting() {
|
||||
if (this->waiting_for_response_.has_value())
|
||||
this->notify_no_response_(*this->waiting_for_response_);
|
||||
this->waiting_for_response_.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// A device with a scripted answer to on_modbus_no_response().
|
||||
class RetryingDevice : public ModbusClientDevice {
|
||||
public:
|
||||
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
|
||||
bool on_modbus_no_response() override {
|
||||
this->no_response_count_++;
|
||||
return this->retry_;
|
||||
}
|
||||
int no_response_count_{0};
|
||||
|
||||
protected:
|
||||
bool retry_{false};
|
||||
};
|
||||
|
||||
// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry.
|
||||
class ClearingRetryDevice : public ModbusClientDevice {
|
||||
public:
|
||||
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
|
||||
bool on_modbus_no_response() override {
|
||||
this->no_response_count_++;
|
||||
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
|
||||
return true; // and still requests a retry
|
||||
}
|
||||
int no_response_count_{0};
|
||||
};
|
||||
|
||||
constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100
|
||||
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> read_pdu() {
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
|
||||
pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// A device that requests a retry gets the frame the hub was holding re-queued on its behalf,
|
||||
// byte-identical and still routed to the same device.
|
||||
TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
|
||||
NoResponseProbeHub hub;
|
||||
RetryingDevice device(&hub, 0x02, /*retry=*/true);
|
||||
|
||||
device.send_pdu(read_pdu());
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
hub.force_send_front();
|
||||
ASSERT_EQ(hub.queued_frames(), 0u);
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
hub.timeout_waiting();
|
||||
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
const ModbusDeviceCommand &requeued = hub.front();
|
||||
EXPECT_EQ(requeued.device, &device);
|
||||
// address + PDU + CRC
|
||||
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
|
||||
EXPECT_EQ(requeued.frame.data.data()[0], 0x02);
|
||||
EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU)));
|
||||
}
|
||||
|
||||
// A device that declines the retry has the frame dropped.
|
||||
TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) {
|
||||
NoResponseProbeHub hub;
|
||||
RetryingDevice device(&hub, 0x02, /*retry=*/false);
|
||||
|
||||
device.send_pdu(read_pdu());
|
||||
hub.force_send_front();
|
||||
|
||||
hub.timeout_waiting();
|
||||
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
}
|
||||
|
||||
// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on
|
||||
// destruction), a timeout must not deliver a callback or re-queue anything.
|
||||
TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) {
|
||||
NoResponseProbeHub hub;
|
||||
{
|
||||
RetryingDevice device(&hub, 0x02, /*retry=*/true);
|
||||
device.send_pdu(read_pdu());
|
||||
hub.force_send_front();
|
||||
// device destructor clears its queue entries, including the waiting frame's device pointer
|
||||
}
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
EXPECT_EQ(hub.waiting_command().device, nullptr);
|
||||
|
||||
hub.timeout_waiting();
|
||||
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
}
|
||||
|
||||
// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the
|
||||
// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the
|
||||
// send-wait timeout clears it - without a second no-response callback or a duplicate requeue.
|
||||
TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) {
|
||||
NoResponseProbeHub hub;
|
||||
RetryingDevice device(&hub, 0x02, /*retry=*/true);
|
||||
|
||||
device.send_pdu(read_pdu());
|
||||
hub.force_send_front();
|
||||
|
||||
// A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch.
|
||||
const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload));
|
||||
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue...
|
||||
EXPECT_EQ(hub.front().device, &device);
|
||||
ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot
|
||||
EXPECT_TRUE(hub.waiting_command().interrupted);
|
||||
EXPECT_EQ(hub.waiting_command().device, nullptr);
|
||||
|
||||
// The send-wait timeout clears the shell without a second callback or another requeue.
|
||||
hub.timeout_waiting();
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_EQ(hub.queued_frames(), 1u);
|
||||
}
|
||||
|
||||
// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request:
|
||||
// no orphaned frame with a null device is re-queued.
|
||||
TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
|
||||
NoResponseProbeHub hub;
|
||||
ClearingRetryDevice device(&hub, 0x02);
|
||||
|
||||
device.send_pdu(read_pdu());
|
||||
hub.force_send_front();
|
||||
hub.timeout_waiting();
|
||||
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
@@ -181,17 +181,17 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
|
||||
|
||||
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
|
||||
const std::vector<uint8_t> data{0x12, 0x34};
|
||||
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);
|
||||
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) {
|
||||
const std::vector<uint8_t> data{0x12, 0x34, 0x56};
|
||||
EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0);
|
||||
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value());
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) {
|
||||
const std::vector<uint8_t> data{0x12, 0x34};
|
||||
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
|
||||
EXPECT_EQ(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
|
||||
}
|
||||
|
||||
// --- registers_to_number ---------------------------------------------------
|
||||
@@ -218,16 +218,15 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
|
||||
const uint16_t registers[] = {0x8001, 0x0002};
|
||||
const std::vector<uint8_t> bytes{0x80, 0x01, 0x00, 0x02};
|
||||
for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) {
|
||||
EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF))
|
||||
EXPECT_EQ(registers_to_number(registers, 2, value_type),
|
||||
payload_to_number(std::span<const uint8_t>(bytes), value_type, 0, 0xFFFFFFFF))
|
||||
<< "value_type=" << static_cast<int>(value_type);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
|
||||
const uint16_t registers[] = {0x1234};
|
||||
bool error = false;
|
||||
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0);
|
||||
EXPECT_TRUE(error);
|
||||
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::helpers
|
||||
|
||||
Reference in New Issue
Block a user