From 82a63658f9da55eed7ecd80b6a9b8a1b470ac6a0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 11 Aug 2026 00:20:20 +0200 Subject: [PATCH] =?UTF-8?q?[hoermann=5Fhcp]=20Add=20H=C3=B6rmann=20HCP=20g?= =?UTF-8?q?arage=20door=20component=20(#17355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/hoermann_hcp/__init__.py | 33 ++ .../components/hoermann_hcp/cover/__init__.py | 22 + .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 87 ++++ .../hoermann_hcp/cover/hoermann_hcp_cover.h | 27 ++ .../components/hoermann_hcp/hoermann_hcp.cpp | 336 ++++++++++++++ .../components/hoermann_hcp/hoermann_hcp.h | 110 +++++ script/analyze_component_buses.py | 1 + tests/components/hoermann_hcp/common.yaml | 8 + .../cover/hoermann_hcp_cover_test.cpp | 174 +++++++ .../hoermann_hcp/hoermann_hcp_test.cpp | 430 ++++++++++++++++++ .../hoermann_hcp/test.esp32-idf.yaml | 3 + .../hoermann_hcp/test.esp8266-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 +- .../common/modbus_server/esp32-idf.yaml | 10 + .../common/modbus_server/esp8266-ard.yaml | 10 + 16 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hoermann_hcp/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.cpp create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.h create mode 100644 tests/components/hoermann_hcp/common.yaml create mode 100644 tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp create mode 100644 tests/components/hoermann_hcp/hoermann_hcp_test.cpp create mode 100644 tests/components/hoermann_hcp/test.esp32-idf.yaml create mode 100644 tests/components/hoermann_hcp/test.esp8266-ard.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp32-idf.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9bcbe087c5..253b0c05b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -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) diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -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) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -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 diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#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 diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..0dc146a061 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -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((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(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(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(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(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(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 diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..142365f16e --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +#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 void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(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 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 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -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 } diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..3b77eed7ea --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -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 diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..0ec2ed1ddd --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,174 @@ +#include + +#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 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 diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..8463c3f605 --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,430 @@ +#include + +#include +#include + +#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 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 diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -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 diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.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 diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -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