mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 19:13:18 +08:00
[modbus_server] Add coil/discrete-input support (#17464)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c8d2c3691a
commit
2a0f2d59f0
@@ -12,6 +12,7 @@ from esphome.types import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_ALLOW_PARTIAL_READ,
|
||||
CONF_BITS,
|
||||
CONF_COURTESY_RESPONSE,
|
||||
CONF_READ_LAMBDA,
|
||||
CONF_REGISTER_LAST_ADDRESS,
|
||||
@@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_(
|
||||
|
||||
ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse")
|
||||
ServerRegister = modbus_server_ns.struct("ServerRegister")
|
||||
ServerBit = modbus_server_ns.class_("ServerBit")
|
||||
|
||||
SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
ModbusServerBitSchema = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ServerBit),
|
||||
cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
|
||||
cv.Required(CONF_READ_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType:
|
||||
# Coils and discrete inputs share one bit address space (like holding/input registers share the
|
||||
# register table), so each bit address may appear only once.
|
||||
seen: set[int] = set()
|
||||
for bit in config.get(CONF_BITS, []):
|
||||
address = bit[CONF_ADDRESS]
|
||||
if address in seen:
|
||||
raise cv.Invalid(
|
||||
f"Bit address 0x{address:04X} is configured more than once; coils and discrete "
|
||||
"inputs share one bit address space, so each address must be unique",
|
||||
path=[CONF_BITS],
|
||||
)
|
||||
seen.add(address)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_register_ranges(config: ConfigType) -> ConfigType:
|
||||
# Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit
|
||||
# Modbus address space (0x0000-0xFFFF).
|
||||
@@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(
|
||||
CONF_REGISTERS,
|
||||
): cv.ensure_list(ModbusServerRegisterSchema),
|
||||
cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema),
|
||||
}
|
||||
).extend(modbus.modbus_device_schema(0x01, role="server")),
|
||||
_validate_register_ranges,
|
||||
_validate_no_overlapping_registers,
|
||||
_validate_unique_bit_addresses,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,7 +182,7 @@ async def to_code(config):
|
||||
await cg.process_lambda(
|
||||
server_register[CONF_READ_LAMBDA],
|
||||
[(cg.uint16, "address")],
|
||||
return_type=cpp_type,
|
||||
return_type=cg.optional.template(cpp_type),
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -170,5 +200,27 @@ async def to_code(config):
|
||||
if server_register[CONF_ALLOW_PARTIAL_READ]:
|
||||
cg.add(server_register_var.set_allow_partial_read(True))
|
||||
cg.add(var.add_server_register(server_register_var))
|
||||
for server_bit in config.get(CONF_BITS, []):
|
||||
server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS])
|
||||
cg.add(
|
||||
server_bit_var.set_read_lambda(
|
||||
await cg.process_lambda(
|
||||
server_bit[CONF_READ_LAMBDA],
|
||||
[(cg.uint16, "address")],
|
||||
return_type=cg.optional.template(cg.bool_),
|
||||
)
|
||||
)
|
||||
)
|
||||
if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None:
|
||||
cg.add(
|
||||
server_bit_var.set_write_lambda(
|
||||
await cg.process_lambda(
|
||||
write_lambda,
|
||||
parameters=[(cg.uint16, "address"), (cg.bool_, "x")],
|
||||
return_type=cg.bool_,
|
||||
)
|
||||
)
|
||||
)
|
||||
cg.add(var.add_server_bit(server_bit_var))
|
||||
await cg.register_component(var, config)
|
||||
return await modbus.register_modbus_server_device(var, config)
|
||||
|
||||
@@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response"
|
||||
CONF_READ_LAMBDA = "read_lambda"
|
||||
CONF_WRITE_LAMBDA = "write_lambda"
|
||||
CONF_REGISTERS = "registers"
|
||||
CONF_BITS = "bits"
|
||||
CONF_ALLOW_PARTIAL_READ = "allow_partial_read"
|
||||
|
||||
@@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u
|
||||
"Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.",
|
||||
this->address_, start_address, number_of_registers);
|
||||
|
||||
// No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement
|
||||
// the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers
|
||||
// ILLEGAL_DATA_ADDRESS below.
|
||||
if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled)
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
|
||||
const uint32_t end_address = static_cast<uint32_t>(start_address) + number_of_registers;
|
||||
uint32_t current_address = start_address;
|
||||
while (current_address < end_address) {
|
||||
@@ -75,7 +81,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
int64_t value = server_register->read_lambda();
|
||||
const optional<int64_t> read_value = server_register->read_lambda();
|
||||
if (!read_value.has_value()) {
|
||||
ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.",
|
||||
server_register->address);
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
const int64_t value = *read_value;
|
||||
char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE];
|
||||
ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.",
|
||||
server_register->address, static_cast<size_t>(server_register->value_type),
|
||||
@@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.",
|
||||
this->address_, start_address, registers.size());
|
||||
|
||||
// No registers configured (e.g. a bits-only server): this device does not implement the register-write
|
||||
// function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS.
|
||||
if (this->server_registers_.empty())
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
|
||||
auto for_each_register =
|
||||
[this, start_address,
|
||||
®isters](const std::function<bool(ServerRegister *, uint16_t register_offset)> &callback) -> bool {
|
||||
@@ -167,6 +184,83 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
return {};
|
||||
}
|
||||
|
||||
ServerBit *ModbusServer::find_bit_(uint16_t address) const {
|
||||
for (auto *server_bit : this->server_bits_) {
|
||||
if (server_bit->address == address) {
|
||||
return server_bit;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) {
|
||||
ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.",
|
||||
this->address_, start_address, bits.size());
|
||||
|
||||
// No bits configured: this device does not implement the coil/discrete-input function, so answer
|
||||
// ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below.
|
||||
if (this->server_bits_.empty())
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
|
||||
for (uint16_t i = 0; i < bits.size(); i++) {
|
||||
const uint16_t address = static_cast<uint16_t>(start_address + i); // range pre-checked by the hub
|
||||
ServerBit *server_bit = this->find_bit_(address);
|
||||
if (server_bit == nullptr || !server_bit->read_lambda) {
|
||||
ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address);
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
const optional<bool> value = server_bit->read_lambda(address);
|
||||
if (!value.has_value()) {
|
||||
ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address);
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
bits.set(i, *value);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) {
|
||||
ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_,
|
||||
start_address, bits.size());
|
||||
|
||||
// No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather
|
||||
// than ILLEGAL_DATA_ADDRESS.
|
||||
if (this->server_bits_.empty())
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
|
||||
// Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write
|
||||
// before discovering a problem (mirrors the register write's two passes).
|
||||
for (uint16_t i = 0; i < bits.size(); i++) {
|
||||
const uint16_t address = static_cast<uint16_t>(start_address + i);
|
||||
ServerBit *server_bit = this->find_bit_(address);
|
||||
if (server_bit == nullptr || !server_bit->write_lambda) {
|
||||
// Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for
|
||||
// bits this device does not map is routine. The hub logs the outcome with the context it has.
|
||||
ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address);
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather
|
||||
// than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the
|
||||
// register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight
|
||||
// can never turn this into a silent null dereference. The only expected failure is a write callback
|
||||
// rejecting the value at runtime, which cannot be rolled back.
|
||||
for (uint16_t i = 0; i < bits.size(); i++) {
|
||||
const uint16_t address = static_cast<uint16_t>(start_address + i);
|
||||
ServerBit *server_bit = this->find_bit_(address);
|
||||
if (server_bit == nullptr || !server_bit->write_lambda) {
|
||||
ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address);
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
if (!server_bit->write_lambda(address, bits[i])) {
|
||||
ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address);
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ModbusServer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"ModbusServer:\n"
|
||||
@@ -184,6 +278,11 @@ void ModbusServer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address,
|
||||
static_cast<uint8_t>(r->value_type), r->register_count);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, "server bits");
|
||||
for (auto &b : this->server_bits_) {
|
||||
ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false",
|
||||
b->write_lambda ? "true" : "false");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ struct ServerCourtesyResponse {
|
||||
};
|
||||
|
||||
class ServerRegister {
|
||||
using ReadLambda = std::function<int64_t()>;
|
||||
using ReadLambda = std::function<optional<int64_t>()>;
|
||||
using WriteLambda = std::function<bool(int64_t value)>;
|
||||
|
||||
public:
|
||||
@@ -30,13 +30,18 @@ class ServerRegister {
|
||||
this->register_count = register_count;
|
||||
}
|
||||
|
||||
template<typename T> void set_read_lambda(const std::function<T(uint16_t address)> &&user_read_lambda) {
|
||||
this->read_lambda = [this, user_read_lambda]() -> int64_t {
|
||||
T user_value = user_read_lambda(this->address);
|
||||
/// The user lambda returns optional<T>: an empty optional declines the read, answering the whole
|
||||
/// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly.
|
||||
template<typename T> void set_read_lambda(const std::function<optional<T>(uint16_t address)> &&user_read_lambda) {
|
||||
this->read_lambda = [this, user_read_lambda]() -> optional<int64_t> {
|
||||
const optional<T> user_value = user_read_lambda(this->address);
|
||||
if (!user_value.has_value()) {
|
||||
return {};
|
||||
}
|
||||
if constexpr (std::is_same_v<T, float>) {
|
||||
return bit_cast<uint32_t>(user_value);
|
||||
return bit_cast<uint32_t>(*user_value);
|
||||
} else {
|
||||
return static_cast<int64_t>(user_value);
|
||||
return static_cast<int64_t>(*user_value);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -97,17 +102,43 @@ class ServerRegister {
|
||||
WriteLambda write_lambda;
|
||||
};
|
||||
|
||||
/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete
|
||||
/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the
|
||||
/// register table: both read function codes are served from the same bits.
|
||||
class ServerBit {
|
||||
/// Returning an empty optional declines the read: the whole request is answered with a
|
||||
/// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly.
|
||||
using ReadLambda = std::function<optional<bool>(uint16_t address)>;
|
||||
using WriteLambda = std::function<bool(uint16_t address, bool value)>;
|
||||
|
||||
public:
|
||||
explicit ServerBit(uint16_t address) : address(address) {}
|
||||
void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); }
|
||||
void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); }
|
||||
|
||||
uint16_t address{0};
|
||||
ReadLambda read_lambda;
|
||||
WriteLambda write_lambda;
|
||||
};
|
||||
|
||||
class ModbusServer final : public Component, public modbus::ModbusServerDevice {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
/// Registers a server register with the controller. Called by esphomes code generator
|
||||
void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); }
|
||||
/// Registers a server bit with the controller. Called by esphomes code generator
|
||||
void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); }
|
||||
/// called when a modbus request (function code 0x03 or 0x04) was parsed without errors
|
||||
modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®isters) final;
|
||||
/// called when a modbus request (function code 0x06 or 0x10) was parsed without errors
|
||||
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final;
|
||||
/// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are
|
||||
/// served from the same bit table (see ServerBit)
|
||||
modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final;
|
||||
/// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors
|
||||
modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final;
|
||||
/// Called by esphome generated code to set the server courtesy response object
|
||||
void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) {
|
||||
this->server_courtesy_response_ = server_courtesy_response;
|
||||
@@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice {
|
||||
protected:
|
||||
/// Find the registered value whose register span contains address, or nullptr if none does.
|
||||
ServerRegister *find_containing_register_(uint32_t address) const;
|
||||
/// Find the registered bit at address, or nullptr if none is.
|
||||
ServerBit *find_bit_(uint16_t address) const;
|
||||
/// Collection of all server registers for this component
|
||||
std::vector<ServerRegister *> server_registers_{};
|
||||
/// Collection of all server bits (coils/discrete inputs) for this component
|
||||
std::vector<ServerBit *> server_bits_{};
|
||||
/// Server courtesy response
|
||||
ServerCourtesyResponse server_courtesy_response_{
|
||||
.enabled = false, .register_last_address = 0xFFFF, .register_value = 0};
|
||||
|
||||
@@ -7,8 +7,13 @@ from esphome.components.modbus_server import (
|
||||
SERVER_SENSOR_VALUE_TYPE,
|
||||
_validate_no_overlapping_registers,
|
||||
_validate_register_ranges,
|
||||
_validate_unique_bit_addresses,
|
||||
)
|
||||
from esphome.components.modbus_server.const import (
|
||||
CONF_BITS,
|
||||
CONF_REGISTERS,
|
||||
CONF_VALUE_TYPE,
|
||||
)
|
||||
from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE
|
||||
from esphome.const import CONF_ADDRESS
|
||||
|
||||
|
||||
@@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _bits_config(addresses: list[int]) -> dict:
|
||||
return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]}
|
||||
|
||||
|
||||
def test_non_overlapping_registers_pass() -> None:
|
||||
# Values that tile the address space without gaps or overlaps are accepted.
|
||||
config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")])
|
||||
@@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None:
|
||||
_validate_no_overlapping_registers(config)
|
||||
|
||||
|
||||
def test_unique_bit_addresses_pass() -> None:
|
||||
config = _bits_config([0x00, 0x01, 0x02])
|
||||
assert _validate_unique_bit_addresses(config) is config
|
||||
|
||||
|
||||
def test_duplicate_bit_address_rejected() -> None:
|
||||
# Coils and discrete inputs share one bit address space, so a repeated address is rejected.
|
||||
config = _bits_config([0x05, 0x05])
|
||||
with pytest.raises(cv.Invalid, match="more than once"):
|
||||
_validate_unique_bit_addresses(config)
|
||||
|
||||
|
||||
def test_multi_register_value_overlapping_neighbour_rejected() -> None:
|
||||
# U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word.
|
||||
config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")])
|
||||
|
||||
@@ -15,6 +15,16 @@ modbus_server:
|
||||
- id: modbus_server3
|
||||
address: 0x3
|
||||
modbus_id: mod_bus2
|
||||
bits:
|
||||
- address: 0x0
|
||||
read_lambda: |-
|
||||
return true;
|
||||
- address: 0x1
|
||||
read_lambda: |-
|
||||
return address == 0x1;
|
||||
write_lambda: |-
|
||||
printf("bit address=%d, value=%d\n", (int) address, (int) x);
|
||||
return true;
|
||||
registers:
|
||||
- address: 0x9
|
||||
value_type: S_DWORD
|
||||
|
||||
@@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
|
||||
// A write to an address not covered by any configured register (on a populated server) yields
|
||||
// ILLEGAL_DATA_ADDRESS.
|
||||
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
|
||||
ModbusServer server;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
reg.write_lambda = [](int64_t) { return true; };
|
||||
server.add_server_register(®);
|
||||
|
||||
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION.
|
||||
TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) {
|
||||
ModbusServer server;
|
||||
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
|
||||
}
|
||||
|
||||
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
|
||||
// applied, and the handler reports SERVICE_DEVICE_FAILURE.
|
||||
TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
|
||||
@@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
|
||||
EXPECT_EQ(out[1], 0xABCD);
|
||||
}
|
||||
|
||||
// An unregistered address with courtesy disabled is rejected.
|
||||
// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS.
|
||||
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
|
||||
ModbusServer server;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
reg.read_lambda = []() -> int64_t { return 0x1234; };
|
||||
server.add_server_register(®);
|
||||
|
||||
RegisterValues out;
|
||||
auto status = server.on_read_registers(0x0005, 1, out);
|
||||
ASSERT_TRUE(status.has_value());
|
||||
@@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A server with no registers configured (courtesy disabled) does not implement the register-read
|
||||
// function: ILLEGAL_FUNCTION.
|
||||
TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) {
|
||||
ModbusServer server;
|
||||
RegisterValues out;
|
||||
auto status = server.on_read_registers(0x0005, 1, out);
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
|
||||
}
|
||||
|
||||
// A register read lambda returning an empty optional declines the read: the whole request is
|
||||
// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda<T> so the optional-forwarding wrapper
|
||||
// (not a hand-assigned read_lambda) is what carries the decline through.
|
||||
TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) {
|
||||
ModbusServer server;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
reg.set_read_lambda<uint16_t>([](uint16_t address) -> optional<uint16_t> { return {}; });
|
||||
server.add_server_register(®);
|
||||
|
||||
RegisterValues out;
|
||||
auto status = server.on_read_registers(0x0000, 1, out);
|
||||
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
|
||||
}
|
||||
|
||||
// --- partial reads (opt-in) ----------------------------------------------------
|
||||
|
||||
// With allow_partial_read, reading only the first register of a DWORD returns its high word.
|
||||
@@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) {
|
||||
EXPECT_EQ(second[0], 0x1234);
|
||||
}
|
||||
|
||||
// --- bits (coils / discrete inputs, one shared address space) -------------------
|
||||
|
||||
// Bits are read through the shared table regardless of which read function code arrived:
|
||||
// the hub routes both 0x01 and 0x02 to on_read_bits().
|
||||
TEST(ModbusServerBits, ReadSetsRequestedBits) {
|
||||
ModbusServer server;
|
||||
ServerBit bit0(0x0000);
|
||||
bit0.set_read_lambda([](uint16_t) { return true; });
|
||||
ServerBit bit1(0x0001);
|
||||
bit1.set_read_lambda([](uint16_t) { return false; });
|
||||
ServerBit bit2(0x0002);
|
||||
bit2.set_read_lambda([](uint16_t) { return true; });
|
||||
server.add_server_bit(&bit0);
|
||||
server.add_server_bit(&bit1);
|
||||
server.add_server_bit(&bit2);
|
||||
|
||||
uint8_t packed[1] = {0};
|
||||
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_EQ(packed[0], 0b101);
|
||||
}
|
||||
|
||||
// The read lambda receives the bit's address, so one lambda can serve several bits.
|
||||
TEST(ModbusServerBits, ReadLambdaReceivesAddress) {
|
||||
ModbusServer server;
|
||||
ServerBit server_bit(0x0007);
|
||||
server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; });
|
||||
server.add_server_bit(&server_bit);
|
||||
|
||||
uint8_t packed[1] = {0};
|
||||
auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_EQ(packed[0], 0x01);
|
||||
}
|
||||
|
||||
// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS.
|
||||
TEST(ModbusServerBits, UnreadableBitRejectsRead) {
|
||||
ModbusServer server;
|
||||
ServerBit readable(0x0000);
|
||||
readable.set_read_lambda([](uint16_t) { return true; });
|
||||
ServerBit write_only(0x0001);
|
||||
write_only.set_write_lambda([](uint16_t, bool) { return true; });
|
||||
server.add_server_bit(&readable);
|
||||
server.add_server_bit(&write_only);
|
||||
|
||||
uint8_t packed[1] = {0};
|
||||
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
|
||||
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
|
||||
auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1));
|
||||
EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A read lambda returning an empty optional declines the read: the whole request is answered
|
||||
// with SERVICE_DEVICE_FAILURE.
|
||||
TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) {
|
||||
ModbusServer server;
|
||||
ServerBit ok(0x0000);
|
||||
ok.set_read_lambda([](uint16_t) { return true; });
|
||||
ServerBit declining(0x0001);
|
||||
declining.set_read_lambda([](uint16_t) -> optional<bool> { return {}; });
|
||||
server.add_server_bit(&ok);
|
||||
server.add_server_bit(&declining);
|
||||
|
||||
uint8_t packed[1] = {0};
|
||||
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
|
||||
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
|
||||
}
|
||||
|
||||
// A multi-coil write applies every bit and reports success.
|
||||
TEST(ModbusServerBits, WriteAppliesAllBits) {
|
||||
ModbusServer server;
|
||||
bool state[2] = {false, true};
|
||||
ServerBit bit0(0x0000);
|
||||
bit0.set_write_lambda([&state](uint16_t, bool value) {
|
||||
state[0] = value;
|
||||
return true;
|
||||
});
|
||||
ServerBit bit1(0x0001);
|
||||
bit1.set_write_lambda([&state](uint16_t, bool value) {
|
||||
state[1] = value;
|
||||
return true;
|
||||
});
|
||||
server.add_server_bit(&bit0);
|
||||
server.add_server_bit(&bit1);
|
||||
|
||||
const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off
|
||||
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_TRUE(state[0]);
|
||||
EXPECT_FALSE(state[1]);
|
||||
}
|
||||
|
||||
// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any
|
||||
// bit is applied.
|
||||
TEST(ModbusServerBits, UnwritableBitAppliesNothing) {
|
||||
ModbusServer server;
|
||||
bool written = false;
|
||||
ServerBit writable(0x0000);
|
||||
writable.set_write_lambda([&written](uint16_t, bool) {
|
||||
written = true;
|
||||
return true;
|
||||
});
|
||||
ServerBit read_only(0x0001);
|
||||
read_only.set_read_lambda([](uint16_t) { return false; });
|
||||
server.add_server_bit(&writable);
|
||||
server.add_server_bit(&read_only);
|
||||
|
||||
const uint8_t packed[1] = {0b11};
|
||||
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
|
||||
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
EXPECT_FALSE(written); // the writable bit must NOT have been applied
|
||||
}
|
||||
|
||||
// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and
|
||||
// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior).
|
||||
TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) {
|
||||
ModbusServer server;
|
||||
bool first_written = false;
|
||||
ServerBit first(0x0000);
|
||||
first.set_write_lambda([&first_written](uint16_t, bool) {
|
||||
first_written = true;
|
||||
return true;
|
||||
});
|
||||
ServerBit second(0x0001);
|
||||
second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime
|
||||
server.add_server_bit(&first);
|
||||
server.add_server_bit(&second);
|
||||
|
||||
const uint8_t packed[1] = {0b11};
|
||||
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
|
||||
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
|
||||
EXPECT_TRUE(first_written);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_server
|
||||
|
||||
@@ -133,8 +133,8 @@ button:
|
||||
on_error:
|
||||
then:
|
||||
- lambda: "id(error_code).publish_state((int) exception_code);"
|
||||
# The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read
|
||||
# action's request PDU and its typed error delivery.
|
||||
# The mock server maps no bits, so it does not implement the coil function: a coil read draws
|
||||
# ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery.
|
||||
- modbus_client.read_coils:
|
||||
address: 1
|
||||
start_address: 0x00
|
||||
@@ -166,7 +166,7 @@ button:
|
||||
on_not_sent:
|
||||
then:
|
||||
- lambda: "id(not_sent_flag).publish_state(1);"
|
||||
# Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION.
|
||||
# Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION.
|
||||
- modbus_client.write_multiple_coils:
|
||||
address: 1
|
||||
start_address: 0x00
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-bits
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_bit_2
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
- id: stored_bit_3
|
||||
type: bool
|
||||
initial_value: "true"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
bits:
|
||||
- address: 0x00
|
||||
read_lambda: return true;
|
||||
- address: 0x01
|
||||
read_lambda: return false;
|
||||
- address: 0x02
|
||||
read_lambda: return id(stored_bit_2);
|
||||
write_lambda: id(stored_bit_2) = x; return true;
|
||||
- address: 0x03
|
||||
read_lambda: return id(stored_bit_3);
|
||||
write_lambda: id(stored_bit_3) = x; return true;
|
||||
|
||||
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
|
||||
# (FC 0x02): the server serves both from one shared bit table, so the two
|
||||
# views must always agree.
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_0"
|
||||
address: 0x00
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_1"
|
||||
address: 0x01
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_0"
|
||||
address: 0x00
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_1"
|
||||
address: 0x01
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_2"
|
||||
address: 0x02
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_3"
|
||||
address: 0x03
|
||||
register_type: discrete_input
|
||||
|
||||
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
|
||||
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
use_write_multiple: true
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -387,8 +387,9 @@ class SensorStateCollector:
|
||||
class SensorTracker:
|
||||
"""Data-driven sensor state tracker with expected-value futures.
|
||||
|
||||
Tracks sensor state updates and resolves futures when sensors report
|
||||
specific expected values. Eliminates per-sensor future boilerplate.
|
||||
Tracks sensor and binary sensor state updates and resolves futures when
|
||||
they report specific expected values. Eliminates per-sensor future
|
||||
boilerplate.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -421,7 +422,10 @@ class SensorTracker:
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
"""State callback suitable for ``subscribe_states``."""
|
||||
if not isinstance(state, SensorState) or state.missing_state:
|
||||
if (
|
||||
not isinstance(state, (SensorState, BinarySensorState))
|
||||
or state.missing_state
|
||||
):
|
||||
return
|
||||
sensor_name = self.key_to_sensor.get(state.key)
|
||||
if not sensor_name or sensor_name not in self.sensor_states:
|
||||
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioesphomeapi import ButtonInfo, NumberInfo
|
||||
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import SensorTracker, find_entity
|
||||
@@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_bits(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test coil/discrete-input round trips between controller and server bits.
|
||||
|
||||
The server serves four bits from one shared table. The controller reads
|
||||
each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02),
|
||||
so the two views must always agree. Two bits are then written back, one
|
||||
via the single-coil write (FC 0x05) and one via the multiple-coils write
|
||||
(FC 0x0F), and the new values must show up in both read views.
|
||||
"""
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
initial_values = {
|
||||
"bit_coil_0": True,
|
||||
"bit_coil_1": False,
|
||||
"bit_coil_2": False,
|
||||
"bit_coil_3": True,
|
||||
"bit_di_0": True,
|
||||
"bit_di_1": False,
|
||||
"bit_di_2": False,
|
||||
"bit_di_3": True,
|
||||
}
|
||||
tracker = SensorTracker(list(initial_values.keys()))
|
||||
|
||||
# Phase 1: expect initial baseline values in both read views
|
||||
initial_futures = tracker.expect_all(initial_values)
|
||||
# Phase 2: expect post-write values (registered now so on_state can match them)
|
||||
written_futures = tracker.expect_all(
|
||||
{
|
||||
"bit_coil_2": True,
|
||||
"bit_di_2": True,
|
||||
"bit_coil_3": False,
|
||||
"bit_di_3": False,
|
||||
}
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities = await tracker.setup_and_start_scenario(client)
|
||||
|
||||
# Wait for initial baseline values to confirm the controller <-> server
|
||||
# connection is working before issuing writes
|
||||
await tracker.await_all(initial_futures, timeout=4.0)
|
||||
|
||||
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false
|
||||
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
|
||||
entity = find_entity(entities, switch_name, SwitchInfo)
|
||||
assert entity is not None, f"{switch_name} switch entity not found"
|
||||
client.switch_command(entity.key, value)
|
||||
|
||||
# Wait for both read views to reflect the written values
|
||||
await tracker.await_all(written_futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_multiple(
|
||||
yaml_config: str,
|
||||
@@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed(
|
||||
with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value);
|
||||
a read of unserved register 0x99 resolves via on_error with the device's exception code
|
||||
(ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via
|
||||
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error
|
||||
delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12
|
||||
chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from
|
||||
the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
|
||||
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not
|
||||
implement the coil function - proving the bit-read request and typed error delivery. A multi-register
|
||||
write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler
|
||||
(-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server
|
||||
(-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
|
||||
builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent
|
||||
(-> not_sent_flag).
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user