[hoermann_hcp] Add the motor's serial number and firmware version as text sensors (#19543)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Raphael Hehl
2026-09-27 05:30:30 +01:00
committed by GitHub
co-authored by J. Nick Koston
parent 89ce3ab98c
commit b619df886a
8 changed files with 876 additions and 3 deletions
+17 -3
View File
@@ -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<uint16_t, uint16_t> 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<uint16_t, uint16_t> 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_;
@@ -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
@@ -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
@@ -0,0 +1,510 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <string>
#include <thread>
#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<uint16_t>((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<uint16_t>((counter << 8) | 0x04));
written.push_back(static_cast<uint16_t>(sub_code << 8));
for (size_t i = 0; i < len; i += 2) {
written.push_back(
static_cast<uint16_t>((static_cast<uint8_t>(bytes[i]) << 8) | static_cast<uint8_t>(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