[hoermann_hcp] Add Hörmann HCP garage door component (#17355)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Josef Zweck
2026-08-10 17:20:20 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 596827c51c
commit 82a63658f9
16 changed files with 1259 additions and 1 deletions
+1
View File
@@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004 esphome/components/homeassistant/switch/* @Links2004
@@ -0,0 +1,33 @@
import esphome.codegen as cg
from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@zweckj"]
DEPENDENCIES = ["modbus"]
MULTI_CONF = True
CONF_HOERMANN_HCP_ID = "hoermann_hcp_id"
hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp")
HoermannHcp = hoermann_hcp_ns.class_(
"HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice
)
# The Hoermann UAP module answers on Modbus server address 2.
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)})
.extend(cv.polling_component_schema("500ms"))
.extend(modbus.modbus_device_schema(0x02, role="server"))
)
FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device(
"hoermann_hcp", role="server"
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_server_device(var, config)
@@ -0,0 +1,22 @@
import esphome.codegen as cg
from esphome.components import cover
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component)
CONFIG_SCHEMA = (
cover.cover_schema(HoermannHcpCover)
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
var = await cover.new_cover(config, parent)
await cg.register_component(var, config)
@@ -0,0 +1,87 @@
#include "hoermann_hcp_cover.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp.cover";
cover::CoverTraits HoermannHcpCover::get_traits() {
cover::CoverTraits traits;
traits.set_supports_position(true);
traits.set_supports_stop(true);
traits.set_supports_toggle(true);
return traits;
}
void HoermannHcpCover::setup() {
// Nothing is published before the bus controller is heard from, and the untouched position reads as fully
// open, so flag the entity until the first contact clears it again.
this->status_set_warning("waiting for the bus controller");
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
}
void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); }
void HoermannHcpCover::control(const cover::CoverCall &call) {
bool accepted = true;
if (call.get_stop())
accepted &= this->parent_->stop_door();
if (call.get_toggle().has_value())
accepted &= this->parent_->impulse_door();
if (const auto position = call.get_position())
accepted &= this->parent_->set_position(*position);
if (!accepted) {
// The command never reached the door, so publish the unchanged state over the one the caller assumed.
ESP_LOGW(TAG, "Command was not accepted by the door");
this->publish_state(false);
}
}
void HoermannHcpCover::update_from_state_() {
if (!this->parent_->is_valid()) {
this->status_set_warning();
// The door can now move unheard, so drop the baseline a direction would be inferred from and stop
// reporting motion instead of leaving the cover travelling until the controller returns.
this->previous_position_ = NAN;
if (this->current_operation != cover::COVER_OPERATION_IDLE) {
this->current_operation = cover::COVER_OPERATION_IDLE;
this->publish_state();
}
return;
}
this->status_clear_warning();
const auto previous_operation = this->current_operation;
const float current_position = this->parent_->get_current_position();
switch (this->parent_->get_door_state()) {
case DoorState::OPENING:
this->current_operation = cover::COVER_OPERATION_OPENING;
break;
case DoorState::CLOSING:
this->current_operation = cover::COVER_OPERATION_CLOSING;
break;
case DoorState::MOVE_VENTING:
case DoorState::MOVE_HALF:
// These states carry no direction, so keep the current one until the position actually moves.
if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) {
this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING
: cover::COVER_OPERATION_CLOSING;
}
break;
default:
this->current_operation = cover::COVER_OPERATION_IDLE;
break;
}
this->previous_position_ = current_position;
// Compare against the position last published, which starts at COVER_OPEN rather than at zero.
const bool changed = this->position != current_position || previous_operation != this->current_operation;
this->position = current_position;
if (changed) {
// The bus reports the position on every broadcast, so nothing here is worth restoring from flash.
this->publish_state(false);
}
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,27 @@
#pragma once
#include <cmath>
#include "esphome/components/cover/cover.h"
#include "esphome/core/component.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
class HoermannHcpCover : public cover::Cover, public Component {
public:
explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {}
void setup() override;
void dump_config() override;
cover::CoverTraits get_traits() override;
void control(const cover::CoverCall &call) override;
protected:
void update_from_state_();
HoermannHcp *const parent_;
// NAN until the first position is observed, so no direction is inferred from a baseline that never existed.
float previous_position_{NAN};
};
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,336 @@
#include "hoermann_hcp.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp";
// Hoermann HCP holding-register blocks.
static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
// its low byte tells a plain stop from the vent position.
struct DoorStateMapping {
uint8_t code;
DoorState state;
};
static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
{0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF},
{0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN},
{0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN},
};
// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
static void push_zeros(modbus::RegisterValues &registers, uint16_t count) {
for (uint16_t i = 0; i < count; i++)
registers.push_back(0x0000);
}
// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
static bool is_moving(DoorState state) {
switch (state) {
case DoorState::OPENING:
case DoorState::CLOSING:
case DoorState::MOVE_HALF:
case DoorState::MOVE_VENTING:
return true;
default:
return false;
}
}
void HoermannHcp::update() {
const uint32_t now = millis();
// Time out the connection flag if the bus controller stopped polling.
if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
this->set_valid_(false);
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
this->clear_target_();
}
if (this->changed_) {
this->changed_ = false;
this->state_callback_.call();
}
}
void HoermannHcp::dump_config() {
ESP_LOGCONFIG(TAG,
"Hoermann HCP bridge:\n"
" Modbus server address: 0x%02X",
this->get_address());
}
modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) {
if (start_address != STATE_REG) {
ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// 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;
const uint16_t command = static_cast<uint16_t>((this->command_reg_value_ & 0x00FF) << 8);
switch (number_of_registers) {
case 8:
// Command request: return the internal state, injecting any pending command.
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
push_zeros(registers, 4);
break;
case 2:
// Empty command request.
registers.push_back(static_cast<uint16_t>(0x0004 | counter));
registers.push_back(command);
break;
case 5:
// Bus scan (the bus controller discovering us, typically at startup).
ESP_LOGD(TAG, "Bus scan received from bus controller");
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0005 | command));
registers.push_back(0x0430);
registers.push_back(0x10FF);
registers.push_back(0xA845);
break;
default:
ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers);
push_zeros(registers, number_of_registers);
break;
}
return {};
}
modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
const modbus::RegisterValues &registers) {
if (start_address == COMMAND_REG) {
// 0x17 write half: stash the command register so the following read half can echo its message counter and
// 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];
return {};
}
if (start_address != BROADCAST_REG) {
// Every device sees every broadcast, so a frame meant for another node is ordinary traffic
ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
// position checks the target against the new state.
if (registers.size() > 2)
this->on_state_reg_(registers[2]);
if (registers.size() > 1)
this->on_position_reg_(registers[1]);
return {};
}
void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
const HoermannHcpCommand *command = this->next_command_;
if (command == nullptr) {
push_zeros(registers, 2);
return;
}
if (this->command_written_at_ == 0) {
// First read after the command was queued: present the "key pressed" values.
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(0x0000);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Still inside the key-press window, so keep presenting 0x0000.
push_zeros(registers, 2);
return;
}
// Enough time passed: present the "key released" values and clear the command.
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
this->next_command_ = nullptr;
registers.push_back(command->released_value);
registers.push_back(0x0000);
}
void HoermannHcp::on_position_reg_(uint16_t value) {
// Low byte: current position.
const uint8_t position = static_cast<uint8_t>(value);
if (this->position_raw_ == position)
return;
this->position_raw_ = position;
this->update_current_position_();
// Until the door actually travels the way it was told to, its position says nothing about the target.
if (!this->has_target_() || !this->target_started_)
return;
// The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
const bool reached = this->target_direction_ == DoorState::OPENING
? this->current_position_ >= this->target_position_
: this->current_position_ <= this->target_position_;
if (reached)
this->stop_door();
}
void HoermannHcp::on_state_reg_(uint16_t value) {
// The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
const uint16_t previous = this->prev_state_reg_;
this->prev_state_reg_ = value;
if (previous == value)
return;
const uint8_t state = value >> 8;
if (state == 0x00) {
// Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
return;
}
for (const auto &mapping : DOOR_STATE_MAPPINGS) {
if (mapping.code == state) {
this->set_door_state_(mapping.state);
return;
}
}
// The low byte can change on its own, so only report a state we cannot decode once.
if (state != (previous >> 8))
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
}
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
if (!this->valid_) {
// Queueing now would fire the command whenever the controller comes back, which may be much later.
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
return false;
}
if (this->next_command_ != nullptr) {
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::stop_door() {
if (!is_moving(this->door_state_)) {
this->clear_target_();
return true;
}
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
return this->queue_command_(COMMAND_IMPULSE);
}
bool HoermannHcp::set_position(float position) {
// The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
if (position <= CLOSE_POSITION_THRESHOLD)
return this->close_door();
if (position >= OPEN_POSITION_THRESHOLD)
return this->open_door();
// Asking the door to travel to where it already is means stopping it.
if (position == this->current_position_)
return this->stop_door();
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
const bool opening = position > this->current_position_;
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
return false;
this->target_position_ = position;
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
// A door already travelling that way is on its way; one moving the other way has to turn around first.
this->target_started_ = this->door_state_ == this->target_direction_;
return true;
}
void HoermannHcp::record_response_() {
this->last_response_ = millis();
this->set_valid_(true);
}
void HoermannHcp::set_valid_(bool valid) {
if (this->valid_ == valid)
return;
this->valid_ = valid;
this->changed_ = true;
if (valid) {
ESP_LOGI(TAG, "Bus controller connected");
return;
}
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
}
void HoermannHcp::set_door_state_(DoorState state) {
if (this->door_state_ == state)
return;
this->door_state_ = state;
this->changed_ = true;
this->update_current_position_();
if (!this->has_target_())
return;
if (state == this->target_direction_) {
this->target_started_ = true;
} else if (this->target_started_ && !is_moving(state)) {
// The door came to rest without reaching the target, so the request it belonged to is over.
this->clear_target_();
}
}
void HoermannHcp::update_current_position_() {
// Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
// trust the reported end stop over the raw count.
float position = static_cast<float>(this->position_raw_) / 200.0f;
if (this->door_state_ == DoorState::CLOSED) {
position = 0.0f;
} else if (this->door_state_ == DoorState::OPEN) {
position = 1.0f;
}
if (this->current_position_ != position) {
this->current_position_ = position;
this->changed_ = true;
}
}
void HoermannHcp::clear_target_() {
this->target_position_ = 0.0f;
this->target_started_ = false;
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,110 @@
#pragma once
#include <utility>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::hoermann_hcp {
// Door state as reported by the Hoermann bus controller.
enum class DoorState : uint8_t {
OPEN,
OPENING,
CLOSED,
CLOSING,
HALF_OPEN,
MOVE_VENTING,
VENT,
MOVE_HALF,
STOPPED,
};
// 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. The second command register remains zero.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
uint16_t released_value;
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
public:
void update() override;
void dump_config() override;
// Registered by child entities to be notified when the door state changes.
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
// Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write
// half first, storing the command register that the read half echoes back) and broadcasts status with 0x10.
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues &registers) override;
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) override;
// Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus
// controller cannot be asked right now, so the caller can react.
bool open_door();
bool close_door();
bool impulse_door();
bool stop_door();
bool set_position(float position);
DoorState get_door_state() const { return this->door_state_; }
float get_current_position() const { return this->current_position_; }
bool is_valid() const { return this->valid_; }
protected:
void record_response_();
// Returns false when the bus controller has not fetched the previous command yet.
bool queue_command_(const HoermannHcpCommand &command);
// Appends the two key-press registers and advances the pending command's press/release state.
void push_command_registers_(modbus::RegisterValues &registers);
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void set_valid_(bool valid);
void set_door_state_(DoorState state);
// Recomputes the reported position from position_raw_ and the current door state.
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
void clear_target_();
CallbackManager<void()> state_callback_;
float current_position_{0.0f};
// Position the door was told to travel to; 0.0 means no target is armed.
float target_position_{0.0f};
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
uint32_t command_queued_at_{0};
uint32_t command_written_at_{0};
uint32_t last_response_{0};
// A command is "pressed" for this long before its end value is sent.
uint16_t key_press_delay_ms_{100};
// Drop the "connected" flag if the bus controller has not polled us for this long.
uint16_t connection_timeout_ms_{2000};
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
// it reads 0x0000.
uint16_t prev_state_reg_{0xFFFF};
// 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message
// counter and low-byte command back from STATE_REG.
uint16_t command_reg_value_{0};
DoorState door_state_{DoorState::CLOSED};
// Direction the door was started in for the current target. A target armed while the door is still travelling
// the other way must not be judged by the reported direction until the door has turned around.
DoorState target_direction_{DoorState::STOPPED};
// Position as reported by the bus controller, 0..200 across the full travel.
uint8_t position_raw_{0};
bool target_started_{false};
bool valid_{false};
bool changed_{false};
};
} // namespace esphome::hoermann_hcp
+1
View File
@@ -52,6 +52,7 @@ COMMON_BUS_PATH = (
# the packages on the right as well # the packages on the right as well
PACKAGE_DEPENDENCIES = { PACKAGE_DEPENDENCIES = {
"modbus": ["uart"], # modbus packages include uart packages "modbus": ["uart"], # modbus packages include uart packages
"modbus_server": ["uart"], # modbus_server packages include uart packages
# Add more package dependencies here as needed # Add more package dependencies here as needed
} }
@@ -0,0 +1,8 @@
hoermann_hcp:
id: hoermann_hcp_hub
modbus_id: modbus_server_bus
cover:
- platform: hoermann_hcp
name: Garage Door
device_class: garage
@@ -0,0 +1,174 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h"
namespace esphome::hoermann_hcp {
using modbus::RegisterValues;
namespace {
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The door only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
} // namespace
// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish.
TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
int publishes = 0;
cover.add_on_state_callback([&publishes]() { publishes++; });
ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
// Any request marks the device connected, which is itself a state change.
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
door.update();
EXPECT_EQ(publishes, 1);
EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED);
}
// Venting and half-open moves report no direction, so one is only derived once the position has moved.
TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
// Position 100/200 = 0.5, state 0x80 -> resting half open.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000}));
door.update();
ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
// State 0x05 -> moving to half-open, but the position has not moved yet.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
// Position 120/200 = 0.6 is higher than before, so the door is opening.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING);
EXPECT_FLOAT_EQ(cover.position, 0.6f);
}
// Booting while the door is already mid-move gives no baseline to compare against, so no direction
// may be inferred from the first update.
TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
// The very first thing seen is a half-open move already at 100/200 = 0.5.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
}
// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather
// than as a target the door would be stopped at.
TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_open().perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
}
// The same for cover.close, which arrives as a position of 0.0.
TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
}
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_toggle().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
// The door is opening, so it takes an impulse to stop it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
cover.make_call().set_command_stop().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// A position between the end stops starts the door in the right direction; it is stopped there later.
TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
HoermannHcp door; // starts out fully closed
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_position(0.5f).perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
}
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
// to be published back over that assumption.
TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) {
HoermannHcp door; // never contacted by a bus controller
HoermannHcpCover cover(&door);
cover.setup();
int publishes = 0;
cover.add_on_state_callback([&publishes]() { publishes++; });
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(publishes, 1);
EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
}
// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would
// otherwise sit at its fully open default and look healthy.
TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
EXPECT_TRUE(cover.status_has_warning());
connect(door);
door.update();
EXPECT_FALSE(cover.status_has_warning());
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,430 @@
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
namespace esphome::hoermann_hcp {
using modbus::RegisterValues;
namespace {
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The device only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
class TestableHoermannHcp : public HoermannHcp {
public:
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
using HoermannHcp::set_valid_;
};
} // namespace
// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004.
TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 2, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 2u);
EXPECT_EQ(response[0], 0x0004);
EXPECT_EQ(response[1], 0x0000);
}
// A bus scan (write 3 / read 5) answers with the fixed device identification block.
TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 5, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 5u);
EXPECT_EQ(response[1], 0x0005);
EXPECT_EQ(response[2], 0x0430);
EXPECT_EQ(response[3], 0x10ff);
EXPECT_EQ(response[4], 0xa845);
}
// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press.
TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[1], 0x0001);
EXPECT_EQ(response[2], 0x0000);
EXPECT_EQ(response[3], 0x0000);
}
// A queued control command is injected into the next command poll as a simulated key press.
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
HoermannHcp door;
connect(door);
door.open_door();
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
EXPECT_EQ(response[3], 0x0000);
}
// A read of any other block is an addressing error rather than a successful all-zero reply.
TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
HoermannHcp door;
RegisterValues response;
EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A command is held for the key-press duration, then released, and only then can the next one be queued.
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
TestableHoermannHcp door;
connect(door);
door.open_door();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
// Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120.
door.close_door();
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// With the command gone, the next one is accepted again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
}
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) {
HoermannHcp door;
door.open_door();
EXPECT_EQ(poll_command(door), 0x0000);
}
// Losing the controller must drop a command it never fetched, otherwise it blocks every later command
// and fires unasked once the bus comes back.
TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
TestableHoermannHcp door;
connect(door);
door.open_door();
ASSERT_TRUE(door.is_valid());
door.set_valid_(false);
EXPECT_FALSE(door.is_valid());
// The reconnecting poll must not replay the dropped command.
EXPECT_EQ(poll_command(door), 0x0000);
// And the slot is free, so a new command is accepted.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
}
// The connection is dropped by update() once the controller stops polling, which is what releases a
// command it never fetched in the field.
TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
TestableHoermannHcp door;
// Wide enough that a stall cannot expire the connection before the check below runs.
door.connection_timeout_ms_ = 10000;
connect(door);
door.open_door();
// Still inside the window: the controller counts as present.
door.update();
ASSERT_TRUE(door.is_valid());
// Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer.
door.connection_timeout_ms_ = 20;
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.update();
EXPECT_FALSE(door.is_valid());
// The pending command went with the connection instead of firing on the reconnecting poll.
EXPECT_EQ(poll_command(door), 0x0000);
}
// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to
// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely.
TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
door.open_door();
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// A status broadcast refreshes the connection without ever fetching the command.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
door.update();
ASSERT_TRUE(door.is_valid());
// With the stale command gone, the door accepts commands again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
}
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
// differently per block length.
TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) {
HoermannHcp door;
// Counter 0x34 in the high byte, command 0x07 in the low byte.
door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000}));
RegisterValues command_poll;
door.on_read_holding_registers(STATE_REG, 8, command_poll);
ASSERT_EQ(command_poll.size(), 8u);
EXPECT_EQ(command_poll[0], 0x3400); // counter alone
EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low
RegisterValues empty_poll;
door.on_read_holding_registers(STATE_REG, 2, empty_poll);
ASSERT_EQ(empty_poll.size(), 2u);
EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here
EXPECT_EQ(empty_poll[1], 0x0700); // command alone
RegisterValues scan;
door.on_read_holding_registers(STATE_REG, 5, scan);
ASSERT_EQ(scan.size(), 5u);
EXPECT_EQ(scan[0], 0x3400);
EXPECT_EQ(scan[1], 0x0705);
}
// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position.
TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) {
HoermannHcp door;
// registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening).
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a
// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed.
TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) {
HoermannHcp door;
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(door.get_door_state(), DoorState::STOPPED);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of
// the state register still has to be decoded.
TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) {
HoermannHcp door;
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061}));
EXPECT_EQ(door.get_door_state(), DoorState::VENT);
}
// A door parking a count short of its end stop must still report exactly closed or open, because
// Cover::is_fully_closed() compares against 0.0 exactly.
TEST(HoermannHcpWrite, EndStopsReportExactPositions) {
HoermannHcp door;
// Position register 1 of 200 while the door reports itself closed.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f);
// Position register 199 of 200 while the door reports itself open.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000}));
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f);
// Away from the end stops the raw count is reported as-is.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// A position request below the lower snap threshold becomes a plain close command.
TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
HoermannHcp door;
connect(door);
door.set_position(0.02f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value
}
// A half-open target starts the door moving towards the requested position.
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
HoermannHcp door; // starts out fully closed
connect(door);
door.set_position(0.5f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
}
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// Position 20/200 = 0.1 while opening: short of the target, so the door keeps going.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_EQ(poll_command(door), 0x0000);
// Position 120/200 = 0.6 is past the target, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
// must be read as "already stopped" rather than "still opening".
TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
// Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000}));
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
EXPECT_EQ(poll_command(door), 0x0000);
}
// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
// The door is stopped at 0.3 by a wall button, short of the requested 0.5.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
}
// A target armed while the door is still travelling the other way must not be judged by that old direction,
// otherwise the very next position it reports counts as reached and stops the door where it stands.
TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) {
TestableHoermannHcp door;
connect(door);
// The door is closing, passing 60/200 = 0.3.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// Still closing at 58/200 = 0.29: below the target, but not on the way to it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200}));
EXPECT_EQ(poll_command(door), 0x0000);
// Now opening at 62/200 = 0.31, still short of the target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
// Past the target at 110/200 = 0.55, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// A motor turning around can report a momentary stop; dropping the target there would let the door run on
// to the end stop that the reversing command asked for.
TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
TestableHoermannHcp door;
connect(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
// The stop reported on the way from closing to opening.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// The door then opens and still has to be stopped at the requested position.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240);
}
// A door that never turns around has to lose the target as well, otherwise it would cut a later move short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the
// target is the only thing that may expire here.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
door.update();
ASSERT_TRUE(door.is_valid());
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
hoermann_hcp: !include common.yaml
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml
hoermann_hcp: !include common.yaml
+4 -1
View File
@@ -31,11 +31,14 @@ common/
│ ├── esp32-c3-idf.yaml │ ├── esp32-c3-idf.yaml
│ ├── esp8266-ard.yaml │ ├── esp8266-ard.yaml
│ └── rp2040-ard.yaml │ └── rp2040-ard.yaml
├── modbus/ # Modbus (includes uart via packages) ├── modbus/ # Modbus client (includes uart via packages)
│ ├── esp32-idf.yaml │ ├── esp32-idf.yaml
│ ├── esp32-c3-idf.yaml │ ├── esp32-c3-idf.yaml
│ ├── esp8266-ard.yaml │ ├── esp8266-ard.yaml
│ └── rp2040-ard.yaml │ └── rp2040-ard.yaml
├── modbus_server/ # Modbus server (includes uart via packages)
│ ├── esp32-idf.yaml
│ └── esp8266-ard.yaml
└── ble/ └── ble/
├── esp32-idf.yaml ├── esp32-idf.yaml
├── esp32-ard.yaml ├── esp32-ard.yaml
@@ -0,0 +1,10 @@
# Common server-role Modbus configuration for ESP32 IDF tests
# Provides a shared Modbus bus that all Modbus server components can use
packages:
uart: !include ../uart/esp32-idf.yaml
modbus:
- id: modbus_server_bus
uart_id: uart_bus
role: server
@@ -0,0 +1,10 @@
# Common server-role Modbus configuration for ESP8266 Arduino tests
# Provides a shared Modbus bus that all Modbus server components can use
packages:
uart: !include ../uart/esp8266-ard.yaml
modbus:
- id: modbus_server_bus
uart_id: uart_bus
role: server