diff --git a/CODEOWNERS b/CODEOWNERS index dc567ca5c0..e91116795a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -548,3 +548,4 @@ esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zio_ultrasonic/* @kahrendt +esphome/components/zwave_proxy/* @kbx81 diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 37e4c16bfc..fa64649df5 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -66,6 +66,9 @@ service APIConnection { rpc voice_assistant_set_configuration(VoiceAssistantSetConfiguration) returns (void) {} rpc alarm_control_panel_command (AlarmControlPanelCommandRequest) returns (void) {} + + rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {} + rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} } @@ -254,6 +257,9 @@ message DeviceInfoResponse { // Top-level area info to phase out suggested_area AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; + + // Indicates if Z-Wave proxy support is available and features supported + uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; } message ListEntitiesRequest { @@ -2276,3 +2282,26 @@ message UpdateCommandRequest { UpdateCommand command = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; } + +// ==================== Z-WAVE ==================== + +message ZWaveProxyFrame { + option (id) = 128; + option (source) = SOURCE_BOTH; + option (ifdef) = "USE_ZWAVE_PROXY"; + option (no_delay) = true; + + bytes data = 1 [(fixed_array_size) = 257]; +} + +enum ZWaveProxyRequestType { + ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; + ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; +} +message ZWaveProxyRequest { + option (id) = 129; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_ZWAVE_PROXY"; + + ZWaveProxyRequestType type = 1; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dfcab4bda6..697b02b915 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -30,6 +30,9 @@ #ifdef USE_VOICE_ASSISTANT #include "esphome/components/voice_assistant/voice_assistant.h" #endif +#ifdef USE_ZWAVE_PROXY +#include "esphome/components/zwave_proxy/zwave_proxy.h" +#endif namespace esphome::api { @@ -1203,7 +1206,16 @@ void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetCon voice_assistant::global_voice_assistant->on_set_configuration(msg.active_wake_words); } } +#endif +#ifdef USE_ZWAVE_PROXY +void APIConnection::zwave_proxy_frame(const ZWaveProxyFrame &msg) { + zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); +} + +void APIConnection::zwave_proxy_request(const ZWaveProxyRequest &msg) { + zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type); +} #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -1460,6 +1472,9 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_VOICE_ASSISTANT resp.voice_assistant_feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); #endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); +#endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7d50aa4591..380894a4ff 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -171,6 +171,11 @@ class APIConnection final : public APIServerConnection { void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; #endif +#ifdef USE_ZWAVE_PROXY + void zwave_proxy_frame(const ZWaveProxyFrame &msg) override; + void zwave_proxy_request(const ZWaveProxyRequest &msg) override; +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4f2130466a..58a083ad06 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -129,6 +129,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_AREAS buffer.encode_message(22, this->area); #endif +#ifdef USE_ZWAVE_PROXY + buffer.encode_uint32(23, this->zwave_proxy_feature_flags); +#endif } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD @@ -181,6 +184,9 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_AREAS size.add_message_object(2, this->area); #endif +#ifdef USE_ZWAVE_PROXY + size.add_uint32(2, this->zwave_proxy_feature_flags); +#endif } #ifdef USE_BINARY_SENSOR void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { @@ -3013,5 +3019,35 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #endif +#ifdef USE_ZWAVE_PROXY +bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: { + const std::string &data_str = value.as_string(); + this->data_len = data_str.size(); + if (this->data_len > 257) { + this->data_len = 257; + } + memcpy(this->data, data_str.data(), this->data_len); + break; + } + default: + return false; + } + return true; +} +void ZWaveProxyFrame::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data, this->data_len); } +void ZWaveProxyFrame::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } +bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->type = static_cast(value.as_uint32()); + break; + default: + return false; + } + return true; +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e68fce75aa..d52cb9eab3 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -276,6 +276,12 @@ enum UpdateCommand : uint32_t { UPDATE_COMMAND_CHECK = 2, }; #endif +#ifdef USE_ZWAVE_PROXY +enum ZWaveProxyRequestType : uint32_t { + ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, + ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, +}; +#endif } // namespace enums @@ -492,7 +498,7 @@ class DeviceInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 247; + static constexpr uint8_t ESTIMATED_SIZE = 252; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -552,6 +558,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_AREAS AreaInfo area{}; +#endif +#ifdef USE_ZWAVE_PROXY + uint32_t zwave_proxy_feature_flags{0}; #endif void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; @@ -2913,5 +2922,40 @@ class UpdateCommandRequest final : public CommandProtoMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyFrame final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 128; + static constexpr uint8_t ESTIMATED_SIZE = 33; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "z_wave_proxy_frame"; } +#endif + uint8_t data[257]{}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; +class ZWaveProxyRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 129; + static constexpr uint8_t ESTIMATED_SIZE = 2; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "z_wave_proxy_request"; } +#endif + enums::ZWaveProxyRequestType type{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 222aa2b603..d24f9b3fdc 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -655,6 +655,18 @@ template<> const char *proto_enum_to_string(enums::UpdateC } } #endif +#ifdef USE_ZWAVE_PROXY +template<> const char *proto_enum_to_string(enums::ZWaveProxyRequestType value) { + switch (value) { + case enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: + return "ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE"; + case enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + return "ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"; + default: + return "UNKNOWN"; + } +} +#endif void HelloRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HelloRequest"); @@ -754,6 +766,9 @@ void DeviceInfoResponse::dump_to(std::string &out) const { this->area.dump_to(out); out.append("\n"); #endif +#ifdef USE_ZWAVE_PROXY + dump_field(out, "zwave_proxy_feature_flags", this->zwave_proxy_feature_flags); +#endif } void ListEntitiesRequest::dump_to(std::string &out) const { out.append("ListEntitiesRequest {}"); } void ListEntitiesDoneResponse::dump_to(std::string &out) const { out.append("ListEntitiesDoneResponse {}"); } @@ -2107,6 +2122,18 @@ void UpdateCommandRequest::dump_to(std::string &out) const { #endif } #endif +#ifdef USE_ZWAVE_PROXY +void ZWaveProxyFrame::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ZWaveProxyFrame"); + out.append(" data: "); + out.append(format_hex_pretty(this->data, this->data_len)); + out.append("\n"); +} +void ZWaveProxyRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ZWaveProxyRequest"); + dump_field(out, "type", static_cast(this->type)); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index ef7acbc6b2..4afc66dc44 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -588,6 +588,28 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_bluetooth_scanner_set_mode_request(msg); break; } +#endif +#ifdef USE_ZWAVE_PROXY + case ZWaveProxyFrame::MESSAGE_TYPE: { + ZWaveProxyFrame msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_z_wave_proxy_frame: %s", msg.dump().c_str()); +#endif + this->on_z_wave_proxy_frame(msg); + break; + } +#endif +#ifdef USE_ZWAVE_PROXY + case ZWaveProxyRequest::MESSAGE_TYPE: { + ZWaveProxyRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_z_wave_proxy_request: %s", msg.dump().c_str()); +#endif + this->on_z_wave_proxy_request(msg); + break; + } #endif default: break; @@ -899,5 +921,19 @@ void APIServerConnection::on_alarm_control_panel_command_request(const AlarmCont } } #endif +#ifdef USE_ZWAVE_PROXY +void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { + if (this->check_authenticated_()) { + this->zwave_proxy_frame(msg); + } +} +#endif +#ifdef USE_ZWAVE_PROXY +void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { + if (this->check_authenticated_()) { + this->zwave_proxy_request(msg); + } +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index f81ac1a337..9379dfee7d 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -207,6 +207,12 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_UPDATE virtual void on_update_command_request(const UpdateCommandRequest &value){}; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void on_z_wave_proxy_frame(const ZWaveProxyFrame &value){}; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif protected: void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -335,6 +341,12 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_ALARM_CONTROL_PANEL virtual void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) = 0; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void zwave_proxy_frame(const ZWaveProxyFrame &msg) = 0; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void zwave_proxy_request(const ZWaveProxyRequest &msg) = 0; #endif protected: void on_hello_request(const HelloRequest &msg) override; @@ -459,6 +471,12 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ALARM_CONTROL_PANEL void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override; #endif +#ifdef USE_ZWAVE_PROXY + void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override; +#endif +#ifdef USE_ZWAVE_PROXY + void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; +#endif }; } // namespace esphome::api diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py new file mode 100644 index 0000000000..d88f9f7041 --- /dev/null +++ b/esphome/components/zwave_proxy/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +import esphome.final_validate as fv + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["api", "uart"] + +zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") +ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) + + +def final_validate(config): + full_config = fv.full_config.get() + if (wifi_conf := full_config.get(CONF_WIFI)) and ( + wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" + ): + raise cv.Invalid( + f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" + ) + + return config + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ZWaveProxy), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add_define("USE_ZWAVE_PROXY") diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp new file mode 100644 index 0000000000..e39f857743 --- /dev/null +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -0,0 +1,224 @@ +#include "zwave_proxy.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/util.h" + +namespace esphome { +namespace zwave_proxy { + +static const char *const TAG = "zwave_proxy"; + +ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } + +void ZWaveProxy::loop() { + if (this->response_handler_()) { + ESP_LOGV(TAG, "Handled late response"); + } + if (this->api_connection_ != nullptr && (!this->api_connection_->is_connection_setup() || !api_is_connected())) { + ESP_LOGW(TAG, "Subscriber disconnected"); + this->api_connection_ = nullptr; // Unsubscribe if disconnected + } + + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) { + this->status_set_warning("UART read failed"); + return; + } + if (this->parse_byte_(byte)) { + ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); + if (this->api_connection_ != nullptr) { + // minimize copying to reduce CPU overhead + if (this->in_bootloader_) { + this->outgoing_proto_msg_.data_len = this->buffer_index_; + } else { + // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN + this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; + } + std::memcpy(this->outgoing_proto_msg_.data, this->buffer_, this->outgoing_proto_msg_.data_len); + this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); + } + } + } + this->status_clear_warning(); +} + +void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } + +void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { + switch (type) { + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: + if (this->api_connection_ != nullptr) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + this->api_connection_ = api_connection; + ESP_LOGV(TAG, "API connection is now subscribed"); + break; + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + if (this->api_connection_ != api_connection) { + ESP_LOGV(TAG, "API connection is not subscribed"); + return; + } + this->api_connection_ = nullptr; + break; + default: + ESP_LOGW(TAG, "Unknown request type: %d", type); + break; + } +} + +void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { + if (length == 1 && data[0] == this->last_response_) { + ESP_LOGV(TAG, "Skipping sending duplicate response: 0x%02X", data[0]); + return; + } + ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty(data, length).c_str()); + this->write_array(data, length); +} + +bool ZWaveProxy::parse_byte_(uint8_t byte) { + bool frame_completed = false; + // Basic parsing logic for received frames + switch (this->parsing_state_) { + case ZWAVE_PARSING_STATE_WAIT_START: + this->parse_start_(byte); + break; + case ZWAVE_PARSING_STATE_WAIT_LENGTH: + if (!byte) { + ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + return false; + } + ESP_LOGVV(TAG, "Received LENGTH: %u", byte); + this->end_frame_after_ = this->buffer_index_ + byte; + ESP_LOGVV(TAG, "Calculated EOF: %u", this->end_frame_after_); + this->buffer_[this->buffer_index_++] = byte; + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_TYPE; + break; + case ZWAVE_PARSING_STATE_WAIT_TYPE: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received TYPE: 0x%02X", byte); + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_COMMAND_ID; + break; + case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + break; + case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: + this->buffer_[this->buffer_index_++] = byte; + this->checksum_ ^= byte; + ESP_LOGVV(TAG, "Received PAYLOAD: 0x%02X", byte); + if (this->buffer_index_ >= this->end_frame_after_) { + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_CHECKSUM; + } + break; + case ZWAVE_PARSING_STATE_WAIT_CHECKSUM: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received CHECKSUM: 0x%02X", byte); + ESP_LOGV(TAG, "Calculated CHECKSUM: 0x%02X", this->checksum_); + if (this->checksum_ != byte) { + ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", this->checksum_, byte); + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + } else { + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; + ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(this->buffer_, this->buffer_index_).c_str()); + frame_completed = true; + } + this->response_handler_(); + break; + case ZWAVE_PARSING_STATE_READ_BL_MENU: + this->buffer_[this->buffer_index_++] = byte; + if (!byte) { + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + frame_completed = true; + } + break; + case ZWAVE_PARSING_STATE_SEND_ACK: + case ZWAVE_PARSING_STATE_SEND_NAK: + break; // Should not happen, handled in loop() + default: + ESP_LOGW(TAG, "Bad parsing state; resetting"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + break; + } + return frame_completed; +} + +void ZWaveProxy::parse_start_(uint8_t byte) { + this->buffer_index_ = 0; + this->checksum_ = 0xFF; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + switch (byte) { + case ZWAVE_FRAME_TYPE_START: + ESP_LOGVV(TAG, "Received START"); + if (this->in_bootloader_) { + ESP_LOGD(TAG, "Exited bootloader mode"); + this->in_bootloader_ = false; + } + this->buffer_[this->buffer_index_++] = byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; + return; + case ZWAVE_FRAME_TYPE_BL_MENU: + ESP_LOGVV(TAG, "Received BL_MENU"); + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + } + this->buffer_[this->buffer_index_++] = byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; + return; + case ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD: + ESP_LOGVV(TAG, "Received BL_BEGIN_UPLOAD"); + break; + case ZWAVE_FRAME_TYPE_ACK: + ESP_LOGVV(TAG, "Received ACK"); + break; + case ZWAVE_FRAME_TYPE_NAK: + ESP_LOGW(TAG, "Received NAK"); + break; + case ZWAVE_FRAME_TYPE_CAN: + ESP_LOGW(TAG, "Received CAN"); + break; + default: + ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + return; + } + // Forward response (ACK/NAK/CAN) back to client for processing + if (this->api_connection_ != nullptr) { + this->outgoing_proto_msg_.data[0] = byte; + this->outgoing_proto_msg_.data_len = 1; + this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); + } +} + +bool ZWaveProxy::response_handler_() { + switch (this->parsing_state_) { + case ZWAVE_PARSING_STATE_SEND_ACK: + this->last_response_ = ZWAVE_FRAME_TYPE_ACK; + break; + case ZWAVE_PARSING_STATE_SEND_CAN: + this->last_response_ = ZWAVE_FRAME_TYPE_CAN; + break; + case ZWAVE_PARSING_STATE_SEND_NAK: + this->last_response_ = ZWAVE_FRAME_TYPE_NAK; + break; + default: + return false; // No response handled + } + + ESP_LOGVV(TAG, "Sending %s (0x%02X)", this->last_response_ == ZWAVE_FRAME_TYPE_ACK ? "ACK" : "NAK/CAN", + this->last_response_); + this->write_byte(this->last_response_); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + return true; +} + +ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace zwave_proxy +} // namespace esphome diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h new file mode 100644 index 0000000000..a0f25849e4 --- /dev/null +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -0,0 +1,73 @@ +#pragma once + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +namespace esphome { +namespace zwave_proxy { + +enum ZWaveResponseTypes : uint8_t { + ZWAVE_FRAME_TYPE_ACK = 0x06, + ZWAVE_FRAME_TYPE_CAN = 0x18, + ZWAVE_FRAME_TYPE_NAK = 0x15, + ZWAVE_FRAME_TYPE_START = 0x01, + ZWAVE_FRAME_TYPE_BL_MENU = 0x0D, + ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD = 0x43, +}; + +enum ZWaveParsingState : uint8_t { + ZWAVE_PARSING_STATE_WAIT_START, + ZWAVE_PARSING_STATE_WAIT_LENGTH, + ZWAVE_PARSING_STATE_WAIT_TYPE, + ZWAVE_PARSING_STATE_WAIT_COMMAND_ID, + ZWAVE_PARSING_STATE_WAIT_PAYLOAD, + ZWAVE_PARSING_STATE_WAIT_CHECKSUM, + ZWAVE_PARSING_STATE_SEND_ACK, + ZWAVE_PARSING_STATE_SEND_CAN, + ZWAVE_PARSING_STATE_SEND_NAK, + ZWAVE_PARSING_STATE_READ_BL_MENU, +}; + +enum ZWaveProxyFeature : uint32_t { + FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, +}; + +class ZWaveProxy : public uart::UARTDevice, public Component { + public: + ZWaveProxy(); + + void loop() override; + void dump_config() override; + + void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type); + api::APIConnection *get_api_connection() { return this->api_connection_; } + + uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; } + + void send_frame(const uint8_t *data, size_t length); + + protected: + bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) + void parse_start_(uint8_t byte); + bool response_handler_(); + + api::APIConnection *api_connection_{nullptr}; // Current subscribed client + + uint8_t buffer_[sizeof(api::ZWaveProxyFrame::data)]; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t checksum_{0}; // Checksum of the frame being parsed + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent + ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; + bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + + // Pre-allocated message - always ready to send + api::ZWaveProxyFrame outgoing_proto_msg_; +}; + +extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace zwave_proxy +} // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6e8d5ed74c..bdc59598f6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -100,6 +100,7 @@ #define USE_UART_DEBUGGER #define USE_UPDATE #define USE_VALVE +#define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr #ifndef USE_ZEPHYR diff --git a/tests/components/zwave_proxy/common.yaml b/tests/components/zwave_proxy/common.yaml new file mode 100644 index 0000000000..08092ebe55 --- /dev/null +++ b/tests/components/zwave_proxy/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +uart: + - id: uart_zwave_proxy + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 115200 + +api: + +zwave_proxy: + id: zw_proxy diff --git a/tests/components/zwave_proxy/test.esp32-ard.yaml b/tests/components/zwave_proxy/test.esp32-ard.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-c3-ard.yaml b/tests/components/zwave_proxy/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-c3-idf.yaml b/tests/components/zwave_proxy/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-idf.yaml b/tests/components/zwave_proxy/test.esp32-idf.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp8266-ard.yaml b/tests/components/zwave_proxy/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.rp2040-ard.yaml b/tests/components/zwave_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml