[modbus] Finalize unreleased API surface before 2026.7 (#17434)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-07-08 00:20:14 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 731e9fda03
commit 731486d9b0
12 changed files with 272 additions and 55 deletions
+28 -6
View File
@@ -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)
+10 -3
View File
@@ -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;
+7 -14
View File
@@ -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,
+24 -11
View File
@@ -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([&registers](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