ESPHome 2026.10.0-dev
Loading...
Searching...
No Matches
zwave_proxy.cpp
Go to the documentation of this file.
1#include "zwave_proxy.h"
2
3#ifdef USE_API
4
6
7#include <cinttypes>
10#include "esphome/core/log.h"
11#include "esphome/core/util.h"
12
14
15static const char *const TAG = "zwave_proxy";
16
17// Maximum bytes to log in very verbose hex output (168 * 3 = 504, under TX buffer size of 512)
18static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168;
19
20static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20;
21// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...]
22// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and
23// anything after it are not required to be present
24static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value
25static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum
26static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame)
27static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte
28static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup
29static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect
30static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect
31
32static constexpr bool is_bootloader_menu_byte(uint8_t byte) {
33 // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator
34 return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E);
35}
36
37static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) {
38 // Calculate Z-Wave frame checksum
39 // XOR all bytes between SOF and checksum position (exclusive)
40 // Initial value is 0xFF per Z-Wave protocol specification
41 uint8_t checksum = 0xFF;
42 for (uint8_t i = 1; i < length - 1; i++) {
43 checksum ^= data[i];
44 }
45 return checksum;
46}
47
49
52 this->was_connected_ = this->parent_->is_connected();
53 if (this->was_connected_) {
54 this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS);
55 }
56}
57
59 // Set up before API so home ID is ready when API starts
61}
62
64 // If we already have the home ID, we can proceed
65 if (this->home_id_ready_) {
66 return true;
67 }
68
69 // Handle any pending responses
70 if (this->response_handler_()) {
71 ESP_LOGV(TAG, "Handled response during setup");
72 }
73
74 // Process UART data to check for home ID
75 this->process_uart_();
76
77 // Check if we got the home ID after processing
78 if (this->home_id_ready_) {
79 return true;
80 }
81
82 // Wait up to HOME_ID_TIMEOUT_MS for home ID response
84 if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) {
85 ESP_LOGW(TAG, "Timeout reading Home ID during setup");
86 // The modem may simply still be booting; keep querying from loop() using the same retry
87 // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID
88 // via the HOME_ID_CHANGE message whenever it finally arrives.
89 this->reconnect_time_ = now;
90 this->query_retries_ = 0;
91 return true; // Proceed anyway after timeout
92 }
93
94 return false; // Keep waiting
95}
96
98 if (this->response_handler_()) {
99 ESP_LOGV(TAG, "Handled late response");
100 }
101 if (this->api_connection_ != nullptr && (!this->api_connection_->is_connection_setup() || !api_is_connected())) {
102 ESP_LOGW(TAG, "Subscriber disconnected");
103 this->api_connection_ = nullptr; // Unsubscribe if disconnected
104 }
105
106 const bool connected = this->parent_->is_connected();
107 if (this->was_connected_ != connected) {
108 this->on_connection_changed_(connected);
109 }
110 if (this->reconnect_time_ != 0) {
111 this->retry_home_id_query_();
112 }
113
114 this->process_uart_();
115
116 // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort
117 // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK.
118 // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was
119 // already resolved by response_handler_() above, so a state other than WAIT_START here always
120 // means we are mid-frame.
122 App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) {
123 ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser");
125 this->buffer_index_ = 0;
126 }
127}
128
130 // Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to
131 // drain bytes — available() is still checked at the tail, but not redundantly on entry.
132 do {
133 uint8_t byte;
134 if (!this->read_byte(&byte)) {
135 this->status_set_warning(LOG_STR("UART read failed"));
136 return;
137 }
138 if (this->parse_byte_(byte)) {
139 // Check if this is a GET_NETWORK_IDS response frame
140 // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...]
141 // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so
142 // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode,
143 // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check.
144 // We verify:
145 // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID
146 // - buffer_[2]: Command type (0x01 for response)
147 // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS)
148 if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH &&
149 this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) {
150 // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed
151 // The frame parser has already validated the checksum and ensured all bytes are present
152 if (this->set_home_id_(&this->buffer_[4])) {
153 char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
154 ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()));
156 }
157 this->home_id_ready_ = true;
158 }
159 ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr));
160 if (this->api_connection_ != nullptr) {
161 // Zero-copy: point directly to our buffer
162 this->outgoing_proto_msg_.data = this->buffer_.data();
163 if (this->in_bootloader_) {
165 } else {
166 // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN
167 this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1;
168 }
169 if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) {
170 ESP_LOGV(TAG, "Frame dropped, TCP buffer full");
171 }
172 }
173 }
174 } while (this->available());
175 // Reaching here means every read succeeded, so clear any earlier read-failure warning.
176 // (An early return on read failure skips this, leaving the warning visible until the
177 // next successful drain.)
178 this->status_clear_warning();
179}
180
182 char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
183 ESP_LOGCONFIG(TAG,
184 "Z-Wave Proxy:\n"
185 " Home ID: %s",
186 this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())
187 : LOG_STR_LITERAL("unknown"));
188}
189
191 if (this->home_id_ready_) {
192 // If a client just authenticated & HomeID is ready, send the current HomeID
193 this->send_homeid_changed_msg_(conn);
194 }
195}
196
199 switch (type) {
201 if (this->api_connection_ == api_connection) {
202 ESP_LOGV(TAG, "API connection is already subscribed");
204 }
205 if (this->api_connection_ != nullptr) {
206 // A living subscriber keeps exclusive access. Its connection may be dead without
207 // loop() having noticed yet (e.g. the client crashed and reconnected quickly);
208 // in that case let the new client take over instead of locking it out.
210 ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
212 }
213 ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
214 }
215 this->api_connection_ = api_connection;
216 ESP_LOGV(TAG, "API connection is now subscribed");
218
220 // Unsubscribe is idempotent: not being subscribed is not an error
221 if (this->api_connection_ != api_connection) {
222 ESP_LOGV(TAG, "API connection is not subscribed");
224 }
225 this->api_connection_ = nullptr;
227
228 default:
229 ESP_LOGW(TAG, "Unknown request type: %" PRIu32, static_cast<uint32_t>(type));
231 }
232}
233
235 this->was_connected_ = connected;
236 if (connected) {
237 ESP_LOGD(TAG, "Modem reconnected");
239 this->buffer_index_ = 0;
240 this->last_response_ = 0;
241 this->in_bootloader_ = false;
242 // Defer the query — the modem needs time to initialize after power is applied
244 this->query_retries_ = 0;
245 } else {
246 ESP_LOGW(TAG, "Modem disconnected");
247 this->clear_home_id_();
248 }
249}
250
252 if (this->home_id_ready_) {
253 // Got the home ID, cancel remaining retries
254 this->reconnect_time_ = 0;
255 return;
256 }
257 if (App.get_loop_component_start_time() - this->reconnect_time_ <= RECONNECT_DELAY_MS) {
258 return; // Not yet time for next attempt
259 }
260 this->reconnect_time_ = App.get_loop_component_start_time(); // Reset timer for next retry
261 this->query_retries_++;
262 if (this->query_retries_ <= MAX_QUERY_RETRIES) {
263 ESP_LOGD(TAG, "Querying Home ID (attempt %u)", this->query_retries_);
264 this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS);
265 } else {
266 ESP_LOGW(TAG, "Failed to read Home ID after %u attempts", MAX_QUERY_RETRIES);
267 this->reconnect_time_ = 0;
268 }
269}
270
272 static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {};
273 if (this->set_home_id_(ZERO_HOME_ID)) {
274 ESP_LOGV(TAG, "Home ID cleared");
276 }
277 this->home_id_ready_ = false;
279 this->buffer_index_ = 0;
280 this->last_response_ = 0;
281 this->in_bootloader_ = false;
282}
283
284bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) {
285 if (std::memcmp(this->home_id_.data(), new_home_id, this->home_id_.size()) == 0) {
286 ESP_LOGV(TAG, "Home ID unchanged");
287 return false; // No change
288 }
289 std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size());
290 return true; // Home ID was changed
291}
292
293void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {
294 // Only the subscribed client may talk to the Z-Wave module; a frame from any other
295 // (authenticated but unsubscribed) client would interleave with the subscriber's traffic
296 if (api_connection != this->api_connection_) {
297 ESP_LOGW(TAG, "Ignoring frame from unsubscribed client");
298 return;
299 }
300 this->send_frame_(data, length);
301}
302
303void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) {
304 // Safety: validate pointer before any access
305 if (data == nullptr) {
306 ESP_LOGE(TAG, "Null data pointer");
307 return;
308 }
309 if (length == 0) {
310 ESP_LOGE(TAG, "Length 0");
311 return;
312 }
313
314 // Skip duplicate single-byte responses (ACK/NAK/CAN)
315 if (length == 1 && data[0] == this->last_response_) {
316 ESP_LOGV(TAG, "Response already sent: 0x%02X", data[0]);
317 return;
318 }
319
320#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
321 char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)];
322#endif
323 ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty_to(hex_buf, data, length));
324
325 this->write_array(data, length);
326}
327
331 msg.data = this->home_id_.data();
332 msg.data_len = this->home_id_.size();
333 if (conn != nullptr) {
334 // Send to specific connection
335 if (!conn->send_message(msg)) {
336 API_LOG_MSG_DROPPED(TAG, "Home ID notification");
337 }
338 } else if (api::global_api_server != nullptr) {
339 // We could add code to manage a second subscription type, but, since this message is
340 // very infrequent and small, we simply send it to all clients
342 }
343}
344
345void ZWaveProxy::send_simple_command_(const uint8_t command_id) {
346 // Send a simple Z-Wave command with no parameters
347 // Frame format: [SOF][LENGTH][TYPE][CMD][CHECKSUM]
348 // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM)
349 uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00};
350 cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd));
351 this->send_frame_(cmd, sizeof(cmd));
352}
353
354bool ZWaveProxy::parse_byte_(uint8_t byte) {
355 bool frame_completed = false;
356 // Basic parsing logic for received frames
357 switch (this->parsing_state_) {
359 this->parse_start_(byte);
360 break;
362 if (byte < ZWAVE_MIN_FRAME_LENGTH) {
363 ESP_LOGW(TAG, "Invalid LENGTH: %u", byte);
365 // Send the NAK now; otherwise any bytes already buffered behind this one would be
366 // silently discarded by the SEND_NAK case below until the next loop() iteration
367 this->response_handler_();
368 return false;
369 }
370 ESP_LOGVV(TAG, "Received LENGTH: %u", byte);
371 this->end_frame_after_ = this->buffer_index_ + byte;
372 ESP_LOGVV(TAG, "Calculated EOF: %u", this->end_frame_after_);
373 this->buffer_[this->buffer_index_++] = byte;
375 break;
377 this->buffer_[this->buffer_index_++] = byte;
378 ESP_LOGVV(TAG, "Received TYPE: 0x%02X", byte);
380 break;
382 this->buffer_[this->buffer_index_++] = byte;
383 ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte);
384 // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID
387 break;
389 this->buffer_[this->buffer_index_++] = byte;
390 ESP_LOGVV(TAG, "Received PAYLOAD: 0x%02X", byte);
391 if (this->buffer_index_ >= this->end_frame_after_) {
393 }
394 break;
396 this->buffer_[this->buffer_index_++] = byte;
397 auto checksum = calculate_frame_checksum(this->buffer_.data(), this->buffer_index_);
398 ESP_LOGVV(TAG, "CHECKSUM Received: 0x%02X - Calculated: 0x%02X", byte, checksum);
399 if (checksum != byte) {
400 ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", checksum, byte);
402 } else {
404#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
405 char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)];
406#endif
407 ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty_to(hex_buf, this->buffer_.data(), this->buffer_index_));
408 frame_completed = true;
409 }
410 this->response_handler_();
411 break;
412 }
414 // This state is tentative (see parse_start_): bootloader mode is committed only when a
415 // plausible menu — printable text ending in a NUL terminator — completes. A byte that
416 // cannot be menu text means the 0x0D that started this state was not a menu after all,
417 // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic.
418 if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) {
420 this->parse_start_(byte);
421 break;
422 }
423 this->buffer_[this->buffer_index_++] = byte;
424 if (!byte) {
425 if (!this->in_bootloader_) {
426 ESP_LOGD(TAG, "Entered bootloader mode");
427 this->in_bootloader_ = true;
428 // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM
429 // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses
430 this->last_response_ = 0;
431 }
433 frame_completed = true;
434 }
435 break;
438 break; // Should not happen, handled in loop()
439 default:
440 ESP_LOGW(TAG, "Bad parsing state; resetting");
442 break;
443 }
444 return frame_completed;
445}
446
447void ZWaveProxy::parse_start_(uint8_t byte) {
448 this->buffer_index_ = 0;
450 switch (byte) {
452 ESP_LOGV(TAG, "Received START");
453 if (this->in_bootloader_) {
454 ESP_LOGD(TAG, "Exited bootloader mode");
455 this->in_bootloader_ = false;
456 }
458 this->buffer_[this->buffer_index_++] = byte;
460 return;
462 ESP_LOGV(TAG, "Received BL_MENU");
463 // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the
464 // parser loses frame alignment, so bootloader mode is only committed once a plausible
465 // menu completes (see READ_BL_MENU handling in parse_byte_)
467 this->buffer_[this->buffer_index_++] = byte;
469 return;
471 ESP_LOGV(TAG, "Received BL_BEGIN_UPLOAD");
472 break;
474 ESP_LOGV(TAG, "Received ACK");
475 break;
477 ESP_LOGV(TAG, "Received NAK");
478 break;
480 ESP_LOGV(TAG, "Received CAN");
481 break;
482 default:
483 ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte);
484 return;
485 }
486 // Forward response (ACK/NAK/CAN) back to client for processing
487 if (this->api_connection_ != nullptr) {
488 // Store single byte in buffer and point to it
489 this->buffer_[0] = byte;
490 this->outgoing_proto_msg_.data = this->buffer_.data();
492 if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) {
493 ESP_LOGV(TAG, "Frame dropped, TCP buffer full");
494 }
495 }
496}
497
499 switch (this->parsing_state_) {
502 break;
505 break;
508 break;
509 default:
510 return false; // No response handled
511 }
512
513 ESP_LOGVV(TAG, "Sending %s (0x%02X)",
514 this->last_response_ == ZWAVE_FRAME_TYPE_ACK ? LOG_STR_LITERAL("ACK") : LOG_STR_LITERAL("NAK/CAN"),
515 this->last_response_);
516 this->write_byte(this->last_response_);
518 return true;
519}
520
521ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
522
523} // namespace esphome::zwave_proxy
524
525#endif // USE_API
uint8_t checksum
Definition bl0906.h:3
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void status_clear_warning()
Definition component.h:289
bool send_message(const T &msg)
Returns false as soon as the TCP buffer is full.
void on_zwave_proxy_request(const ZWaveProxyRequest &msg)
enums::ZWaveProxyRequestType type
Definition api_pb2.h:3142
UARTComponent * parent_
Definition uart.h:75
bool read_byte(uint8_t *data)
Definition uart.h:35
void write_byte(uint8_t data)
Definition uart.h:19
void write_array(const uint8_t *data, size_t len)
Definition uart.h:27
api::ZWaveProxyFrame outgoing_proto_msg_
void send_homeid_changed_msg_(api::APIConnection *conn=nullptr)
bool set_home_id_(const uint8_t *new_home_id)
void send_simple_command_(uint8_t command_id)
ESPHOME_ALWAYS_INLINE void process_uart_()
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length)
void send_frame_(const uint8_t *data, size_t length)
void api_connection_authenticated(api::APIConnection *conn)
std::array< uint8_t, MAX_ZWAVE_FRAME_SIZE > buffer_
ESPHOME_ALWAYS_INLINE bool response_handler_()
Definition zwave_proxy.h:90
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type)
api::APIConnection * api_connection_
float get_setup_priority() const override
void on_connection_changed_(bool connected)
std::array< uint8_t, ZWAVE_HOME_ID_SIZE > home_id_
uint16_t type
@ ZWAVE_PROXY_STATUS_NOT_SUPPORTED
Definition api_pb2.h:344
@ ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE
Definition api_pb2.h:337
@ ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE
Definition api_pb2.h:338
@ ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE
Definition api_pb2.h:339
APIServer * global_api_server
constexpr float BEFORE_CONNECTION
For components that should be initialized after WiFi and before API is connected.
Definition component.h:53
ZWaveProxy * global_zwave_proxy
ESPHOME_ALWAYS_INLINE bool api_is_connected()
Return whether the node has at least one client connected to the native API.
Definition util.h:20
char * format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator)
Format byte array as uppercase hex to buffer (base implementation).
Definition helpers.cpp:425
constexpr size_t format_hex_pretty_size(size_t byte_count)
Calculate buffer size needed for format_hex_pretty_to with separator: "XX:XX:...:XX\0".
Definition helpers.h:1438
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t
uint16_t length
Definition tt21100.cpp:0