Files
esphome/tests/components/hoermann_hcp/hoermann_hcp_test.cpp
T

701 lines
30 KiB
C++

#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#include "common.h"
namespace esphome::hoermann_hcp::testing {
// 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 command.
TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0003, 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], 0x0301);
EXPECT_EQ(response[2], 0x0000);
EXPECT_EQ(response[3], 0x0000);
}
// A queued control command is injected into the next command poll.
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
HoermannHcp door;
connect_controller(door);
door.open_door();
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0003, 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], 0x0110); // COMMAND_OPEN
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 door command goes out in one answer and frees the slot at once.
TEST(HoermannHcpReadWrite, DoorCommandIsSentOnceAndFreesTheSlot) {
TestableHoermannHcp door;
connect_controller(door);
EXPECT_TRUE(door.open_door());
// Refused while one is unfetched.
EXPECT_FALSE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
EXPECT_EQ(poll_command(door).first, 0x0000);
// Once the door has run and come to rest, the slot takes the next command.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// A moving door is only ever stopped, whatever it is asked to do.
TEST(HoermannHcpReadWrite, MovingDoorIsOnlyStopped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A second stop within 500 ms is the same press and must not restart the door.
TEST(HoermannHcpReadWrite, SecondStopWithinHalfASecondIsIgnored) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
// Still reported moving while it slows down.
EXPECT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.last_stop_at_ -= 500;
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// A stop outranks a command still waiting, so a door at rest does not start after it.
TEST(HoermannHcpReadWrite, StopCancelsAnUnfetchedCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// The impulse would start a door that came to rest while the stop waited, so the stop is dropped instead.
TEST(HoermannHcpReadWrite, StopIsDroppedWhenTheDoorRestsBeforeTheFetch) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Until the door reports the start it reads as at rest, so a command in between only stops it, once it moves.
TEST(HoermannHcpReadWrite, CommandBeforeTheStartIsReportedBecomesAStop) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A door that never reports moving after its command is at rest after all, so later commands are its own.
TEST(HoermannHcpReadWrite, StartWindowClosesWhenTheDoorNeverMoves) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
EXPECT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110);
}
// Only the read half of a status poll carries a command, so a second read without a new write gets none.
TEST(HoermannHcpReadWrite, SecondReadWithoutAWriteCarriesNoCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
door.on_write_registers(COMMAND_REG, make_registers({0x0003, 0x0000}));
RegisterValues first;
door.on_read_holding_registers(STATE_REG, 8, first);
ASSERT_TRUE(door.open_door());
RegisterValues second;
door.on_read_holding_registers(STATE_REG, 8, second);
ASSERT_EQ(second.size(), 8u);
EXPECT_EQ(second[2], 0x0000);
// The next status poll takes it.
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// A command queued for a door at rest only stops it if the door was started from elsewhere before the fetch.
TEST(HoermannHcpReadWrite, CommandForADoorStartedBeforeTheFetchStopsIt) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.open_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A door started from elsewhere the way the queued command wants keeps going, and the command is dropped.
TEST(HoermannHcpReadWrite, CommandForADoorAlreadyMovingThatWayIsDropped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// The same holds for a close queued while the door was open and then started closing from elsewhere.
TEST(HoermannHcpReadWrite, CloseForADoorAlreadyClosingIsDropped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.close_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Another rest state after a fetched start is not the door's answer yet, so the start window stays open.
TEST(HoermannHcpReadWrite, RestStateAfterAStartKeepsTheWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0061}));
ASSERT_EQ(door.get_door_state(), DoorState::VENT);
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// Still starting, so a close is only a stop, held until the door moves.
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A first report showing the door where the command sent it ends the start window, even without a change.
TEST(HoermannHcpReadWrite, FirstReportAtTheDestinationEndsTheStart) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// A stop held for a late start report is sent once the door reports moving.
TEST(HoermannHcpReadWrite, HeldStopSurvivesALateStart) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
// The start is reported after the stop's own fetch deadline, which then starts over.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
door.update();
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A held stop goes when the door never reports its start, and never becomes an impulse.
TEST(HoermannHcpReadWrite, HeldStopIsDroppedWhenTheDoorNeverStarts) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A command for where the door already rests does not move it, so the next command is its own.
TEST(HoermannHcpReadWrite, CommandForTheCurrentEndOpensNoStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// The stop lock ends with the door at rest, so a target reached soon after the next start still stops it.
TEST(HoermannHcpReadWrite, StopLockEndsWhenTheDoorRests) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// A stop pressed while disconnected is not kept for the reconnect, where it could start a door at rest.
TEST(HoermannHcpReadWrite, StopWhileDisconnectedIsNotQueued) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.update();
ASSERT_FALSE(door.is_valid());
ASSERT_EQ(door.get_door_state(), DoorState::OPENING); // the stale report the stop would be judged by
EXPECT_FALSE(door.stop_door());
connect_controller(door);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Pressing stop twice before the fetch sends one impulse.
TEST(HoermannHcpReadWrite, SecondStopBeforeTheFetchSendsOneImpulse) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A position asked for before the start is reported stops the door once it moves.
TEST(HoermannHcpReadWrite, PositionBeforeTheStartIsReportedIsAStop) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A stop left waiting for a door that came to rest does not block the next command.
TEST(HoermannHcpReadWrite, StaleStopDoesNotBlockTheNextCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// Nor does it block a move to a position.
TEST(HoermannHcpReadWrite, StaleStopDoesNotBlockAPosition) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// Close on a closed door and vent at the vent position are no moves either, but half open at vent is.
TEST(HoermannHcpReadWrite, EveryEndOpensNoStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0A00}));
ASSERT_EQ(door.get_door_state(), DoorState::VENT);
ASSERT_TRUE(door.vent_door());
EXPECT_EQ(poll_command(door).first, 0x0100); // COMMAND_VENT
ASSERT_TRUE(door.half_open_door());
EXPECT_EQ(poll_command(door).first, 0x0100); // COMMAND_HALF_OPEN
// Half open from vent is a move, so a stop now waits for its start.
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Until the door has reported where it is, no command counts as a no-op.
TEST(HoermannHcpReadWrite, CommandBeforeTheFirstReportOpensAStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A stop dropped with the start window does not come back when the door moves after all.
TEST(HoermannHcpReadWrite, DroppedHeldStopDoesNotFireLater) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A start does not hold off the stop that follows it.
TEST(HoermannHcpReadWrite, StopRightAfterAStartIsSent) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.impulse_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// Only a status poll (command 0x03) fetches a command; another 8-register read carries zeros.
TEST(HoermannHcpReadWrite, CommandWaitsForAStatusPoll) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.open_door());
// A transfer write (command 0x04) read back as 8 registers.
door.on_write_registers(COMMAND_REG, make_registers({0x0504, 0x0000}));
RegisterValues other;
door.on_read_holding_registers(STATE_REG, 8, other);
ASSERT_EQ(other.size(), 8u);
EXPECT_EQ(other[2], 0x0000);
EXPECT_EQ(other[3], 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// 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).first, 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_controller(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).first, 0x0000);
// And the slot is free, so a new command is accepted.
door.close_door();
EXPECT_EQ(poll_command(door).first, 0x0120);
}
// 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_controller(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).first, 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_controller(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, 0x0000}));
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).first, 0x0120);
}
// 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_controller(door);
door.set_position(0.02f);
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// A half-open target starts the door moving towards the requested position.
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
HoermannHcp door; // starts out fully closed
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// 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_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
// 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).first, 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).first, 0x0140); // COMMAND_IMPULSE
}
// 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_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 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).first, 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_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 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).first, 0x0000);
}
// A new position while the door moves only stops it.
TEST(HoermannHcpPosition, NewPositionWhileMovingStopsTheDoor) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
EXPECT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A target outlives a start reported late, as long as it comes within the start window.
TEST(HoermannHcpPosition, TargetSurvivesALateStart) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A door that never starts has to lose the target, otherwise it would cut a later move short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverStarts) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
door.start_window_ms_ = 200;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0110);
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// The door ignored the command. 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, 0x003C, 0x0000}));
door.update();
ASSERT_TRUE(door.is_valid());
// 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).first, 0x0000);
}
} // namespace esphome::hoermann_hcp::testing