mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 16:00:23 +00:00
[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:
co-authored by
J. Nick Koston
parent
89ce3ab98c
commit
b619df886a
@@ -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 ®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<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 ®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<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 ®isters) {
|
||||
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 ®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<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 ®isters, 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 ®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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user