mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[hoermann_hcp] Add Hörmann HCP garage door component (#17355)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
parent
596827c51c
commit
82a63658f9
@@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
|
||||
esphome/components/hm3301/* @freekode
|
||||
esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
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 ®isters, 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 ®isters) {
|
||||
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 ®isters) {
|
||||
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 ®isters) {
|
||||
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 ®isters) override;
|
||||
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®isters) 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 ®isters);
|
||||
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
|
||||
@@ -52,6 +52,7 @@ COMMON_BUS_PATH = (
|
||||
# the packages on the right as well
|
||||
PACKAGE_DEPENDENCIES = {
|
||||
"modbus": ["uart"], # modbus packages include uart packages
|
||||
"modbus_server": ["uart"], # modbus_server packages include uart packages
|
||||
# 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
|
||||
@@ -31,11 +31,14 @@ common/
|
||||
│ ├── esp32-c3-idf.yaml
|
||||
│ ├── esp8266-ard.yaml
|
||||
│ └── rp2040-ard.yaml
|
||||
├── modbus/ # Modbus (includes uart via packages)
|
||||
├── modbus/ # Modbus client (includes uart via packages)
|
||||
│ ├── esp32-idf.yaml
|
||||
│ ├── esp32-c3-idf.yaml
|
||||
│ ├── esp8266-ard.yaml
|
||||
│ └── rp2040-ard.yaml
|
||||
├── modbus_server/ # Modbus server (includes uart via packages)
|
||||
│ ├── esp32-idf.yaml
|
||||
│ └── esp8266-ard.yaml
|
||||
└── ble/
|
||||
├── esp32-idf.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
|
||||
Reference in New Issue
Block a user