ESPHome 2026.8.0b4
Loading...
Searching...
No Matches
modbus_server.cpp
Go to the documentation of this file.
1#include "modbus_server.h"
3#include "esphome/core/log.h"
4
8
9static const char *const TAG = "modbus_server";
10
11// The widest Modbus value type (QWORD) spans four registers.
12static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4;
13// number_to_payload() encodes the 64-bit value returned by read_lambda() into 16-bit registers, so the
14// widest possible value spans exactly sizeof(int64_t) / sizeof(uint16_t) registers. Tie the bound to that
15// source so a future wider value type -- which would require widening the encoded value itself -- can't
16// silently overflow the value_words buffer below (StaticVector::push_back drops words past capacity).
17static_assert(MAX_REGISTERS_PER_VALUE == sizeof(int64_t) / sizeof(uint16_t),
18 "MAX_REGISTERS_PER_VALUE must match the register span of the widest encodable value");
19
21 for (auto *server_register : this->server_registers_) {
22 if (address >= server_register->address &&
23 address < static_cast<uint32_t>(server_register->address) + server_register->register_count) {
24 return server_register;
25 }
26 }
27 return nullptr;
28}
29
30modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, uint16_t number_of_registers,
31 modbus::RegisterValues &registers) {
32 ESP_LOGV(TAG,
33 "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.",
34 this->address_, start_address, number_of_registers);
35
36 // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement
37 // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers
38 // ILLEGAL_DATA_ADDRESS below.
39 if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled)
41
42 const uint32_t end_address = static_cast<uint32_t>(start_address) + number_of_registers;
43 uint32_t current_address = start_address;
44 while (current_address < end_address) {
45 ServerRegister *server_register = this->find_containing_register_(current_address);
46
47 if (server_register == nullptr) {
48 // Unregistered address: optionally answer with the courtesy default, otherwise reject.
50 current_address <= this->server_courtesy_response_.register_last_address) {
51 ESP_LOGV(TAG, "No register at 0x%04X; returning courtesy default %" PRIu16 ".",
52 static_cast<uint16_t>(current_address), this->server_courtesy_response_.register_value);
54 current_address += 1; // the courtesy default is always a single register
55 continue;
56 }
57 ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.",
58 static_cast<uint16_t>(current_address));
60 }
61
62 if (!server_register->read_lambda) {
63 // Registered but not readable (write-only); don't mask it with the courtesy default.
64 ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address);
66 }
67
68 // A multi-register value is normally atomic: the request must start at its first register and cover all of
69 // it. A value may opt in to partial reads, in which case the request may start inside it or stop short of
70 // its end and we return only the covered words.
71 const uint16_t value_offset = static_cast<uint16_t>(current_address - server_register->address);
72 const uint16_t words_available = static_cast<uint16_t>(server_register->register_count - value_offset);
73 const uint16_t words_wanted = static_cast<uint16_t>(end_address - current_address);
74 const uint16_t take = words_available < words_wanted ? words_available : words_wanted;
75 const bool clipped = value_offset != 0 || take != server_register->register_count;
76 if (clipped && !server_register->allow_partial_read) {
77 ESP_LOGW(TAG,
78 "Read clips the multi-register value at 0x%04X, which does not allow partial reads. "
79 "Sending exception response.",
80 server_register->address);
82 }
83
84 const optional<int64_t> read_value = server_register->read_lambda();
85 if (!read_value.has_value()) {
86 ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.",
87 server_register->address);
89 }
90 const int64_t value = *read_value;
92 ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.",
93 server_register->address, static_cast<size_t>(server_register->value_type),
94 server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf)));
95
96 // Encode the whole value once (wire word order) and emit only the covered words. Slicing the encoded words
97 // handles the reversed value types for free, since number_to_payload already emits in wire order.
99 modbus::helpers::number_to_payload(value_words, value, server_register->value_type);
100 if (value_offset + take > value_words.size()) {
101 // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault.
102 ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address,
103 server_register->register_count);
105 }
106 for (uint16_t i = 0; i < take; i++) {
107 registers.push_back(value_words[value_offset + i]);
108 }
109 current_address += take;
110 }
111
112 return {};
113}
114
116 const modbus::RegisterValues &registers) {
117 // registers holds the values to write in host byte order; its size is the register count.
118 ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.",
119 this->address_, start_address, registers.size());
120
121 // No registers configured (e.g. a bits-only server): this device does not implement the register-write
122 // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS.
123 if (this->server_registers_.empty())
125
126 auto for_each_register =
127 [this, start_address,
128 &registers](const std::function<bool(ServerRegister *, uint16_t register_offset)> &callback) -> bool {
129 uint16_t register_offset = 0;
130 for (uint32_t current_address = start_address; current_address < start_address + registers.size();) {
131 bool ok = false;
132 for (auto *server_register : this->server_registers_) {
133 if (server_register->address == current_address) {
134 ok = callback(server_register, register_offset);
135 current_address += server_register->register_count;
136 register_offset += server_register->register_count;
137 break;
138 }
139 }
140
141 if (!ok) {
142 return false;
143 }
144 }
145 return true;
146 };
147
148 // Pre-flight: every targeted register must be writable AND have its full value present in the request,
149 // so we never apply a partial write before discovering a problem. The commit pass below re-runs
150 // registers_to_number rather than caching the decoded values: using the same function for the check and
151 // the write keeps a single source of truth for the decode bound, independent of how register_count was set.
152 ExceptionCode precheck = ExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register
153 if (!for_each_register([&precheck, &registers](ServerRegister *server_register, uint16_t register_offset) -> bool {
154 if (server_register->write_lambda == nullptr) {
155 return false; // unwritable -> ILLEGAL_DATA_ADDRESS
156 }
157 if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
158 server_register->value_type)
159 .has_value()) {
160 precheck = ExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value
161 return false;
162 }
163 return true;
164 })) {
165 // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for
166 // registers this device does not map is routine. The hub logs the outcome with the context it has.
167 ESP_LOGV(TAG, "Write request rejected before applying any register.");
168 return precheck;
169 }
170
171 // Commit: every value is known writable and decodable, so the only failure now is a user write callback
172 // rejecting the value at runtime -- which cannot be rolled back.
173 if (!for_each_register([&registers](ServerRegister *server_register, uint16_t register_offset) {
174 int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
175 server_register->value_type)
176 .value_or(0);
177 return server_register->write_lambda(number);
178 })) {
179 ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied.");
181 }
182
183 // Success: the caller builds the write response (an echo of the request header).
184 return {};
185}
186
188 for (auto *server_bit : this->server_bits_) {
189 if (server_bit->address == address) {
190 return server_bit;
191 }
192 }
193 return nullptr;
194}
195
197 ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.",
198 this->address_, start_address, bits.size());
199
200 // No bits configured: this device does not implement the coil/discrete-input function, so answer
201 // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below.
202 if (this->server_bits_.empty())
204
205 for (uint16_t i = 0; i < bits.size(); i++) {
206 const uint16_t address = static_cast<uint16_t>(start_address + i); // range pre-checked by the hub
207 ServerBit *server_bit = this->find_bit_(address);
208 if (server_bit == nullptr || !server_bit->read_lambda) {
209 ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address);
211 }
212 const optional<bool> value = server_bit->read_lambda(address);
213 if (!value.has_value()) {
214 ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address);
216 }
217 bits.set(i, *value);
218 }
219 return {};
220}
221
223 ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_,
224 start_address, bits.size());
225
226 // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather
227 // than ILLEGAL_DATA_ADDRESS.
228 if (this->server_bits_.empty())
230
231 // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write
232 // before discovering a problem (mirrors the register write's two passes).
233 for (uint16_t i = 0; i < bits.size(); i++) {
234 const uint16_t address = static_cast<uint16_t>(start_address + i);
235 ServerBit *server_bit = this->find_bit_(address);
236 if (server_bit == nullptr || !server_bit->write_lambda) {
237 // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for
238 // bits this device does not map is routine. The hub logs the outcome with the context it has.
239 ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address);
241 }
242 }
243
244 // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather
245 // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the
246 // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight
247 // can never turn this into a silent null dereference. The only expected failure is a write callback
248 // rejecting the value at runtime, which cannot be rolled back.
249 for (uint16_t i = 0; i < bits.size(); i++) {
250 const uint16_t address = static_cast<uint16_t>(start_address + i);
251 ServerBit *server_bit = this->find_bit_(address);
252 if (server_bit == nullptr || !server_bit->write_lambda) {
253 ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address);
255 }
256 if (!server_bit->write_lambda(address, bits[i])) {
257 ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address);
259 }
260 }
261 return {};
262}
263
265 ESP_LOGCONFIG(TAG,
266 "ModbusServer:\n"
267 " Address: 0x%02X\n"
268 " Server Courtesy Response:\n"
269 " Enabled: %s\n"
270 " Register Last Address: 0x%02X\n"
271 " Register Value: %" PRIu16,
272 this->address_, this->server_courtesy_response_.enabled ? "true" : "false",
273 this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value);
274
275#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
276 ESP_LOGCONFIG(TAG, "server registers");
277 for (auto &r : this->server_registers_) {
278 ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address,
279 static_cast<uint8_t>(r->value_type), r->register_count);
280 }
281 ESP_LOGCONFIG(TAG, "server bits");
282 for (auto &b : this->server_bits_) {
283 ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false",
284 b->write_lambda ? "true" : "false");
285 }
286#endif
287}
288
289} // namespace esphome::modbus_server
uint8_t address
Definition bl0906.h:4
Minimal static vector - saves memory by avoiding std::vector overhead.
Definition helpers.h:227
size_t size() const
Definition helpers.h:292
void push_back(const T &value)
Definition helpers.h:265
Mutable counterpart of PackedBits: set() writes bits in place (deliberately no proxy operator[]=).
void set(size_t bit, bool value)
Set or clear the given bit.
Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first),...
uint16_t size() const
Number of bits in the view.
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues &registers) final
called when a modbus request (function code 0x06 or 0x10) was parsed without errors
modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final
called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are served ...
ServerRegister * find_containing_register_(uint32_t address) const
Find the registered value whose register span contains address, or nullptr if none does.
std::vector< ServerBit * > server_bits_
Collection of all server bits (coils/discrete inputs) for this component.
std::vector< ServerRegister * > server_registers_
Collection of all server registers for this component.
modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues &registers) final
called when a modbus request (function code 0x03 or 0x04) was parsed without errors
ServerBit * find_bit_(uint16_t address) const
Find the registered bit at address, or nullptr if none is.
modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final
called when a modbus request (function code 0x05 or 0x0F) was parsed without errors
ServerCourtesyResponse server_courtesy_response_
Server courtesy response.
A single bit in the server's coil/discrete-input table.
const char * format_value(int64_t value, char *buf, size_t buf_size) const
static constexpr size_t FORMAT_VALUE_BUF_SIZE
void number_to_payload(Container &data, int64_t value, SensorValueType value_type)
Append the Modbus register words for value to data.
std::optional< int64_t > registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type)
Reconstruct a number from register words (host byte order).
std::optional< ExceptionCode > ResponseStatus
Definition modbus.h:336
static void uint32_t