diff --git a/.github/workflows/esphome.yml b/.github/workflows/esphome.yml index 4902be0b..402d2eba 100644 --- a/.github/workflows/esphome.yml +++ b/.github/workflows/esphome.yml @@ -13,6 +13,7 @@ jobs: outputs: matrix_json: ${{ steps.set-matrix.outputs.matrix_json }} run_compile: ${{ steps.set-matrix.outputs.run_compile }} + run_parser_native_test: ${{ steps.set-matrix.outputs.run_parser_native_test }} steps: - name: Checkout repository uses: actions/checkout@v5 @@ -44,6 +45,12 @@ jobs: $1 == "tests" && $2 == "components" { print $3 } ' | sort -u) + RUN_PARSER_NATIVE_TEST=false + if echo "$CHANGED_FILES" | grep -qE '^(components/uartex/|tests/components/uartex/|tests/native/uartex_parser/)'; then + RUN_PARSER_NATIVE_TEST=true + fi + echo "run_parser_native_test=$RUN_PARSER_NATIVE_TEST" >> $GITHUB_OUTPUT + # Build a JSON array of configuration files to test # Each item has: {"component": "comp_name", "file": "test..yaml"} MATRIX_JSON="[]" @@ -145,3 +152,16 @@ jobs: - name: Compile ESPHome Config run: | esphome compile "tests/components/${{ matrix.component }}/${{ matrix.file }}" + + parser-native-test: + needs: detect-changes + if: ${{ needs.detect-changes.outputs.run_parser_native_test == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Build and run parser native tests + run: | + chmod +x tests/native/uartex_parser/run.sh + tests/native/uartex_parser/run.sh diff --git a/components/uartex/__init__.py b/components/uartex/__init__.py index 066d152e..394349fc 100644 --- a/components/uartex/__init__.py +++ b/components/uartex/__init__.py @@ -9,7 +9,7 @@ from .const import CONF_RX_HEADER, CONF_RX_FOOTER, CONF_TX_HEADER, CONF_TX_FOOTER, \ CONF_RX_CHECKSUM, CONF_TX_CHECKSUM, CONF_RX_CHECKSUM_2, CONF_TX_CHECKSUM_2, \ CONF_UARTEX_ID, CONF_ERROR, CONF_LOG, CONF_ON_TX_TIMEOUT, CONF_RX_PRIORITY, \ - CONF_ACK, CONF_ON_WRITE, CONF_ON_READ, \ + CONF_ACK, CONF_ON_WRITE, CONF_ON_READ, CONF_RX_REPLY, CONF_COMMAND, \ CONF_STATE, CONF_MASK, CONF_MATCH, \ CONF_STATE_ON, CONF_STATE_OFF, CONF_COMMAND_ON, CONF_COMMAND_OFF, \ CONF_COMMAND_UPDATE, CONF_RX_TIMEOUT, CONF_TX_TIMEOUT, CONF_TX_RETRY_CNT, CONF_TX_COMMAND_QUEUE_SIZE, \ @@ -139,6 +139,17 @@ def header_schema(value): return HEADER_SCHEMA(value) return shorthand_header(value) +def validate_rx_header(value): + if isinstance(value, dict): + return [header_schema(value)] + if isinstance(value, list): + if not value: + raise cv.Invalid("rx_header cannot be empty") + if isinstance(value[0], (list, dict)): + return [header_schema(h) for h in value] + return [header_schema(value)] + return [header_schema(value)] + COMMAND_SCHEMA = cv.Schema({ cv.Required(CONF_DATA): validate_hex_data, cv.Optional(CONF_ACK, default=[]): validate_hex_data, @@ -164,6 +175,11 @@ def command_hex_schema(value): def rx_data_length_schema(value): return RX_DATA_LENGTH_SCHEMA(value) +RX_REPLY_SCHEMA = cv.Schema({ + cv.Required(CONF_STATE): state_schema, + cv.Required(CONF_COMMAND): cv.templatable(command_hex_schema), +}) + # UARTEx Schema CONFIG_SCHEMA = cv.All(cv.Schema({ cv.GenerateID(): cv.declare_id(UARTExComponent), @@ -199,8 +215,9 @@ def rx_data_length_schema(value): cv.Optional(CONF_RX_LENGTH): cv.int_range(min=1, max=256), cv.Optional(CONF_RX_DATA_LENGTH): rx_data_length_schema, cv.Optional(CONF_TX_CTRL_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_RX_HEADER): header_schema, + cv.Optional(CONF_RX_HEADER): validate_rx_header, cv.Optional(CONF_RX_FOOTER): validate_hex_data, + cv.Optional(CONF_RX_REPLY): cv.ensure_list(RX_REPLY_SCHEMA), cv.Optional(CONF_TX_HEADER): validate_hex_data, cv.Optional(CONF_TX_FOOTER): validate_hex_data, cv.Optional(CONF_RX_CHECKSUM): validate_checksum, @@ -296,8 +313,19 @@ async def to_code(config): cg.add(var.set_tx_ctrl_pin(tx_ctrl_pin)) if CONF_RX_HEADER in config: - header = header_hex_expression(config[CONF_RX_HEADER]) - cg.add(var.set_rx_header(header)) + for h in config[CONF_RX_HEADER]: + header = header_hex_expression(h) + cg.add(var.add_rx_header(header)) + + for reply in config.get(CONF_RX_REPLY, []): + state = state_hex_expression(reply[CONF_STATE]) + command_conf = reply[CONF_COMMAND] + if cg.is_template(command_conf): + template_ = await cg.templatable(command_conf, [(uint8_ptr_const, 'data'), (uint16_const, 'len')], cmd_t) + cg.add(var.add_rx_reply(state, template_)) + else: + command = command_hex_expression(command_conf) + cg.add(var.add_rx_reply(state, command)) if CONF_RX_FOOTER in config: cg.add(var.set_rx_footer(config[CONF_RX_FOOTER])) @@ -311,7 +339,10 @@ async def to_code(config): if CONF_RX_CHECKSUM in config: data = config[CONF_RX_CHECKSUM] if cg.is_template(data): - template_ = await cg.templatable(data, [(uint8_ptr_const, 'data'), (uint16_const, 'len')], cg.uint8) + template_ = await cg.templatable(data, [ + (uint8_ptr_const, 'data'), (uint16_const, 'len'), + (uint8_ptr_const, 'header'), (uint16_const, 'header_len'), + ], cg.uint8) cg.add(var.set_rx_checksum(template_)) else: cg.add(var.set_rx_checksum(data)) diff --git a/components/uartex/checksum.cpp b/components/uartex/checksum.cpp new file mode 100644 index 00000000..1177c871 --- /dev/null +++ b/components/uartex/checksum.cpp @@ -0,0 +1,48 @@ +#include "checksum.h" + +namespace esphome { +namespace uartex { + +uint16_t compute_checksum(CHECKSUM checksum, const std::vector &header, const std::vector &data) +{ + uint16_t crc = 0; + uint8_t temp = 0; + switch (checksum) + { + case CHECKSUM_XOR: + for (uint8_t byte : header) { crc ^= byte; } + for (uint8_t byte : data) { crc ^= byte; } + break; + case CHECKSUM_ADD: + for (uint8_t byte : header) { crc += byte; } + for (uint8_t byte : data) { crc += byte; } + break; + case CHECKSUM_XOR_NO_HEADER: + for (uint8_t byte : data) { crc ^= byte; } + break; + case CHECKSUM_ADD_NO_HEADER: + for (uint8_t byte : data) { crc += byte; } + break; + case CHECKSUM_XOR_ADD: + for (uint8_t byte : header) + { + crc += byte; + temp ^= byte; + } + for (uint8_t byte : data) + { + crc += byte; + temp ^= byte; + } + crc += temp; + crc = ((uint16_t) temp << 8) | (crc & 0xFF); + break; + case CHECKSUM_NONE: + case CHECKSUM_CUSTOM: + break; + } + return crc; +} + +} // namespace uartex +} // namespace esphome diff --git a/components/uartex/checksum.h b/components/uartex/checksum.h new file mode 100644 index 00000000..b50f2bba --- /dev/null +++ b/components/uartex/checksum.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include + +namespace esphome { +namespace uartex { + +enum CHECKSUM { + CHECKSUM_NONE, + CHECKSUM_CUSTOM, + CHECKSUM_XOR, + CHECKSUM_ADD, + CHECKSUM_XOR_NO_HEADER, + CHECKSUM_ADD_NO_HEADER, + CHECKSUM_XOR_ADD +}; + +uint16_t compute_checksum(CHECKSUM checksum, const std::vector &header, const std::vector &data); + +} // namespace uartex +} // namespace esphome diff --git a/components/uartex/const.py b/components/uartex/const.py index ebc5bcd2..2f032af6 100644 --- a/components/uartex/const.py +++ b/components/uartex/const.py @@ -176,6 +176,8 @@ CONF_SPEED_CNT = 'speed_cnt' CONF_ON_WRITE = 'on_write' CONF_ON_READ = 'on_read' +CONF_RX_REPLY = 'rx_reply' +CONF_COMMAND = 'command' # Water Heater Commands CONF_COMMAND_ECO = 'command_eco' diff --git a/components/uartex/parser.cpp b/components/uartex/parser.cpp index 2faa243e..14918c0f 100644 --- a/components/uartex/parser.cpp +++ b/components/uartex/parser.cpp @@ -13,27 +13,35 @@ Parser::~Parser() bool Parser::add_header(const unsigned char header) { - header_.push_back(header); - return true; + return this->add_header_candidate({header}); } bool Parser::add_headers(const std::vector& header) { - if (header.empty()) return false; - header_.insert(header_.end(), header.begin(), header.end()); - return true; + return this->add_header_candidate(header); } bool Parser::add_header_mask(const unsigned char mask) { - header_mask_.push_back(mask); + if (this->header_candidates_.empty()) + this->header_candidates_.push_back({}); + this->header_candidates_.back().mask.push_back(mask); return true; } bool Parser::add_header_masks(const std::vector& mask) { - if (mask.empty()) return false; - header_mask_.insert(header_mask_.end(), mask.begin(), mask.end()); + if (this->header_candidates_.empty()) + this->header_candidates_.push_back({}); + auto& candidate = this->header_candidates_.back(); + candidate.mask.insert(candidate.mask.end(), mask.begin(), mask.end()); + return true; +} + +bool Parser::add_header_candidate(const std::vector& header, const std::vector& mask) +{ + if (header.empty()) return false; + this->header_candidates_.push_back({header, mask}); return true; } @@ -50,6 +58,14 @@ bool Parser::add_footers(const std::vector& footer) return true; } +bool Parser::header_prefix_matches(const std::vector& buffer, const header_candidate_t& candidate) const +{ + if (candidate.data.empty() || buffer.empty()) return false; + std::vector masked_buffer = apply_mask(buffer, candidate.mask); + size_t size = masked_buffer.size() < candidate.data.size() ? masked_buffer.size() : candidate.data.size(); + return std::equal(masked_buffer.begin(), masked_buffer.begin() + size, candidate.data.begin()); +} + bool Parser::parse_byte(const unsigned char byte) { buffer_.push_back(byte); @@ -60,6 +76,8 @@ bool Parser::parse_byte(const unsigned char byte) if (parse_header() == false) { buffer_.clear(); + matched_header_index_ = -1; + active_header_.clear(); return false; } if (parse_footer() == true) return true; @@ -76,31 +94,44 @@ bool Parser::verify_checksum(const std::vector& checksum) bool Parser::has_header() { - if (header_.empty()) return false; - return true; + return !header_candidates_.empty(); } bool Parser::has_footer() { - if (footer_.empty()) return false; - return true; + return !footer_.empty(); } void Parser::clear() { buffer_.clear(); dynamic_total_len_ = 0; + matched_header_index_ = -1; + active_header_.clear(); } bool Parser::parse_header() { - if (header_.empty()) return true; - std::vector masked_buffer = apply_mask(buffer_, header_mask_); - size_t size = masked_buffer.size() < header_.size() ? masked_buffer.size() : header_.size(); - return std::equal(masked_buffer.begin(), masked_buffer.begin() + size, header_.begin()); + if (header_candidates_.empty()) return true; + + if (matched_header_index_ >= 0) + { + return this->header_prefix_matches(buffer_, header_candidates_[matched_header_index_]); + } + + for (size_t i = 0; i < header_candidates_.size(); i++) + { + if (this->header_prefix_matches(buffer_, header_candidates_[i])) + { + matched_header_index_ = (int) i; + active_header_ = header_candidates_[i].data; + return true; + } + } + return false; } -const std::vector Parser::apply_mask(const std::vector& data, const std::vector& mask) +const std::vector Parser::apply_mask(const std::vector& data, const std::vector& mask) const { if (mask.empty()) return data; std::vector masked_data = data; @@ -116,20 +147,18 @@ bool Parser::parse_footer() if (footer_.empty()) return false; if (buffer_.size() < footer_.size()) return false; if (total_len_ > 0 && buffer_.size() != total_len_) return false; - - // Dynamic data length mode: don't match footer until we have enough bytes + if (has_data_length_) { if (!calculate_dynamic_length()) return false; if (buffer_.size() < dynamic_total_len_) return false; } - + return std::equal(buffer_.end() - footer_.size(), buffer_.end(), footer_.begin()); } bool Parser::parse_length() { - // Fixed total length mode if (total_len_ > 0) { if (footer_.size() > 0) return false; @@ -137,27 +166,24 @@ bool Parser::parse_length() if (checksum_len_ > 0) return false; return true; } - - // Dynamic data length mode + if (!has_data_length_) return false; if (!calculate_dynamic_length()) return false; if (buffer_.size() < dynamic_total_len_) return false; - + return true; } bool Parser::calculate_dynamic_length() { - // Check minimum required size to read length field - size_t min_size = header_.size() + data_length_offset_ + data_length_size_; + size_t min_size = active_header_.size() + data_length_offset_ + data_length_size_; if (buffer_.size() < min_size) return false; - - // Calculate dynamic total length if not yet calculated + if (dynamic_total_len_ == 0) { - size_t length_pos = header_.size() + data_length_offset_; + size_t length_pos = active_header_.size() + data_length_offset_; uint32_t data_len = 0; - + if (data_length_big_endian_) { for (uint8_t i = 0; i < data_length_size_; i++) @@ -172,23 +198,22 @@ bool Parser::calculate_dynamic_length() data_len = (data_len << 8) | buffer_[length_pos + i]; } } - - dynamic_total_len_ = header_.size() + data_length_offset_ + data_length_size_ + data_len + data_length_adjust_ + checksum_len_ + footer_.size(); + + dynamic_total_len_ = active_header_.size() + data_length_offset_ + data_length_size_ + data_len + data_length_adjust_ + checksum_len_ + footer_.size(); } - + return true; } bool Parser::available() { - if (buffer_.empty()) return false; - return true; + return !buffer_.empty(); } const std::vector Parser::header() { - if (header_.empty()) return {}; - size_t header_size = header_.size(); + if (active_header_.empty()) return {}; + size_t header_size = active_header_.size(); if (buffer_.size() < header_size) header_size = buffer_.size(); return std::vector(buffer_.begin(), buffer_.begin() + header_size); } @@ -196,8 +221,8 @@ const std::vector Parser::header() const std::vector Parser::data(const std::vector& mask) { size_t offset = checksum_len_; - if (buffer_.size() < header_.size() + footer_.size() + offset) return {}; - return apply_mask(std::vector(buffer_.begin() + header_.size(), buffer_.end() - footer_.size() - offset), mask); + if (buffer_.size() < active_header_.size() + footer_.size() + offset) return {}; + return apply_mask(std::vector(buffer_.begin() + active_header_.size(), buffer_.end() - footer_.size() - offset), mask); } const std::vector Parser::buffer() @@ -228,4 +253,4 @@ void Parser::set_data_length(uint8_t offset, uint8_t length, bool big_endian, in data_length_size_ = length; data_length_big_endian_ = big_endian; data_length_adjust_ = adjust; -} \ No newline at end of file +} diff --git a/components/uartex/parser.h b/components/uartex/parser.h index 4e65dfbd..b20cd792 100644 --- a/components/uartex/parser.h +++ b/components/uartex/parser.h @@ -1,8 +1,6 @@ #pragma once -#include +#include #include -#include -#include class Parser { @@ -13,6 +11,7 @@ class Parser bool add_headers(const std::vector& header); bool add_header_mask(const unsigned char mask); bool add_header_masks(const std::vector& mask); + bool add_header_candidate(const std::vector& header, const std::vector& mask = {}); bool add_footer(const unsigned char footer); bool add_footers(const std::vector& footer); bool parse_byte(const unsigned char byte); @@ -23,7 +22,7 @@ class Parser const std::vector header(); const std::vector data(const std::vector& mask = {}); const std::vector buffer(); - const std::vector apply_mask(const std::vector& data, const std::vector& mask); + const std::vector apply_mask(const std::vector& data, const std::vector& mask) const; bool parse_header(); bool parse_footer(); bool parse_length(); @@ -33,15 +32,21 @@ class Parser void set_buffer_len(size_t len); void set_data_length(uint8_t offset, uint8_t length, bool big_endian, int8_t adjust); private: + struct header_candidate_t + { + std::vector data; + std::vector mask; + }; + bool header_prefix_matches(const std::vector& buffer, const header_candidate_t& candidate) const; bool calculate_dynamic_length(); - std::vector header_; - std::vector header_mask_; + std::vector header_candidates_; + std::vector active_header_; + int matched_header_index_{-1}; std::vector footer_; std::vector buffer_; size_t checksum_len_; size_t total_len_; size_t buffer_len_; - // Dynamic data length parsing bool has_data_length_{false}; uint8_t data_length_offset_{0}; uint8_t data_length_size_{1}; @@ -49,4 +54,3 @@ class Parser int8_t data_length_adjust_{0}; size_t dynamic_total_len_{0}; }; - diff --git a/components/uartex/uartex.cpp b/components/uartex/uartex.cpp index 1b0b7a85..5d4bcf55 100644 --- a/components/uartex/uartex.cpp +++ b/components/uartex/uartex.cpp @@ -17,8 +17,14 @@ void UARTExComponent::dump_config() log_config(TAG, "tx_timeout", this->conf_tx_timeout_); log_config(TAG, "tx_delay", this->conf_tx_delay_); log_config(TAG, "tx_retry_cnt", this->conf_tx_retry_cnt_); - if (this->rx_header_.has_value()) log_config(TAG, "rx_header", this->rx_header_.value().data); - if (this->rx_header_.has_value()) log_config(TAG, "rx_header mask", this->rx_header_.value().mask); + if (!this->rx_headers_.empty()) + { + for (auto& h : this->rx_headers_) + { + log_config(TAG, "rx_header", h.data); + log_config(TAG, "rx_header mask", h.mask); + } + } if (this->rx_footer_.has_value()) log_config(TAG, "rx_footer", this->rx_footer_.value()); if (this->tx_header_.has_value()) log_config(TAG, "tx_header", this->tx_header_.value()); if (this->tx_footer_.has_value()) log_config(TAG, "tx_footer", this->tx_footer_.value()); @@ -43,10 +49,9 @@ void UARTExComponent::setup() this->rx_time_ = get_time(); this->tx_time_ = get_time(); this->rx_timer_ = get_time(); - if (this->rx_header_.has_value()) + for (auto& h : this->rx_headers_) { - this->rx_parser_.add_headers(this->rx_header_.value().data); - this->rx_parser_.add_header_masks(this->rx_header_.value().mask); + this->rx_parser_.add_header_candidate(h.data, h.mask); } if (this->rx_footer_.has_value()) this->rx_parser_.add_footers(this->rx_footer_.value()); this->rx_parser_.set_total_len(this->conf_rx_length_); @@ -123,7 +128,12 @@ void UARTExComponent::publish_to_devices() if (!this->rx_parser_.available()) return; if (!verify_data()) return; verify_ack(); + process_rx_reply(); publish_data(); + if (!this->tx_queue_reply_.empty() && !is_tx_cmd_pending()) + { + write_tx_data(); + } this->rx_time_ = get_time(); } @@ -136,6 +146,31 @@ bool UARTExComponent::verify_ack() return true; } +void UARTExComponent::process_rx_reply() +{ + if (this->rx_reply_.empty()) return; + auto& data = this->rx_parser_.data(); + if (data.empty()) return; + for (auto& entry : this->rx_reply_) + { + if (!verify_state(data, &entry.state)) continue; + cmd_t cmd; + if (entry.command_f.has_value()) + { + cmd = (*entry.command_f)(&data[0], data.size()); + } + else + { + cmd = entry.command; + } + if (cmd.data.empty()) break; + auto owned = std::make_shared(std::move(cmd)); + enqueue_tx_reply({nullptr, owned.get(), owned}); + ESP_LOGD(TAG, "Rx reply: %s", to_hex_string(owned->data).c_str()); + break; + } +} + void UARTExComponent::publish_data() { auto& data = this->rx_parser_.data(); @@ -191,7 +226,13 @@ bool UARTExComponent::retry_tx_data() void UARTExComponent::write_tx_data() { dequeue_tx_data_from_devices(); - if (!this->tx_queue_.empty()) + if (!this->tx_queue_reply_.empty()) + { + this->current_tx_data_ = this->tx_queue_reply_.front(); + this->tx_queue_reply_.pop(); + write_tx_cmd(); + } + else if (!this->tx_queue_.empty()) { this->current_tx_data_ = this->tx_queue_.front(); this->tx_queue_.pop(); @@ -251,6 +292,11 @@ void UARTExComponent::enqueue_tx_data(const tx_data_t data, bool low_priority) else this->tx_queue_.push(data); } +void UARTExComponent::enqueue_tx_reply(const tx_data_t data) +{ + this->tx_queue_reply_.push(data); +} + void UARTExComponent::write_command(cmd_t cmd) { // Own the command so its address stays valid (and unique) from enqueue @@ -345,7 +391,7 @@ ERROR UARTExComponent::validate_data() { return ERROR_SIZE; } - if (this->rx_header_.has_value() && this->rx_parser_.parse_header() == false) + if (!this->rx_headers_.empty() && this->rx_parser_.parse_header() == false) { return ERROR_HEADER; } @@ -439,9 +485,19 @@ void UARTExComponent::publish_log(std::string msg) } } -void UARTExComponent::set_rx_header(header_t header) +void UARTExComponent::add_rx_header(header_t header) +{ + this->rx_headers_.push_back(header); +} + +void UARTExComponent::add_rx_reply(state_t state, cmd_t command) { - this->rx_header_ = header; + this->rx_reply_.push_back({state, command, {}}); +} + +void UARTExComponent::add_rx_reply(state_t state, std::function &&command_f) +{ + this->rx_reply_.push_back({state, {}, std::move(command_f)}); } void UARTExComponent::set_rx_footer(std::vector footer) @@ -464,7 +520,7 @@ void UARTExComponent::set_rx_checksum(CHECKSUM checksum) this->rx_checksum_ = checksum; } -void UARTExComponent::set_rx_checksum(std::function &&f) +void UARTExComponent::set_rx_checksum(std::function &&f) { this->rx_checksum_f_ = f; this->rx_checksum_ = CHECKSUM_CUSTOM; @@ -512,7 +568,9 @@ std::vector UARTExComponent::get_rx_checksum(const std::vector { if (this->rx_checksum_f_.has_value()) { - uint8_t crc = (*this->rx_checksum_f_)(&data[0], data.size()); + auto& hdr = header; + uint8_t crc = (*this->rx_checksum_f_)(&data[0], data.size(), + hdr.empty() ? nullptr : &hdr[0], hdr.size()); return { crc }; } else if (this->rx_checksum_f_2_.has_value()) @@ -523,12 +581,12 @@ std::vector UARTExComponent::get_rx_checksum(const std::vector { if (this->rx_checksum_ != CHECKSUM_NONE) { - uint8_t crc = get_checksum(this->rx_checksum_, header, data) & 0xFF; + uint8_t crc = compute_checksum(this->rx_checksum_, header, data) & 0xFF; return { crc }; } else if (this->rx_checksum_2_ != CHECKSUM_NONE) { - uint16_t crc = get_checksum(this->rx_checksum_2_, header, data); + uint16_t crc = compute_checksum(this->rx_checksum_2_, header, data); return { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFF) }; } } @@ -551,59 +609,17 @@ std::vector UARTExComponent::get_tx_checksum(const std::vector std::vector header = this->tx_header_.value_or(std::vector{}); if (this->tx_checksum_ != CHECKSUM_NONE) { - uint8_t crc = get_checksum(this->tx_checksum_, header, data) & 0xFF; + uint8_t crc = compute_checksum(this->tx_checksum_, header, data) & 0xFF; return { crc }; } else if (this->tx_checksum_2_ != CHECKSUM_NONE) { - uint16_t crc = get_checksum(this->tx_checksum_2_, header, data); + uint16_t crc = compute_checksum(this->tx_checksum_2_, header, data); return { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFF) }; } } return {}; } -uint16_t UARTExComponent::get_checksum(CHECKSUM checksum, const std::vector &header, const std::vector &data) -{ - uint16_t crc = 0; - uint8_t temp = 0; - switch(checksum) - { - case CHECKSUM_XOR: - for (uint8_t byte : header) { crc ^= byte; } - for (uint8_t byte : data) { crc ^= byte; } - break; - case CHECKSUM_ADD: - for (uint8_t byte : header) { crc += byte; } - for (uint8_t byte : data) { crc += byte; } - break; - case CHECKSUM_XOR_NO_HEADER: - for (uint8_t byte : data) { crc ^= byte; } - break; - case CHECKSUM_ADD_NO_HEADER: - for (uint8_t byte : data) { crc += byte; } - break; - case CHECKSUM_XOR_ADD: - for (uint8_t byte : header) - { - crc += byte; - temp ^= byte; - } - for (uint8_t byte : data) - { - crc += byte; - temp ^= byte; - } - // High byte = XOR of all bytes, low byte = (ADD + XOR) & 0xFF. - crc += temp; - crc = ((uint16_t)temp << 8) | (crc & 0xFF); - break; - case CHECKSUM_NONE: - case CHECKSUM_CUSTOM: - break; - } - return crc; -} - } // namespace uartex } // namespace esphome \ No newline at end of file diff --git a/components/uartex/uartex.h b/components/uartex/uartex.h index 3b7fa79e..92c40d9b 100644 --- a/components/uartex/uartex.h +++ b/components/uartex/uartex.h @@ -5,6 +5,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "uartex_device.h" #include "parser.h" +#include "checksum.h" #include "version.h" namespace esphome { namespace uartex { @@ -19,16 +20,6 @@ enum ERROR { ERROR_TX_TIMEOUT }; -enum CHECKSUM { - CHECKSUM_NONE, - CHECKSUM_CUSTOM, - CHECKSUM_XOR, - CHECKSUM_ADD, - CHECKSUM_XOR_NO_HEADER, - CHECKSUM_ADD_NO_HEADER, - CHECKSUM_XOR_ADD -}; - enum PRIORITY { PRIORITY_DATA, PRIORITY_LOOP @@ -58,16 +49,25 @@ struct rx_data_length_t int8_t adjust{0}; }; +struct rx_reply_t +{ + state_t state; + cmd_t command; + optional> command_f{}; +}; + class UARTExComponent : public uart::UARTDevice, public Component { public: UARTExComponent() = default; - void set_rx_header(header_t header); + void add_rx_header(header_t header); + void add_rx_reply(state_t state, cmd_t command); + void add_rx_reply(state_t state, std::function &&command_f); void set_rx_footer(std::vector footer); void set_tx_header(std::vector header); void set_tx_footer(std::vector footer); void set_rx_checksum(CHECKSUM checksum); - void set_rx_checksum(std::function &&f); + void set_rx_checksum(std::function &&f); void set_tx_checksum(CHECKSUM checksum); void set_tx_checksum(std::function &&f); void set_rx_checksum_2(CHECKSUM checksum); @@ -101,6 +101,7 @@ class UARTExComponent : public uart::UARTDevice, public Component void set_rx_timeout(uint16_t timeout); void set_tx_ctrl_pin(InternalGPIOPin *pin); void enqueue_tx_data(const tx_data_t data, bool low_priority = false); + void enqueue_tx_reply(const tx_data_t data); void write_command(cmd_t cmd); protected: bool is_tx_cmd_pending(); @@ -118,12 +119,12 @@ class UARTExComponent : public uart::UARTDevice, public Component bool parse_bytes(); void publish_to_devices(); bool verify_ack(); + void process_rx_reply(); void publish_data(); void write_to_uart(); bool retry_tx_data(); void write_tx_data(); void dequeue_tx_data_from_devices(); - uint16_t get_checksum(CHECKSUM checksum, const std::vector &header, const std::vector &data); protected: std::vector devices_{}; uint16_t conf_rx_timeout_{10}; @@ -133,14 +134,15 @@ class UARTExComponent : public uart::UARTDevice, public Component uint16_t conf_tx_command_queue_size_{10}; uint16_t conf_rx_length_{0}; optional conf_rx_data_length_{}; - optional rx_header_{}; + std::vector rx_headers_{}; + std::vector rx_reply_{}; optional> rx_footer_{}; optional> tx_header_{}; optional> tx_footer_{}; PRIORITY rx_priority_{PRIORITY_DATA}; CHECKSUM rx_checksum_{CHECKSUM_NONE}; CHECKSUM tx_checksum_{CHECKSUM_NONE}; - optional> rx_checksum_f_{}; + optional> rx_checksum_f_{}; optional> tx_checksum_f_{}; CHECKSUM rx_checksum_2_{CHECKSUM_NONE}; CHECKSUM tx_checksum_2_{CHECKSUM_NONE}; @@ -151,6 +153,7 @@ class UARTExComponent : public uart::UARTDevice, public Component ERROR error_code_{ERROR_NONE}; CallbackManager error_callback_{}; std::queue tx_queue_{}; + std::queue tx_queue_reply_{}; std::queue tx_queue_low_priority_{}; tx_data_t current_tx_data_{nullptr, nullptr}; bool rx_processing_{false}; diff --git a/packages/wallpad/samsung/samsung_sds.yaml b/packages/wallpad/samsung/samsung_sds.yaml index e29a075e..e85c9b3b 100644 --- a/packages/wallpad/samsung/samsung_sds.yaml +++ b/packages/wallpad/samsung/samsung_sds.yaml @@ -16,9 +16,10 @@ uartex: tx_delay: 50ms tx_timeout: 500ms tx_retry_cnt: 3 + rx_length: 4 rx_header: [0xB0] rx_checksum: !lambda |- - uint8_t crc = 0xB0; + uint8_t crc = header[0]; for (size_t i = 0; i < len; i++) crc ^= data[i]; if (data[0] < 0x7C) crc ^= 0x80; diff --git a/packages/wallpad/samsung/samsung_sds_door.yaml b/packages/wallpad/samsung/samsung_sds_door.yaml index d58d7f97..f7a2bbe6 100644 --- a/packages/wallpad/samsung/samsung_sds_door.yaml +++ b/packages/wallpad/samsung/samsung_sds_door.yaml @@ -17,9 +17,10 @@ uartex: tx_delay: 0ms tx_timeout: 500ms tx_retry_cnt: 3 + rx_length: 4 rx_header: [0xA4] rx_checksum: !lambda |- - uint8_t crc = 0xA4; + uint8_t crc = header[0]; for (size_t i = 0; i < len; i++) crc ^= data[i]; if (data[0] < 0x7C) crc ^= 0x80; @@ -30,6 +31,39 @@ uartex: crc ^= data[i]; crc ^= 0x80; return crc; + rx_reply: + - state: [0x5A] + command: [0xB0, 0x5A, 0x00] + - state: [0x41] + command: !lambda |- + if (id(door_state) == "D_IDLE") return {{0xB0, 0x41, 0x00}}; + if (id(door_state) == "D_CALL" || id(door_state) == "D_CALL_P") { + uint8_t value = id(door_state) == "D_CALL" ? 0x01 : 0x02; + id(door_state) = "D_CALLED"; + return {{0xB0, 0x36, value}}; + } + if (id(door_state) == "D_OPEN" || id(door_state) == "D_OPEN_P") { + uint8_t value = id(door_state) == "D_OPEN" ? 0x00 : 0x06; + id(door_state) = "D_OPENED"; + return {{0xB0, 0x3B, value}}; + } + return {{0xB0, 0x42, 0x00}}; + - state: [0x42] + command: [0xB0, 0x41, 0x00] + - state: [0x32] + command: [0xB0, 0x32, 0x00] + - state: [0x31] + command: [0xB0, 0x31, 0x00] + - state: [0x36] + command: [0xB0, 0x42, 0x00] + - state: [0x38] + command: [0xB0, 0x42, 0x00] + - state: [0x35] + command: [0xB0, 0x35, 0x00] + - state: [0x3B] + command: [0xB0, 0x42, 0x00] + - state: [0x3E] + command: !lambda 'return {{0xB0, 0x3E, data[1]}};' version: disabled: False @@ -92,42 +126,6 @@ globals: type: std::string initial_value: '"D_IDLE"' -text_sensor: - - platform: uartex - name: "Response" - internal: True - lambda: |- - if (data[0] == 0x5A) - { - id(uartex_id).write_command("init", {0xB0, 0x5A, 0x00}); - } - else if (data[0] == 0x41) - { - if (id(door_state) == "D_IDLE") id(uartex_id).write_command("query", {0xB0, 0x41, 0x00}); - else if (id(door_state) == "D_CALL" || id(door_state) == "D_CALL_P") - { - uint8_t value = id(door_state) == "D_CALL" ? 0x01 : 0x02; - id(uartex_id).write_command("query", {0xB0, 0x36, value}); //01현관, 02공동 - id(door_state) = "D_CALLED"; - } - else if (id(door_state) == "D_OPEN" || id(door_state) == "D_OPEN_P") - { - uint8_t value = id(door_state) == "D_OPEN" ? 0x00 : 0x06; - id(uartex_id).write_command("query", {0xB0, 0x3B, value}); //00현관, 06공동 - id(door_state) = "D_OPENED"; - } - else id(uartex_id).write_command("query", {0xB0, 0x42, 0x00}); - } - else if (data[0] == 0x42) id(uartex_id).write_command("block", {0xB0, 0x41, 0x00}); - else if (data[0] == 0x32) id(uartex_id).write_command("public", {0xB0, 0x32, 0x00}); - else if (data[0] == 0x31) id(uartex_id).write_command("private", {0xB0, 0x31, 0x00}); - else if (data[0] == 0x36) id(uartex_id).write_command("opena", {0xB0, 0x42, 0x00}); - else if (data[0] == 0x38) id(uartex_id).write_command("vopena", {0xB0, 0x42, 0x00}); - else if (data[0] == 0x35) id(uartex_id).write_command("vconna", {0xB0, 0x35, 0x00}); - else if (data[0] == 0x3B) id(uartex_id).write_command("open2a", {0xB0, 0x42, 0x00}); - else if (data[0] == 0x3E) id(uartex_id).write_command("end", {0xB0, 0x3E, data[1]}); //01현관, 02공동 - return to_hex_string(data, len); - button: - platform: template id: door_call diff --git a/tests/components/uartex/common_rx_features.yaml b/tests/components/uartex/common_rx_features.yaml new file mode 100644 index 00000000..287d0c83 --- /dev/null +++ b/tests/components/uartex/common_rx_features.yaml @@ -0,0 +1,49 @@ +external_components: + - source: + type: local + path: ../../../components + components: [ uartex ] + +esphome: + name: test-uartex-rx-features + +uart: + baud_rate: 9600 + data_bits: 8 + parity: NONE + stop_bits: 1 + rx_pin: ${rx_pin} + tx_pin: ${tx_pin} + +# Multi-candidate rx_header, header-aware rx_checksum, and rx_reply (static + lambda). +uartex: + id: uartex_rx_features + rx_timeout: 10ms + tx_delay: 0ms + tx_timeout: 500ms + tx_retry_cnt: 3 + rx_length: 4 + rx_header: + - [0xA0] + - [0xA1] + rx_checksum: !lambda |- + uint8_t crc = header[0]; + for (size_t i = 0; i < len; i++) + crc ^= data[i]; + return crc; + tx_checksum: xor_no_header + rx_reply: + - state: [0x51] + command: [0xA0, 0x51, 0x00] + - state: [0x52] + command: !lambda 'return {{0xA0, 0x52, data[1]}};' + +binary_sensor: + - platform: uartex + name: Rx Features Ping + uartex_id: uartex_rx_features + state: [0x50] + state_on: + data: [0x01] + state_off: + data: [0x00] diff --git a/tests/components/uartex/test.esp32-idf.rx-features.yaml b/tests/components/uartex/test.esp32-idf.rx-features.yaml new file mode 100644 index 00000000..7368507b --- /dev/null +++ b/tests/components/uartex/test.esp32-idf.rx-features.yaml @@ -0,0 +1,10 @@ +substitutions: + rx_pin: GPIO22 + tx_pin: GPIO19 + +esp32: + board: m5stack-atom + framework: + type: esp-idf + +<<: !include common_rx_features.yaml diff --git a/tests/components/uartex/test.esp8266-ard.rx-features.yaml b/tests/components/uartex/test.esp8266-ard.rx-features.yaml new file mode 100644 index 00000000..e8ab7f35 --- /dev/null +++ b/tests/components/uartex/test.esp8266-ard.rx-features.yaml @@ -0,0 +1,8 @@ +substitutions: + rx_pin: GPIO5 + tx_pin: GPIO4 + +esp8266: + board: nodemcuv2 + +<<: !include common_rx_features.yaml diff --git a/tests/native/uartex_parser/README.md b/tests/native/uartex_parser/README.md new file mode 100644 index 00000000..1f7a41f7 --- /dev/null +++ b/tests/native/uartex_parser/README.md @@ -0,0 +1,51 @@ +# UARTEx native tests + +`Parser` 및 내장 `compute_checksum` (ADD/XOR/…) 을 호스트에서 검증합니다. + +## 실행 + +```bash +tests/native/uartex_parser/run.sh +``` + +Windows (g++ 필요): + +```powershell +$root = (Resolve-Path "$PSScriptRoot\..\..\..").Path +g++ -std=c++17 -Wall -Wextra -Werror ` + -I"$root/components/uartex" ` + "$root/components/uartex/parser.cpp" ` + "$root/tests/native/uartex_parser/test_parser.cpp" ` + -o uartex_parser_test.exe +.\uartex_parser_test.exe +``` + +## 커버 시나리오 + +### Parser (`test_parser.cpp`) + +| 테스트 | 조건 | +|--------|------| +| `no_header_fixed_length` | 헤더 없음, `total_len`만 | +| `single_header_fixed_length` | 단일 헤더 + 고정 길이 | +| `single_header_with_checksum` | 체크섬 1바이트, 파서 자동 완료 없음 | +| `multi_header_candidates` | 헤더 후보 2개 (0xA0 / 0xA1) | +| `header_mismatch_clears_buffer` | 헤더 불일치 시 버퍼 클리어 | +| `header_with_mask` | 헤더 마스크 매칭 | +| `header_footer_checksum` | 헤더 + 푸터 + 체크섬 | +| `fixed_length_with_footer` | 고정 길이 + 푸터 | +| `dynamic_data_length_*` | 가변 길이 필드 (BE / LE) | +| `no_footer_no_length_incomplete` | 푸터·길이 없으면 미완료 | +| `apply_mask` / `clear` / `buffer_len` | 유틸·상태 리셋 | + +### Checksum (`test_checksum.cpp`) + +| 테스트 | 조건 | +|--------|------| +| `NONE` / `CUSTOM` | 0 반환 | +| `ADD` / `XOR` | 헤더 포함 | +| `ADD_NO_HEADER` / `XOR_NO_HEADER` | payload만 | +| `XOR_ADD` | 2바이트 checksum | +| Parser 연동 | `compute_checksum`으로 만든 패킷이 `verify_checksum` 통과 | + +CI: `.github/workflows/esphome.yml` 의 `parser-native-test` job (`components/uartex`, `tests/components/uartex`, `tests/native/uartex_parser` 변경 시). diff --git a/tests/native/uartex_parser/run.sh b/tests/native/uartex_parser/run.sh new file mode 100644 index 00000000..b46f60cc --- /dev/null +++ b/tests/native/uartex_parser/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +INC=(-I"$ROOT/components/uartex") +SRC=("$ROOT/components/uartex/parser.cpp" "$ROOT/components/uartex/checksum.cpp") +CXXFLAGS=(-std=c++17 -Wall -Wextra -Werror) + +g++ "${CXXFLAGS[@]}" "${INC[@]}" "${SRC[@]}" \ + "$ROOT/tests/native/uartex_parser/test_parser.cpp" \ + -o "${TMPDIR:-/tmp}/uartex_parser_test" +"${TMPDIR:-/tmp}/uartex_parser_test" + +g++ "${CXXFLAGS[@]}" "${INC[@]}" "${SRC[@]}" \ + "$ROOT/tests/native/uartex_parser/test_checksum.cpp" \ + -o "${TMPDIR:-/tmp}/uartex_checksum_test" +"${TMPDIR:-/tmp}/uartex_checksum_test" diff --git a/tests/native/uartex_parser/test_checksum.cpp b/tests/native/uartex_parser/test_checksum.cpp new file mode 100644 index 00000000..6304f610 --- /dev/null +++ b/tests/native/uartex_parser/test_checksum.cpp @@ -0,0 +1,162 @@ +#include "checksum.h" +#include "parser.h" + +#include +#include +#include +#include + +using esphome::uartex::CHECKSUM; +using esphome::uartex::CHECKSUM_ADD; +using esphome::uartex::CHECKSUM_ADD_NO_HEADER; +using esphome::uartex::CHECKSUM_CUSTOM; +using esphome::uartex::CHECKSUM_NONE; +using esphome::uartex::CHECKSUM_XOR; +using esphome::uartex::CHECKSUM_XOR_ADD; +using esphome::uartex::CHECKSUM_XOR_NO_HEADER; +using esphome::uartex::compute_checksum; + +namespace { + +int g_failures = 0; + +const std::vector k_header = {0x02, 0x01}; +const std::vector k_data = {0x02, 0x03}; + +void require(bool condition, const char *message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + g_failures++; + } +} + +void require_eq_u16(uint16_t actual, uint16_t expected, const char *message) +{ + if (actual != expected) + { + std::cerr << "FAIL: " << message << " (0x" << std::hex << actual << " vs 0x" << expected << std::dec << ")\n"; + g_failures++; + } +} + +bool feed(Parser &parser, const std::vector &bytes) +{ + bool complete = false; + for (uint8_t byte : bytes) + { + if (parser.parse_byte(byte)) + complete = true; + } + return complete; +} + +void test_checksum_none_and_custom() +{ + require_eq_u16(compute_checksum(CHECKSUM_NONE, k_header, k_data), 0, "NONE returns 0"); + require_eq_u16(compute_checksum(CHECKSUM_CUSTOM, k_header, k_data), 0, "CUSTOM returns 0"); +} + +void test_checksum_add() +{ + require_eq_u16(compute_checksum(CHECKSUM_ADD, k_header, k_data), 0x08, "ADD with header"); + require_eq_u16(compute_checksum(CHECKSUM_ADD, {}, k_data), 0x05, "ADD empty header"); +} + +void test_checksum_xor() +{ + require_eq_u16(compute_checksum(CHECKSUM_XOR, k_header, k_data), 0x02, "XOR with header"); + require_eq_u16(compute_checksum(CHECKSUM_XOR, {}, {0xFF, 0x0F}), 0xF0, "XOR empty header"); +} + +void test_checksum_add_no_header() +{ + require_eq_u16(compute_checksum(CHECKSUM_ADD_NO_HEADER, k_header, k_data), 0x05, "ADD_NO_HEADER ignores header"); +} + +void test_checksum_xor_no_header() +{ + require_eq_u16(compute_checksum(CHECKSUM_XOR_NO_HEADER, k_header, k_data), 0x01, "XOR_NO_HEADER ignores header"); +} + +void test_checksum_xor_add() +{ + require_eq_u16(compute_checksum(CHECKSUM_XOR_ADD, k_header, k_data), 0x020A, "XOR_ADD 16-bit result"); +} + +void test_parser_integration_add_with_footer() +{ + Parser parser; + parser.add_headers(k_header); + parser.add_footers({0x0D, 0x0A}); + parser.set_checksum_len(1); + + uint8_t crc = static_cast(compute_checksum(CHECKSUM_ADD, k_header, k_data) & 0xFF); + std::vector packet = k_header; + packet.insert(packet.end(), k_data.begin(), k_data.end()); + packet.push_back(crc); + packet.insert(packet.end(), {0x0D, 0x0A}); + + require(feed(parser, packet), "integration ADD: frame completes"); + require(parser.verify_checksum({crc}), "integration ADD: checksum verifies"); +} + +void test_parser_integration_xor_no_header_fixed_length() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.set_total_len(4); + parser.set_checksum_len(1); + + std::vector payload = {0x51, 0x00}; + uint8_t crc = static_cast(compute_checksum(CHECKSUM_XOR_NO_HEADER, {}, payload) & 0xFF); + require_eq_u16(crc, 0x51, "integration XOR_NO_HEADER: expected crc"); + + require(!feed(parser, {0xA0, 0x51, 0x00, crc}), "integration XOR_NO_HEADER: completion deferred when checksum set"); + require(parser.buffer().size() == 4, "integration XOR_NO_HEADER: frame buffered"); + require(parser.verify_checksum({crc}), "integration XOR_NO_HEADER: checksum verifies"); +} + +void test_parser_integration_xor_add_two_byte_checksum() +{ + Parser parser; + parser.add_headers(k_header); + parser.add_footers({0x0D, 0x0A}); + parser.set_checksum_len(2); + + uint16_t crc16 = compute_checksum(CHECKSUM_XOR_ADD, k_header, k_data); + std::vector packet = k_header; + packet.insert(packet.end(), k_data.begin(), k_data.end()); + packet.push_back(static_cast((crc16 >> 8) & 0xFF)); + packet.push_back(static_cast(crc16 & 0xFF)); + packet.insert(packet.end(), {0x0D, 0x0A}); + + require(feed(parser, packet), "integration XOR_ADD: frame completes"); + require(parser.verify_checksum({static_cast((crc16 >> 8) & 0xFF), static_cast(crc16 & 0xFF)}), + "integration XOR_ADD: 2-byte checksum verifies"); +} + +} // namespace + +int main() +{ + test_checksum_none_and_custom(); + test_checksum_add(); + test_checksum_xor(); + test_checksum_add_no_header(); + test_checksum_xor_no_header(); + test_checksum_xor_add(); + test_parser_integration_add_with_footer(); + test_parser_integration_xor_no_header_fixed_length(); + test_parser_integration_xor_add_two_byte_checksum(); + + if (g_failures > 0) + { + std::cerr << g_failures << " checksum test(s) failed\n"; + return EXIT_FAILURE; + } + + std::cout << "All checksum tests passed.\n"; + return EXIT_SUCCESS; +} diff --git a/tests/native/uartex_parser/test_parser.cpp b/tests/native/uartex_parser/test_parser.cpp new file mode 100644 index 00000000..f99a2d91 --- /dev/null +++ b/tests/native/uartex_parser/test_parser.cpp @@ -0,0 +1,221 @@ +#include "parser.h" + +#include +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +void require(bool condition, const char *message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + g_failures++; + } +} + +void require_eq(const std::vector &actual, const std::vector &expected, const char *message) +{ + if (actual != expected) + { + std::cerr << "FAIL: " << message << " (size " << actual.size() << " vs " << expected.size() << ")\n"; + g_failures++; + } +} + +bool feed(Parser &parser, const std::vector &bytes) +{ + bool complete = false; + for (uint8_t byte : bytes) + { + if (parser.parse_byte(byte)) + complete = true; + } + return complete; +} + +bool feed_byte(Parser &parser, uint8_t byte) +{ + return parser.parse_byte(byte); +} + +void test_no_header_fixed_length() +{ + Parser parser; + parser.set_total_len(4); + require(feed(parser, {0x10, 0x20, 0x30, 0x40}), "no header: frame completes at fixed length"); + require_eq(parser.buffer(), {0x10, 0x20, 0x30, 0x40}, "no header: buffer"); + require_eq(parser.data(), {0x10, 0x20, 0x30, 0x40}, "no header: payload"); + require(parser.header().empty(), "no header: matched header empty"); +} + +void test_single_header_fixed_length() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.set_total_len(3); + require(feed(parser, {0xA0, 0x51, 0x00}), "single header: frame completes at fixed length"); + require_eq(parser.header(), {0xA0}, "single header: matched header"); + require_eq(parser.data(), {0x51, 0x00}, "single header: payload"); +} + +void test_single_header_with_checksum_no_auto_complete() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.set_total_len(4); + parser.set_checksum_len(1); + require(!feed(parser, {0xA0, 0x51, 0x00, 0xF1}), "checksum+length: parser waits for external validation"); + require_eq(parser.header(), {0xA0}, "checksum+length: header still matched"); + require_eq(parser.data(), {0x51, 0x00}, "checksum+length: payload"); + require(parser.verify_checksum({0xF1}), "checksum+length: verify_checksum accepts trailing byte"); + require(!parser.verify_checksum({0x00}), "checksum+length: wrong checksum rejected"); +} + +void test_multi_header_candidates() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.add_header_candidate({0xA1}); + parser.set_total_len(4); + + require(feed(parser, {0xA1, 0x52, 0x01, 0xAB}), "multi header: A1 frame completes"); + require_eq(parser.header(), {0xA1}, "multi header: second candidate matched"); + + parser.clear(); + require(feed(parser, {0xA0, 0x51, 0x00, 0xCD}), "multi header: A0 frame completes"); + require_eq(parser.header(), {0xA0}, "multi header: first candidate matched"); +} + +void test_header_mismatch_clears_buffer() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.set_total_len(4); + require(!feed_byte(parser, 0xFF), "header mismatch: first byte rejected"); + require(!parser.available(), "header mismatch: buffer cleared"); + require(parser.header().empty(), "header mismatch: no active header"); +} + +void test_header_with_mask() +{ + Parser parser; + parser.add_header_candidate({0x30, 0xB0}, {0xFF, 0xF0}); + parser.set_total_len(4); + require(feed(parser, {0x30, 0xBC, 0x01, 0x02}), "masked header: accepts masked second byte"); + require_eq(parser.header(), {0x30, 0xBC}, "masked header: stores actual bytes"); +} + +void test_header_footer_checksum() +{ + Parser parser; + parser.add_headers({0x02, 0x01}); + parser.add_footers({0x0D, 0x0A}); + parser.set_checksum_len(1); + // ADD checksum over header+payload: 0x02+0x01+0x02+0x03 = 0x08 + require(feed(parser, {0x02, 0x01, 0x02, 0x03, 0x08, 0x0D, 0x0A}), "footer frame: completes on footer"); + require_eq(parser.data(), {0x02, 0x03}, "footer frame: payload"); + require(parser.verify_checksum({0x08}), "footer frame: checksum valid"); +} + +void test_fixed_length_with_footer() +{ + Parser parser; + parser.add_headers({0x02, 0x01}); + parser.add_footers({0x0D, 0x0A}); + parser.set_total_len(7); + require(feed(parser, {0x02, 0x01, 0xAA, 0xBB, 0xCC, 0x0D, 0x0A}), "fixed length + footer: completes"); + require_eq(parser.data(), {0xAA, 0xBB, 0xCC}, "fixed length + footer: payload"); +} + +void test_dynamic_data_length_big_endian() +{ + Parser parser; + parser.add_headers({0xAA, 0x55}); + parser.add_footers({0x0D, 0x0A}); + parser.set_data_length(0, 1, true, 0); + // header(2) + len(1) + payload(2) + footer(2) = 7 bytes + require(feed(parser, {0xAA, 0x55, 0x02, 0xDD, 0xEE, 0x0D, 0x0A}), "dynamic length: frame completes"); + require_eq(parser.data(), {0x02, 0xDD, 0xEE}, "dynamic length: payload includes length field and body"); +} + +void test_dynamic_data_length_little_endian() +{ + Parser parser; + parser.add_header_candidate({0xAB}); + parser.add_footers({0xEE}); + parser.set_data_length(0, 2, false, 0); + // header(1) + len LE 0x0300 -> 0x0003 = 3 payload bytes + footer(1) = 9 + require(feed(parser, {0xAB, 0x03, 0x00, 0x11, 0x22, 0x33, 0xEE}), "dynamic length LE: completes"); + require(parser.buffer().size() == 7, "dynamic length LE: total size"); +} + +void test_no_footer_no_length_incomplete() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + require(!feed(parser, {0xA0, 0x01, 0x02}), "no footer/length: stays incomplete"); + require(parser.available(), "no footer/length: partial buffer kept"); + require_eq(parser.header(), {0xA0}, "no footer/length: header still matched"); +} + +void test_apply_mask() +{ + Parser parser; + require_eq(parser.apply_mask({0x30, 0xBC}, {0xFF, 0xF0}), {0x30, 0xB0}, "apply_mask: masks bytes"); + require_eq(parser.apply_mask({0x30, 0xBC}, {}), {0x30, 0xBC}, "apply_mask: empty mask is identity"); +} + +void test_clear_resets_state() +{ + Parser parser; + parser.add_header_candidate({0xA0}); + parser.set_total_len(4); + require(feed(parser, {0xA0, 0x01, 0x02, 0x03}), "clear: initial frame"); + parser.clear(); + require(!parser.available(), "clear: buffer empty"); + require(parser.header().empty(), "clear: active header reset"); +} + +void test_buffer_len_trims_oldest() +{ + Parser parser; + parser.set_buffer_len(4); + parser.set_total_len(0); + feed(parser, {0x01, 0x02, 0x03, 0x04, 0x05}); + require_eq(parser.buffer(), {0x02, 0x03, 0x04, 0x05}, "buffer_len: drops oldest byte"); +} + +} // namespace + +int main() +{ + test_no_header_fixed_length(); + test_single_header_fixed_length(); + test_single_header_with_checksum_no_auto_complete(); + test_multi_header_candidates(); + test_header_mismatch_clears_buffer(); + test_header_with_mask(); + test_header_footer_checksum(); + test_fixed_length_with_footer(); + test_dynamic_data_length_big_endian(); + test_dynamic_data_length_little_endian(); + test_no_footer_no_length_incomplete(); + test_apply_mask(); + test_clear_resets_state(); + test_buffer_len_trims_oldest(); + + if (g_failures > 0) + { + std::cerr << g_failures << " parser test(s) failed\n"; + return EXIT_FAILURE; + } + + std::cout << "All parser tests passed.\n"; + return EXIT_SUCCESS; +}