From b619df886a4dbc42f38ebab190c145a4a67d712a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:30 +0200 Subject: [PATCH] [hoermann_hcp] Add the motor's serial number and firmware version as text sensors (#19543) Co-authored-by: J. Nick Koston --- .../components/hoermann_hcp/hoermann_hcp.cpp | 230 ++++++++ .../components/hoermann_hcp/hoermann_hcp.h | 63 +++ .../hoermann_hcp/text_sensor/__init__.py | 37 ++ esphome/core/defines.h | 1 + tests/components/hoermann_hcp/common.h | 20 +- tests/components/hoermann_hcp/common.yaml | 7 + .../hoermann_hcp/text_sensor/__init__.py | 11 + .../hoermann_hcp_text_sensor_test.cpp | 510 ++++++++++++++++++ 8 files changed, 876 insertions(+), 3 deletions(-) create mode 100644 esphome/components/hoermann_hcp/text_sensor/__init__.py create mode 100644 tests/components/hoermann_hcp/text_sensor/__init__.py create mode 100644 tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 4aa2c79bb1..261996e557 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,9 @@ #include "hoermann_hcp.h" +#include + #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hoermann_hcp { @@ -60,6 +63,61 @@ static bool is_moving(DoorState state) { } } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +// The command byte of a status poll. Only its answer can carry a request. +static constexpr uint8_t STATUS_COMMAND = 0x03; +// A status answer with this code in the low byte of its second register asks the bus controller for a value, +// named in the high byte of the third. +static constexpr uint8_t ANSWER_REQUEST = 0x22; +static constexpr uint8_t REQUEST_SERIAL = 0x05; +static constexpr uint8_t REQUEST_FIRMWARE = 0x06; +// Each request goes out in one answer, up to this many times, this far apart. +static constexpr uint8_t IDENTITY_MAX_ATTEMPTS = 3; +static constexpr uint32_t IDENTITY_RETRY_MS = 30000; +// The value comes back as a payload transfer: this command in the low byte of the first command register, a sub +// code in the high byte of the second, the payload from the third on. +static constexpr uint8_t TRANSFER_COMMAND = 0x04; +static constexpr uint8_t TRANSFER_SUB_SERIAL = 0x0C; +static constexpr uint8_t TRANSFER_SUB_FIRMWARE = 0x0D; +static constexpr uint8_t TRANSFER_ACK = 0xFD; +static constexpr size_t TRANSFER_PAYLOAD_REG = 2; +// Marks the first half of the serial number in the counter byte, and is not part of the count. +static constexpr uint8_t COUNTER_FIRST_HALF = 0x80; +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. It is as long +// as a second half, so one copy path serves both. +static constexpr size_t SERIAL_SINGLE_FRAME_REGS = 6; +static_assert(SERIAL_SINGLE_FRAME_REGS == SERIAL_SECOND_HALF_REGS, "one copy path serves both serial frames"); + +// Registers hold two payload bytes each, high byte first. +static void copy_payload(const modbus::RegisterValues ®isters, size_t count, char *out) { + for (size_t i = 0; i < count; i++) { + const uint16_t value = registers[TRANSFER_PAYLOAD_REG + i]; + out[2 * i] = static_cast(value >> 8); + out[2 * i + 1] = static_cast(value); + } +} + +// Length of the printable text at the start, without trailing spaces. The padding after it varies. +static size_t text_length(const char *text, size_t len) { + size_t at = 0; + while (at < len && text[at] >= 0x20 && text[at] <= 0x7E) + at++; + while (at > 0 && text[at - 1] == ' ') + at--; + return at; +} + +static void terminate_text(char *text, size_t len) { text[text_length(text, len)] = '\0'; } + +// dump_config() is replayed to remote log clients, so they see the outcome of the exchange at boot. +static void log_identity_value(text_sensor::TextSensor *sensor) { + if (sensor != nullptr) { + ESP_LOGCONFIG(TAG, " Value: %s", + sensor->has_state() ? sensor->get_state().c_str() : LOG_STR_LITERAL("not received")); + } +} +#endif + void HoermannHcp::update() { const uint32_t now = millis(); // Time out the connection flag if the bus controller stopped polling. @@ -91,6 +149,9 @@ void HoermannHcp::update() { ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); this->forget_light_toggles_(); } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->publish_identity_(); +#endif if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -102,6 +163,12 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); + log_identity_value(this->serial_number_text_sensor_); + LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_); + log_identity_value(this->version_text_sensor_); +#endif } modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, @@ -113,6 +180,14 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add this->record_response_(); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // Acknowledge the transfer taken by the write half of this frame. + if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) { + this->push_transfer_answer_(registers, number_of_registers); + return {}; + } +#endif + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message // counter (high byte) and command (low byte). The read length identifies which internal block is requested. const uint16_t counter = this->command_reg_value_ & 0xFF00; @@ -125,6 +200,9 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add registers.push_back(static_cast(0x0001 | command)); this->push_command_registers_(registers); push_zeros(registers, 4); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->add_identity_request_(registers, command); +#endif break; case 2: // Empty command request. @@ -156,6 +234,9 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, // command byte back from STATE_REG. The hub always runs the write before the read within one request. this->record_response_(); this->command_reg_value_ = registers[0]; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->transfer_answer_counter_ = this->take_identity_transfer_(registers); +#endif return {}; } @@ -217,6 +298,155 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { registers.push_back(command->released_value_2); } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +void HoermannHcp::add_identity_request_(modbus::RegisterValues ®isters, uint16_t command) { + if (static_cast(this->command_reg_value_) != STATUS_COMMAND) + return; + // Like Hoermann's own bus accessory, only after one ordinary answer. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE) { + if (this->serial_number_text_sensor_ != nullptr || this->version_text_sensor_ != nullptr) + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL); + return; + } + // Uses the registers of a key press, so it waits while one is pending. + if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_()) + return; + registers[1] = static_cast(ANSWER_REQUEST | command); + registers[2] = encode_uint16(this->identity_request_(), 0); +} + +void HoermannHcp::arm_identity_request_(IdentityPhase phase) { + this->identity_phase_ = phase; + this->identity_attempts_ = 0; +} + +bool HoermannHcp::take_identity_request_() { + const uint8_t request = this->identity_request_(); + if (request == 0) + return false; + const uint32_t now = millis(); + if (this->identity_attempts_ != 0 && now - this->identity_asked_at_ <= IDENTITY_RETRY_MS) + return false; + if (this->identity_attempts_ >= IDENTITY_MAX_ATTEMPTS) { + this->identity_unanswered_ = request; + if (request == REQUEST_SERIAL) { + // Still ask for the firmware version, and drop a leftover first half. + this->serial_number_[0] = '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + } else { + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + } + return false; + } + this->identity_attempts_++; + this->identity_asked_at_ = now; + return true; +} + +uint8_t HoermannHcp::take_identity_transfer_(const modbus::RegisterValues ®isters) { + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG || + static_cast(registers[0]) != TRANSFER_COMMAND) + return NO_TRANSFER_ANSWER; + const uint8_t counter = static_cast(registers[0] >> 8); + const uint8_t sub_code = static_cast(registers[1] >> 8); + if (sub_code != TRANSFER_SUB_SERIAL && sub_code != TRANSFER_SUB_FIRMWARE) + return NO_TRANSFER_ANSWER; + // Acknowledged whether kept or not, as Hoermann's own bus accessory does. What was not kept is asked for again. + const uint8_t answer = counter & ~COUNTER_FIRST_HALF; + // Only an answer to the request that went out is kept. + const uint8_t request = sub_code == TRANSFER_SUB_SERIAL ? REQUEST_SERIAL : REQUEST_FIRMWARE; + if (this->identity_attempts_ == 0 || this->identity_request_() != request) + return answer; + + const size_t payload_regs = registers.size() - TRANSFER_PAYLOAD_REG; + if (request == REQUEST_FIRMWARE) { + const size_t regs = std::min(payload_regs, FIRMWARE_REGS); + copy_payload(registers, regs, this->firmware_version_); + if (regs == FIRMWARE_REGS && text_length(this->firmware_version_, 2 * regs) != 0) { + terminate_text(this->firmware_version_, 2 * regs); + // Clear an earlier unreadable answer, so this one is shown. + this->firmware_unreadable_ = false; + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + // Left as received for update() to log. + this->firmware_unreadable_ = true; + this->firmware_unreadable_len_ = 2 * regs; + // A short answer is asked for again; unreadable text would come back the same. + if (regs == FIRMWARE_REGS) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + if ((counter & COUNTER_FIRST_HALF) != 0) { + // Even a half too short to keep says the number comes in two halves; a first half already kept stays kept. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT; + if (payload_regs >= SERIAL_FIRST_HALF_REGS) { + copy_payload(registers, SERIAL_FIRST_HALF_REGS, this->serial_number_); + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF; + } + return answer; + } + // Without a half marker seen, the frame is the whole number; after one, it can only be the second half. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT || payload_regs < SERIAL_SINGLE_FRAME_REGS) + return answer; + const size_t at = + this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF ? 2 * SERIAL_FIRST_HALF_REGS : 0; + copy_payload(registers, SERIAL_SINGLE_FRAME_REGS, this->serial_number_ + at); + terminate_text(this->serial_number_, at + 2 * SERIAL_SINGLE_FRAME_REGS); + this->serial_unreadable_ = this->serial_number_[0] == '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + return answer; +} + +void HoermannHcp::push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers) { + const uint16_t answer[] = {encode_uint16(this->transfer_answer_counter_, 0), + encode_uint16(TRANSFER_COMMAND, TRANSFER_ACK)}; + this->transfer_answer_counter_ = NO_TRANSFER_ANSWER; + for (uint16_t i = 0; i < number_of_registers; i++) + registers.push_back(i < 2 ? answer[i] : 0x0000); +} + +void HoermannHcp::publish_identity_() { + // Buffers are cleared once published, so each value goes out once. The serial number is whole once the firmware + // version is requested. + if (this->serial_number_text_sensor_ != nullptr && this->identity_request_() != REQUEST_SERIAL && + this->serial_number_[0] != '\0') { + this->serial_number_text_sensor_->publish_state(this->serial_number_); + this->serial_number_[0] = '\0'; + } + if (this->serial_unreadable_) { + this->serial_unreadable_ = false; + ESP_LOGW(TAG, "Unreadable serial number"); + } + // Checked first: the buffer then holds raw bytes, not text. + if (this->firmware_unreadable_) { + this->firmware_unreadable_ = false; + const uint8_t len = this->firmware_unreadable_len_; + // All zeros: the motor does not report a version (index B1 seen). + if (len == 2 * FIRMWARE_REGS && + std::all_of(this->firmware_version_, this->firmware_version_ + len, [](char c) { return c == '\0'; })) { + ESP_LOGD(TAG, "Motor does not report its firmware version"); + } else { + char hex[format_hex_size(2 * FIRMWARE_REGS)]; + ESP_LOGW(TAG, "Unreadable firmware version (%u bytes): %s", len, + format_hex_to(hex, reinterpret_cast(this->firmware_version_), len)); + } + this->firmware_version_[0] = '\0'; + } + if (this->version_text_sensor_ != nullptr && this->firmware_version_[0] != '\0') { + this->version_text_sensor_->publish_state(this->firmware_version_); + this->firmware_version_[0] = '\0'; + } + if (this->identity_unanswered_ != 0) { + ESP_LOGW(TAG, "No usable %s received", + this->identity_unanswered_ == REQUEST_SERIAL ? LOG_STR_LITERAL("serial number") + : LOG_STR_LITERAL("firmware version")); + this->identity_unanswered_ = 0; + } +} +#endif + void HoermannHcp::on_position_reg_(uint16_t value) { // Low byte: current position. const uint8_t position = static_cast(value); diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 83be385c7b..fd52a689d0 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -4,7 +4,11 @@ #include "esphome/components/modbus/modbus.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::hoermann_hcp { @@ -21,6 +25,28 @@ enum class DoorState : uint8_t { STOPPED, }; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +// Payload registers of each value, two bytes each. +static constexpr size_t SERIAL_FIRST_HALF_REGS = 7; +static constexpr size_t SERIAL_SECOND_HALF_REGS = 6; +static constexpr size_t FIRMWARE_REGS = 6; +// Counter of the transfer the read half of the frame acknowledges. Real counters have the 0x80 half marker +// stripped, so this value never occurs. +static constexpr uint8_t NO_TRANSFER_ANSWER = 0xFF; + +// Where the identity exchange stands. The low nibble is the code the motor is asked with, 0 while nothing is +// outstanding. Like Hoermann's own bus accessory, the first status answer stays ordinary. Older motors (index B1 +// seen) send the whole serial number in one frame, without the half marker. +enum class IdentityPhase : uint8_t { + IDENTITY_PHASE_IDLE = 0x00, + IDENTITY_PHASE_DONE = 0x10, + IDENTITY_PHASE_SERIAL = 0x05, // A plain frame is the whole serial number. + IDENTITY_PHASE_SERIAL_SPLIT = 0x15, // A half marker was seen, so a plain frame can only be the second half. + IDENTITY_PHASE_SERIAL_SECOND_HALF = 0x25, // The first half is in. + IDENTITY_PHASE_FIRMWARE = 0x06, +}; +#endif + // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a // short delay the released value. Each half also carries a second register, which names the buttons that do // not fit into the first. @@ -35,6 +61,12 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // The motor is asked for these only when one of them is configured. + SUB_TEXT_SENSOR(serial_number) + SUB_TEXT_SENSOR(version) +#endif + public: void update() override; void dump_config() override; @@ -95,6 +127,21 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); void on_light_reg_(uint16_t value); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // Puts a due request into a status answer. + void add_identity_request_(modbus::RegisterValues ®isters, uint16_t command); + void arm_identity_request_(IdentityPhase phase); + uint8_t identity_request_() const { return static_cast(this->identity_phase_) & 0x0F; } + // True when the request is due, counting the attempt. Gives up after the last one. + bool take_identity_request_(); + // Takes a value the motor hands over as a payload transfer. Returns the counter to acknowledge it with, kept or + // not, or NO_TRANSFER_ANSWER when the frame was something else. + uint8_t take_identity_transfer_(const modbus::RegisterValues ®isters); + // Acknowledges the transfer taken by the write half of the same frame. + void push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers); + // Runs from update(), outside the bus callbacks. + void publish_identity_(); +#endif void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -146,6 +193,22 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_on_{false}; bool light_seen_{false}; bool short_broadcast_logged_{false}; + +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + uint32_t identity_asked_at_{0}; + IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE}; + uint8_t identity_attempts_{0}; + // The request given up on, for update() to report. + uint8_t identity_unanswered_{0}; + uint8_t transfer_answer_counter_{NO_TRANSFER_ANSWER}; + // Length of an unreadable firmware version left in firmware_version_. + uint8_t firmware_unreadable_len_{0}; + // A serial number arrived without text, for update() to log. + bool serial_unreadable_{false}; + bool firmware_unreadable_{false}; + char serial_number_[2 * (SERIAL_FIRST_HALF_REGS + SERIAL_SECOND_HALF_REGS) + 1]{}; + char firmware_version_[2 * FIRMWARE_REGS + 1]{}; +#endif }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 0000000000..0fcbe60040 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_SERIAL_NUMBER = "serial_number" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( + icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_CHIP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_SERIAL_NUMBER, CONF_VERSION), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + if (conf := config.get(CONF_SERIAL_NUMBER)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_serial_number_text_sensor(sens)) + if (conf := config.get(CONF_VERSION)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_version_text_sensor(sens)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75c7a76a40..19b3f475be 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -77,6 +77,7 @@ #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_TEXT_SENSOR #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index a6151697f0..fb940d790f 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -35,11 +35,18 @@ inline void connect_controller(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } -// Runs one command poll (write 2 / read 8) and returns both key-press registers. -inline std::pair poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter +// with command 0x03 here; most tests do not care and pass zero. +inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) { + door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000})); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); + return response; +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + const RegisterValues response = status_answer(door); EXPECT_EQ(response.size(), 8u); if (response.size() != 8u) return {0xFFFF, 0xFFFF}; @@ -59,7 +66,14 @@ class TestableHoermannHcp : public HoermannHcp { TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } using HoermannHcp::connection_timeout_ms_; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + using HoermannHcp::identity_asked_at_; + using HoermannHcp::identity_request_; + using HoermannHcp::firmware_unreadable_; + using HoermannHcp::serial_unreadable_; +#endif using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::key_press_delay_ms_; using HoermannHcp::light_toggle_released_at_; using HoermannHcp::light_toggles_in_flight_; using HoermannHcp::set_valid_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 618a8181bf..394afed360 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -22,3 +22,10 @@ button: light: - platform: hoermann_hcp name: Garage Light + +text_sensor: + - platform: hoermann_hcp + serial_number: + name: Garage Motor Serial Number + version: + name: Garage Motor Firmware Version diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 0000000000..693c48b736 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + + manifest.to_code = to_code_testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp new file mode 100644 index 0000000000..8dff56e2a3 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp @@ -0,0 +1,510 @@ +#include +#include + +#include +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Made up. 26 bytes on the wire: the first 14 arrive in one transfer, the other 12 in the next. +constexpr const char *SERIAL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +// Made up as well, padded with a space to the 12 bytes the motor sends. +constexpr const char *FIRMWARE = "FW-TEST 1.0 "; + +constexpr uint8_t SUB_SERIAL = 0x0C; +constexpr uint8_t SUB_FIRMWARE = 0x0D; +constexpr uint8_t FIRST_HALF = 0x80; + +// A status poll as the bus controller writes it: its counter in the high byte, command 0x03 in the low. +RegisterValues status_poll(HoermannHcp &door, uint8_t counter = 0x05) { + return status_answer(door, static_cast((counter << 8) | 0x03)); +} + +// The write half of a payload transfer: the motor writes the bytes into the command block. +void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len) { + RegisterValues written; + written.push_back(static_cast((counter << 8) | 0x04)); + written.push_back(static_cast(sub_code << 8)); + for (size_t i = 0; i < len; i += 2) { + written.push_back( + static_cast((static_cast(bytes[i]) << 8) | static_cast(bytes[i + 1]))); + } + door.on_write_registers(COMMAND_REG, written); +} + +// A whole payload transfer, returning the answer the motor reads back. +RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len, + uint16_t read_registers = 8) { + write_transfer(door, counter, sub_code, bytes, len); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, read_registers, response); + return response; +} + +// The first status poll gets an ordinary answer, the next one carries the serial number request. +void request_serial(HoermannHcp &door) { + status_poll(door, 0x03); + status_poll(door, 0x04); +} + +void send_serial(HoermannHcp &door) { + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); +} + +// A hub with both sensors configured, which is what makes it ask. +struct IdentityFixture { + IdentityFixture() { + this->door.set_serial_number_text_sensor(&this->serial); + this->door.set_version_text_sensor(&this->version); + } + // What the sensors show once the loop has turned; empty while they have no state. + std::string serial_shown() { + this->door.update(); + return this->serial.has_state() ? this->serial.get_state() : ""; + } + std::string version_shown() { + this->door.update(); + return this->version.has_state() ? this->version.get_state() : ""; + } + + TestableHoermannHcp door; + text_sensor::TextSensor serial; + text_sensor::TextSensor version; +}; + +// The whole exchange as the motor runs it, with the loop turning in between as it would. +void run_identity_exchange(HoermannHcp &door) { + request_serial(door); + send_serial(door); + door.update(); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + door.update(); +} + +} // namespace + +// With no sensor configured, polls and transfers are answered exactly as before. +TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) { + HoermannHcp door; + for (int poll = 0; poll < 2; poll++) { + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0301); + EXPECT_EQ(response[2], 0x0000); + } + const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + EXPECT_EQ(answer[1] & 0x00FF, 0x0001); +} + +// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the +// request, echoing the status command like any status answer. It is asked again only once 30 s have passed. +TEST(HoermannHcpTextSensorTest, SerialNumberIsAskedForAfterOneOrdinaryAnswer) { + IdentityFixture fixture; + auto &door = fixture.door; + EXPECT_EQ(status_poll(door)[1], 0x0301); + + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[0], 0x0500); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + + door.identity_asked_at_ -= 29000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + door.identity_asked_at_ -= 2000; + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Three attempts at the serial number, then the firmware version is asked for anyway, three times as well. +TEST(HoermannHcpTextSensorTest, GivesUpAfterThreeAttemptsEach) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A first half left behind by a serial number that never completed is not shown. +TEST(HoermannHcpTextSensorTest, HalfASerialNumberIsNeverShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + for (int attempt = 0; attempt < 4; attempt++) { + door.identity_asked_at_ -= 31000; + status_poll(door); + } + EXPECT_EQ(fixture.serial_shown(), ""); +} + +// Each half is acknowledged with the counter it came with, minus the half marker. The serial number is shown as +// soon as it is whole, and the firmware version is asked for right after. +TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + + RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + ASSERT_EQ(answer.size(), 8u); + EXPECT_EQ(answer[0], 0x0500); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), ""); + + answer = transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(answer[0], 0x0600); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + EXPECT_EQ(fixture.version_shown(), ""); + + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + + answer = transfer(door, 0x07, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// Once both values are in, nothing more is asked, however long the device keeps running. +TEST(HoermannHcpTextSensorTest, FinishedExchangeStaysFinished) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + + door.identity_asked_at_ -= 31000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// The text ends at the first byte that is not printable. 0xFF is below the printable range where char is signed, +// as on the host, and above it where char is unsigned, as on most targets. DEL is above it either way. +TEST(HoermannHcpTextSensorTest, PaddingEndsTheText) { + for (const char pad : {'\xFF', '\x7F'}) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char first[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', pad}; + const char second[] = {pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad}; + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, first, sizeof(first)); + transfer(door, 0x06, SUB_SERIAL, second, sizeof(second)); + EXPECT_EQ(fixture.serial_shown(), "1234567890123"); + } +} + +// A half that cannot be used is still acknowledged but not kept, so the request stays open for the retry: a +// first half too short, a second half too short, a second half without a first. +TEST(HoermannHcpTextSensorTest, UnusableSerialHalvesAreNotKept) { + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12)[1], 0x04FD); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 10); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } +} + +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. The bytes are +// the ones a B1 sent, with the serial number made up. +TEST(HoermannHcpTextSensorTest, SerialNumberInOneFrameThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x05, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + auto answer = status_poll(door, 0x06); + EXPECT_EQ(answer[1], 0x0322); + EXPECT_EQ(answer[2], 0x0600); +} + +// The frames as a B1 motor sends them on the bus, which reads a transfer answer back as 2 registers. The serial +// number is made up. +TEST(HoermannHcpTextSensorTest, ExchangeWithTheFrameSizesOfAB1) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_THAT(transfer(door, 0x05, SUB_SERIAL, one_frame, 12, 2), ::testing::ElementsAre(0x0500, 0x04FD)); + + EXPECT_THAT(status_poll(door, 0x06), ::testing::ElementsAre(0x0600, 0x0322, 0x0600, 0, 0, 0, 0, 0)); + const char zeros[12] = {}; + EXPECT_THAT(transfer(door, 0x07, SUB_FIRMWARE, zeros, 12, 2), ::testing::ElementsAre(0x0700, 0x04FD)); + + EXPECT_EQ(door.identity_request_(), 0); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_EQ(status_poll(door, 0x08)[1], 0x0301); +} + +// A transfer of the value not asked for is acknowledged but not kept, and the request stays open. +TEST(HoermannHcpTextSensorTest, TheValueNotAskedForIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, 0x05, SUB_FIRMWARE, FIRMWARE, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.version_shown(), ""); + + send_serial(door); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + status_poll(door, 0x07); + const char other[14] = {'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'}; + EXPECT_EQ(transfer(door, FIRST_HALF | 0x08, SUB_SERIAL, other, 14)[1], 0x04FD); + EXPECT_EQ(transfer(door, 0x09, SUB_SERIAL, other, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), SERIAL); +} + +// A transfer before the request has gone out, as from a motor still finishing an exchange from before a restart, +// is acknowledged but not kept. +TEST(HoermannHcpTextSensorTest, ATransferBeforeTheRequestIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x04, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(status_poll(door, 0x05)[1], 0x0322); +} + +// After a frame with the half marker, one without it can only be the second half, even if the first was unusable. +TEST(HoermannHcpTextSensorTest, ASecondHalfIsNeverTakenForTheWholeNumber) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); +} + +// A serial number without any text at its start is not shown but logged, in one frame or in two halves. The +// firmware version is still asked for. +TEST(HoermannHcpTextSensorTest, SerialNumberThatIsNotTextIsLoggedNotShown) { + const char zeros[14] = {}; + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, 0x05, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); // kept for the log + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); // logged once + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, zeros, 14); + transfer(door, 0x06, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); + } +} + +// A firmware version too short is not kept, and is asked for again. +TEST(HoermannHcpTextSensorTest, ShortFirmwareVersionIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + EXPECT_TRUE(door.firmware_unreadable_); // kept for the log + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); // logged once + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A firmware version without any payload is logged, not shown. +TEST(HoermannHcpTextSensorTest, EmptyFirmwareVersionIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 0); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A readable firmware version right after a short one, before the loop has turned, is still shown. +TEST(HoermannHcpTextSensorTest, ReadableFirmwareVersionAfterAShortOneIsShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + transfer(door, 0x09, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// All zeros is how a motor that does not report its version says so: nothing shown, not asked for again. +TEST(HoermannHcpTextSensorTest, AllZeroFirmwareVersionMeansNoneIsReported) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char zeros[12] = {}; + transfer(door, 0x08, SUB_FIRMWARE, zeros, 12); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A firmware version that is not text is not shown, is logged, and is not asked for again: it would come back +// the same. +TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char binary[12] = {0x01, 0x12, 0x34, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}; + transfer(door, 0x08, SUB_FIRMWARE, binary, 12); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); // the raw bytes are not published + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a +// status poll instead, it would carry the key press waiting in the slot. +TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + connect_controller(door); + door.open_door(); + + const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(answer[2], 0x0000); + EXPECT_EQ(status_poll(door)[2], 0x0210); +} + +// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves +// nothing behind for the next poll. +TEST(HoermannHcpTextSensorTest, AnAnswerBelongsToItsFrame) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + write_transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + RegisterValues ignored; + door.on_read_holding_registers(COMMAND_REG, 8, ignored); + + EXPECT_EQ(status_poll(door)[1] & 0x00FF, 0x0022); +} + +// Only the answer to a status poll carries a request, not the answer to another frame of the same length, and +// other transfers are not this exchange's to answer. +TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2); + EXPECT_EQ(other[1] & 0x00FF, 0x0001); + EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322); +} + +// The request travels in the registers a key press would, so it waits for the press, the hold and the release. +TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + door.key_press_delay_ms_ = 100; + connect_controller(door); + status_poll(door); + door.open_door(); + + EXPECT_EQ(status_poll(door)[2], 0x0210); + const RegisterValues held = status_poll(door); + EXPECT_EQ(held[1], 0x0301); + EXPECT_EQ(held[2], 0x0000); + door.key_press_delay_ms_ = 0; + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + const RegisterValues release = status_poll(door); + EXPECT_EQ(release[1], 0x0301); + EXPECT_EQ(release[2], 0x0110); + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Each value is published once it is in, and only once. +TEST(HoermannHcpTextSensorTest, EachValueIsPublishedOnce) { + IdentityFixture fixture; + int serial_publishes = 0; + int version_publishes = 0; + fixture.serial.add_on_state_callback([&serial_publishes](const std::string & /*state*/) { serial_publishes++; }); + fixture.version.add_on_state_callback([&version_publishes](const std::string & /*state*/) { version_publishes++; }); + + fixture.door.update(); + EXPECT_EQ(serial_publishes, 0); + EXPECT_EQ(version_publishes, 0); + + run_identity_exchange(fixture.door); + EXPECT_EQ(fixture.serial.get_state(), SERIAL); + EXPECT_EQ(fixture.version.get_state(), "FW-TEST 1.0"); + + fixture.door.update(); + fixture.door.update(); + EXPECT_EQ(serial_publishes, 1); + EXPECT_EQ(version_publishes, 1); +} + +// Configuring only one of the two is enough to ask. +TEST(HoermannHcpTextSensorTest, OneSensorIsEnough) { + TestableHoermannHcp version_only; + text_sensor::TextSensor version; + version_only.set_version_text_sensor(&version); + run_identity_exchange(version_only); + EXPECT_EQ(version.get_state(), "FW-TEST 1.0"); + + TestableHoermannHcp serial_only; + text_sensor::TextSensor serial; + serial_only.set_serial_number_text_sensor(&serial); + run_identity_exchange(serial_only); + EXPECT_EQ(serial.get_state(), SERIAL); +} + +} // namespace esphome::hoermann_hcp::testing