[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
@@ -1,6 +1,9 @@
#include "hoermann_hcp.h"
#include <algorithm>
#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 &registers, 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<char>(value >> 8);
out[2 * i + 1] = static_cast<char>(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<uint16_t>(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 &registers) {
registers.push_back(command->released_value_2);
}
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
void HoermannHcp::add_identity_request_(modbus::RegisterValues &registers, uint16_t command) {
if (static_cast<uint8_t>(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<uint16_t>(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 &registers) {
if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG ||
static_cast<uint8_t>(registers[0]) != TRANSFER_COMMAND)
return NO_TRANSFER_ANSWER;
const uint8_t counter = static_cast<uint8_t>(registers[0] >> 8);
const uint8_t sub_code = static_cast<uint8_t>(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 &registers, 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<const uint8_t *>(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<uint8_t>(value);
@@ -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 &registers, uint16_t command);
void arm_identity_request_(IdentityPhase phase);
uint8_t identity_request_() const { return static_cast<uint8_t>(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 &registers);
// Acknowledges the transfer taken by the write half of the same frame.
void push_transfer_answer_(modbus::RegisterValues &registers, 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
@@ -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))
+1
View File
@@ -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
+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