mirror of
https://github.com/esphome/esphome.git
synced 2026-10-10 13:33:06 +00:00
923 lines
34 KiB
C++
923 lines
34 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include <chrono>
|
|
#include <thread>
|
|
|
|
#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h"
|
|
|
|
#include "../common.h"
|
|
|
|
namespace esphome::hoermann_hcp::testing {
|
|
|
|
namespace {
|
|
|
|
// A status broadcast with the door at the given position and state that also carries the lamp register.
|
|
RegisterValues door_broadcast(uint16_t position, uint16_t state, uint16_t lamp = 0x0000) {
|
|
return make_registers({0x0000, position, state, 0x0000, 0x0000, 0x0000, lamp});
|
|
}
|
|
|
|
// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible.
|
|
class CountingHoermannHcpLight : public HoermannHcpLight {
|
|
public:
|
|
using HoermannHcpLight::HoermannHcpLight;
|
|
|
|
void write_state(light::LightState *state) override {
|
|
this->writes++;
|
|
HoermannHcpLight::write_state(state);
|
|
}
|
|
|
|
int writes{0};
|
|
};
|
|
|
|
// Drives the platform against a real LightState. Boots off (no persistence) by default, which
|
|
// keeps setup() clear of preferences.
|
|
struct LightFixture {
|
|
TestableHoermannHcp door;
|
|
CountingHoermannHcpLight output{&door};
|
|
light::LightState state{&output};
|
|
|
|
explicit LightFixture(bool boot_on = false) {
|
|
if (boot_on) {
|
|
this->state.set_state_callback([](light::LightStateRTCState &s, bool /*restored*/) { s.state = true; });
|
|
}
|
|
this->output.setup();
|
|
// setup() queues the restored state for write_state(); the first settle() below delivers it, which is the
|
|
// boot ordering tests need to be able to place around the bus controller coming up.
|
|
this->state.setup();
|
|
}
|
|
|
|
// Brings the bus controller up and lets the platform read the lamp once, which is what a device does before
|
|
// any user command can arrive.
|
|
void bring_up() {
|
|
connect_controller(this->door);
|
|
this->report_lamp(false);
|
|
}
|
|
|
|
// Issues a command the way Home Assistant would, then lets the state machine settle.
|
|
void command(bool on) {
|
|
auto call = this->state.make_call();
|
|
call.set_state(on);
|
|
call.perform();
|
|
this->settle();
|
|
}
|
|
|
|
// Delivers a status broadcast and runs the hub's notification pass.
|
|
void report_broadcast(const RegisterValues ®isters) {
|
|
this->door.on_write_registers(BROADCAST_REG, registers);
|
|
this->pump();
|
|
}
|
|
|
|
void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); }
|
|
|
|
// Runs the hub's notification pass and lets the resulting publishes settle.
|
|
void pump() {
|
|
this->door.update();
|
|
this->settle();
|
|
}
|
|
|
|
void settle() {
|
|
for (int i = 0; i < 4; i++)
|
|
this->state.loop();
|
|
}
|
|
|
|
bool entity_on() { return this->state.remote_values.is_on(); }
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit.
|
|
TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) {
|
|
HoermannHcp door;
|
|
EXPECT_FALSE(door.is_light_on());
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014));
|
|
EXPECT_TRUE(door.is_light_on());
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
EXPECT_FALSE(door.is_light_on());
|
|
}
|
|
|
|
// A request sends one command, then nothing until the lamp reports.
|
|
TEST(HoermannHcpLightTest, RequestSendsOneCommandUntilTheLampReports) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
|
|
auto [light, light_2] = poll_command(door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
|
|
// Asking again sends no second command.
|
|
ASSERT_TRUE(door.set_light(true));
|
|
auto [waiting, waiting_2] = poll_command(door);
|
|
EXPECT_EQ(waiting, 0x0000);
|
|
EXPECT_EQ(waiting_2, 0x0000);
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_TRUE(door.is_light_heading_on());
|
|
auto [idle, idle_2] = poll_command(door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// The wait for the lamp report starts at the fetch and ends with the report.
|
|
TEST(HoermannHcpLightTest, FetchStartsTheWaitForTheLamp) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
EXPECT_FALSE(door.light_command_sent_);
|
|
const uint32_t requested_at = door.light_since_;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
|
|
|
poll_command(door);
|
|
EXPECT_TRUE(door.light_command_sent_);
|
|
EXPECT_GT(door.light_since_, requested_at);
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_FALSE(door.light_command_sent_);
|
|
}
|
|
|
|
// A request the lamp already meets, or one taken back before the fetch, sends nothing.
|
|
TEST(HoermannHcpLightTest, RequestTheLampAlreadyMeetsSendsNothing) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
|
|
ASSERT_TRUE(door.set_light(false));
|
|
EXPECT_FALSE(door.light_requested_);
|
|
|
|
ASSERT_TRUE(door.set_light(true));
|
|
ASSERT_TRUE(door.set_light(false));
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_FALSE(door.is_light_heading_on());
|
|
auto [idle, idle_2] = poll_command(door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A request reversed after the fetch sends again once the first lands.
|
|
TEST(HoermannHcpLightTest, RequestReversedAfterTheFetchSendsAgainOnceTheFirstLands) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_ON);
|
|
|
|
ASSERT_TRUE(door.set_light(false));
|
|
EXPECT_FALSE(door.is_light_heading_on());
|
|
// Not decided again before the first command is reported.
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
|
|
// It lands, away from the request.
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
EXPECT_TRUE(door.light_requested_);
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_OFF);
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// A lamp gone unknown drops the request, so no command is decided against a stale state.
|
|
TEST(HoermannHcpLightTest, RequestIsNotSentAgainstAStaleLamp) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
|
|
ASSERT_FALSE(door.is_light_known());
|
|
|
|
EXPECT_FALSE(door.light_requested_);
|
|
auto [idle, idle_2] = poll_command(door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// The lamp command, like a door command, only goes out in a status answer.
|
|
TEST(HoermannHcpLightTest, LampCommandWaitsForAStatusPoll) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
|
|
// 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_FALSE(door.light_command_sent_);
|
|
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_ON);
|
|
}
|
|
|
|
// A lamp switched at the door without a request is followed, never switched back.
|
|
TEST(HoermannHcpLightTest, DoorSideLampChangeWithoutARequestSendsNothing) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
EXPECT_TRUE(door.is_light_heading_on());
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
EXPECT_FALSE(door.is_light_heading_on());
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// Decided at the fetch: a lamp already switched to the request at the door gets nothing.
|
|
TEST(HoermannHcpLightTest, DoorSideLampChangeToTheRequestBeforeTheFetchSendsNothing) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// An unreported command is given up after the timeout and not sent again.
|
|
TEST(HoermannHcpLightTest, WatchdogGivesUpOnAnUnreportedCommand) {
|
|
TestableHoermannHcp door;
|
|
door.connection_timeout_ms_ = 20;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_ON);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
// The broadcast keeps the connection alive.
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
door.update();
|
|
ASSERT_TRUE(door.is_valid());
|
|
|
|
EXPECT_FALSE(door.light_requested_);
|
|
EXPECT_FALSE(door.light_command_sent_);
|
|
EXPECT_FALSE(door.is_light_heading_on());
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// A door command goes before a pending lamp command.
|
|
TEST(HoermannHcpLightTest, DoorCommandGoesBeforeTheLampCommand) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
ASSERT_TRUE(door.close_door());
|
|
|
|
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
|
|
// The lamp waits for the door to rest: the motor ignores it while moving and may switch it as it starts.
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200, 0x0000, 0x0000, 0x0000, 0x0000}));
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000}));
|
|
auto [light, light_2] = poll_command(door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// Switching a lit lamp off sends the off command.
|
|
TEST(HoermannHcpLightTest, OffSendsTheOffCommand) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
ASSERT_TRUE(door.set_light(false));
|
|
auto [light, light_2] = poll_command(door);
|
|
EXPECT_EQ(light, LIGHT_OFF);
|
|
EXPECT_EQ(light_2, LIGHT_OFF_2);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
EXPECT_FALSE(door.is_light_heading_on());
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// A command sent again after the lamp moved away from a changed request gets its own deadline.
|
|
TEST(HoermannHcpLightTest, SendingAgainRestartsTheDeadline) {
|
|
TestableHoermannHcp door;
|
|
// Only the second wait has to stay under the timeout; a late wakeup only lengthens the first.
|
|
door.connection_timeout_ms_ = 500;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_ON);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(450));
|
|
ASSERT_TRUE(door.set_light(false));
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); // the first command lands
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
connect_controller(door);
|
|
door.update();
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_OFF);
|
|
}
|
|
|
|
// A lamp command held while the door starts and moves is not given up while it waits.
|
|
TEST(HoermannHcpLightTest, LampCommandHeldForTheDoorIsNotGivenUp) {
|
|
TestableHoermannHcp door;
|
|
door.connection_timeout_ms_ = 20;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
ASSERT_TRUE(door.close_door());
|
|
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
connect_controller(door);
|
|
door.update();
|
|
EXPECT_TRUE(door.light_requested_);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200, 0x0000, 0x0000, 0x0000, 0x0000}));
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
connect_controller(door);
|
|
door.update();
|
|
EXPECT_TRUE(door.light_requested_);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000}));
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_ON);
|
|
}
|
|
|
|
// The motor ignores the lamp while its door moves, so a request then goes out once the door rests.
|
|
TEST(HoermannHcpLightTest, LampWaitsForAMovingDoorToRest) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200, 0x0000, 0x0000, 0x0000, 0x0010}));
|
|
ASSERT_TRUE(door.set_light(false));
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0010}));
|
|
EXPECT_EQ(poll_command(door).first, LIGHT_OFF);
|
|
}
|
|
|
|
// A lamp the motor switches on as the door starts already meets the request, so nothing is sent.
|
|
TEST(HoermannHcpLightTest, LampSwitchedOnByTheStartGetsNoCommand) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
ASSERT_TRUE(door.close_door());
|
|
|
|
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200, 0x0000, 0x0000, 0x0000, 0x0010}));
|
|
EXPECT_TRUE(door.is_light_on());
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
}
|
|
|
|
// A target stop goes out before a pending lamp command.
|
|
TEST(HoermannHcpLightTest, LampRequestDoesNotDelayTheTargetStop) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
// Stopped at 60/200 = 0.3, so a 0.5 target is armed, then the door opens.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003C, 0x0000));
|
|
ASSERT_TRUE(door.set_position(0.5f));
|
|
consume_command(door);
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003E, 0x0100));
|
|
|
|
ASSERT_TRUE(door.set_light(true));
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0078, 0x0100));
|
|
auto [stop, stop_2] = poll_command(door);
|
|
EXPECT_EQ(stop, 0x0140); // COMMAND_IMPULSE
|
|
EXPECT_EQ(stop_2, 0x0000);
|
|
// The lamp follows once the door rests.
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0082, 0x0000));
|
|
auto [light, light_2] = poll_command(door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
}
|
|
|
|
// The target's start deadline is its own, so switching the lamp cannot keep a stale target alive.
|
|
TEST(HoermannHcpLightTest, LampCommandDoesNotExtendTheTargetWatchdog) {
|
|
TestableHoermannHcp door;
|
|
door.connection_timeout_ms_ = 20;
|
|
door.start_window_ms_ = 20;
|
|
connect_controller(door);
|
|
// The door is stopped, so an opening target is armed but not yet under way.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003C, 0x0000));
|
|
ASSERT_TRUE(door.set_position(0.5f));
|
|
consume_command(door);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
// The door never started, so the start window closes along with the target.
|
|
consume_command(door);
|
|
door.update();
|
|
ASSERT_TRUE(door.set_light(true));
|
|
consume_command(door);
|
|
door.update();
|
|
|
|
// The target expired on its own schedule, so a later opening move runs freely.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0050, 0x0100));
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0078, 0x0100));
|
|
auto [idle, idle_2] = poll_command(door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A lamp held while the door moves is not given up, and leaves the cover target alone.
|
|
TEST(HoermannHcpLightTest, HeldLampKeepsTheCoverTarget) {
|
|
TestableHoermannHcp door;
|
|
door.connection_timeout_ms_ = 20;
|
|
connect_controller(door);
|
|
// Stopped at 60/200 = 0.3, so a 0.5 target is armed, then the door opens.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003C, 0x0000));
|
|
ASSERT_TRUE(door.set_position(0.5f));
|
|
consume_command(door);
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003E, 0x0100));
|
|
|
|
ASSERT_TRUE(door.set_light(true));
|
|
EXPECT_EQ(poll_command(door).first, 0x0000);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0050, 0x0100));
|
|
door.update();
|
|
ASSERT_TRUE(door.light_requested_);
|
|
|
|
// The target survived, so the door is still stopped on the way.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0078, 0x0100));
|
|
auto [stop, stop_2] = poll_command(door);
|
|
EXPECT_EQ(stop, 0x0140); // COMMAND_IMPULSE
|
|
EXPECT_EQ(stop_2, 0x0000);
|
|
}
|
|
|
|
// A lost connection clears both the target and the lamp request.
|
|
TEST(HoermannHcpLightTest, ConnectionLossClearsTheTargetAndTheLampRequest) {
|
|
TestableHoermannHcp door;
|
|
door.connection_timeout_ms_ = 20;
|
|
connect_controller(door);
|
|
// Stopped at 60/200 = 0.3, so a 0.5 target is armed, then the door opens.
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003C, 0x0000));
|
|
ASSERT_TRUE(door.set_position(0.5f));
|
|
consume_command(door);
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x003E, 0x0100));
|
|
ASSERT_TRUE(door.set_light(true));
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
door.update();
|
|
ASSERT_FALSE(door.is_valid());
|
|
EXPECT_FALSE(door.light_requested_);
|
|
|
|
// Back on the bus and past the old target, lamp still off: nothing is sent.
|
|
connect_controller(door);
|
|
door.on_write_registers(BROADCAST_REG, door_broadcast(0x0078, 0x0100));
|
|
auto [idle, idle_2] = poll_command(door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// Without a bus controller the request cannot be delivered, and the caller is told.
|
|
TEST(HoermannHcpLightTest, LampRequestIsRefusedWhileDisconnected) {
|
|
HoermannHcp door;
|
|
EXPECT_FALSE(door.set_light(true));
|
|
}
|
|
|
|
// A lamp that has not been reported cannot be requested.
|
|
TEST(HoermannHcpLightTest, LampRequestIsRefusedUntilTheLampIsReported) {
|
|
TestableHoermannHcp door;
|
|
connect_controller(door);
|
|
EXPECT_FALSE(door.set_light(true));
|
|
|
|
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
EXPECT_TRUE(door.set_light(true));
|
|
}
|
|
|
|
// Switching the entity on sends one command, and the door's own report does not send a second.
|
|
TEST(HoermannHcpLightPlatformTest, CommandIsSentOnceAndSettles) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
auto [light, light_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
|
|
// The lamp is now on, and the resulting broadcast must not send another command.
|
|
fixture.report_lamp(true);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A broadcast before the fetch must not take the request back.
|
|
TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingRequestKeepsTheCommand) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
ASSERT_TRUE(fixture.door.light_requested_);
|
|
|
|
// A door movement fires the state callback before the fetch.
|
|
fixture.report_broadcast(door_broadcast(0x0064, 0x0100));
|
|
|
|
EXPECT_TRUE(fixture.door.light_requested_);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
}
|
|
|
|
// A lamp switched on at the door itself has to reach the entity.
|
|
TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
ASSERT_FALSE(fixture.entity_on());
|
|
|
|
fixture.report_lamp(true);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// A refused command must leave the entity showing the lamp, not the request.
|
|
TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) {
|
|
LightFixture fixture; // never connected, so the hub refuses every command
|
|
|
|
fixture.command(true);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// A lamp gone unknown drops the request; the entity follows the next report.
|
|
TEST(HoermannHcpLightPlatformTest, LampBecomingUnknownDropsTheRequest) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
EXPECT_EQ(poll_command(fixture.door).first, LIGHT_ON);
|
|
|
|
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_FALSE(fixture.door.light_command_sent_);
|
|
EXPECT_TRUE(fixture.output.status_has_warning());
|
|
|
|
// The door ignored the command.
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
}
|
|
|
|
// A command never fetched is dropped and not sent once polling resumes.
|
|
TEST(HoermannHcpLightPlatformTest, UnfetchedRequestIsDroppedAndNeverSent) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
ASSERT_TRUE(fixture.door.light_requested_);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
|
|
// Broadcasts keep the connection up, but nothing polls.
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.report_lamp(false);
|
|
ASSERT_TRUE(fixture.door.is_valid());
|
|
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A door broadcast before the lamp reports must not publish the state the lamp is about to leave.
|
|
TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
poll_command(fixture.door);
|
|
ASSERT_TRUE(fixture.door.light_command_sent_);
|
|
ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported
|
|
|
|
fixture.report_broadcast(door_broadcast(0x0064, 0x0100));
|
|
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
}
|
|
|
|
// A request lost with the connection leaves the entity on the lamp.
|
|
TEST(HoermannHcpLightPlatformTest, RequestLostWithTheConnectionReturnsTheEntityToTheLamp) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
ASSERT_TRUE(fixture.door.light_requested_);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.pump(); // the connection times out and the request goes with it
|
|
ASSERT_FALSE(fixture.door.is_valid());
|
|
|
|
connect_controller(fixture.door);
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
}
|
|
|
|
// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not
|
|
// decide whether a command is needed after it.
|
|
TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
fixture.report_lamp(true);
|
|
ASSERT_TRUE(fixture.entity_on());
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.pump();
|
|
ASSERT_FALSE(fixture.door.is_valid());
|
|
|
|
// Back on the bus, but nothing has said what the lamp is doing yet.
|
|
connect_controller(fixture.door);
|
|
fixture.pump();
|
|
ASSERT_TRUE(fixture.door.is_valid());
|
|
ASSERT_FALSE(fixture.door.is_light_known());
|
|
|
|
fixture.command(false);
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy.
|
|
TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) {
|
|
LightFixture fixture;
|
|
connect_controller(fixture.door);
|
|
fixture.pump();
|
|
ASSERT_TRUE(fixture.door.is_valid());
|
|
EXPECT_TRUE(fixture.output.status_has_warning());
|
|
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.output.status_has_warning());
|
|
}
|
|
|
|
// On, off, on while the command is out ends on the last request with one command.
|
|
TEST(HoermannHcpLightPlatformTest, QuickTapsSettleOnTheLastRequest) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
EXPECT_EQ(poll_command(fixture.door).first, LIGHT_ON);
|
|
fixture.command(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
fixture.command(true);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
|
|
fixture.report_lamp(true);
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
}
|
|
|
|
// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll
|
|
// must not be mistaken for it and swallowed.
|
|
TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) {
|
|
LightFixture fixture;
|
|
connect_controller(fixture.door);
|
|
fixture.settle(); // the boot replay lands here, while the lamp is still unknown
|
|
|
|
// The first status broadcast arrives, but the hub has not polled yet, so no callback has fired.
|
|
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
|
ASSERT_TRUE(fixture.door.is_light_known());
|
|
|
|
fixture.command(true);
|
|
auto [light, light_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
}
|
|
|
|
// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp
|
|
// that is already on must not be switched off by that replay.
|
|
TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) {
|
|
LightFixture fixture;
|
|
// The controller is already up and reporting the lamp lit before the entity's first loop.
|
|
connect_controller(fixture.door);
|
|
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
ASSERT_TRUE(fixture.door.is_light_on());
|
|
|
|
fixture.settle();
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
// Once the platform has read the lamp the entity follows it, still without commanding anything.
|
|
fixture.pump();
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
}
|
|
|
|
// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before
|
|
// the first status broadcast must not be judged against a lamp state that was never read.
|
|
TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) {
|
|
LightFixture fixture;
|
|
// The controller polls for commands, which is enough to connect but carries no lamp register.
|
|
connect_controller(fixture.door);
|
|
fixture.pump();
|
|
ASSERT_TRUE(fixture.door.is_valid());
|
|
ASSERT_FALSE(fixture.door.is_light_known());
|
|
|
|
fixture.command(true);
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// A reversal after the fetch shows at once and switches back once the first lands.
|
|
TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterFetchSwitchesBackOnceTheFirstLands) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
EXPECT_EQ(poll_command(fixture.door).first, LIGHT_ON);
|
|
ASSERT_FALSE(fixture.door.is_light_on());
|
|
|
|
fixture.command(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
|
|
// The first command lands and is reported, but the entity is already heading for off.
|
|
fixture.report_lamp(true);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
auto [light, light_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(light, LIGHT_OFF);
|
|
EXPECT_EQ(light_2, LIGHT_OFF_2);
|
|
|
|
// The second command lands too, and the lamp finally agrees with the request.
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
}
|
|
|
|
// A refusal leaves no request, so the entity keeps following the lamp.
|
|
TEST(HoermannHcpLightPlatformTest, RefusalWithoutARequestStillFollowsTheLamp) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.pump();
|
|
ASSERT_FALSE(fixture.door.is_valid());
|
|
|
|
// Refused because the bus is down, so no command is heading for the lamp.
|
|
fixture.command(true);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
|
|
// The controller returns and reports the lamp switched on at the door itself.
|
|
connect_controller(fixture.door);
|
|
fixture.report_lamp(true);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
}
|
|
|
|
// A command the door never carries out must not leave the entity on the request.
|
|
TEST(HoermannHcpLightPlatformTest, CommandTheDoorIgnoresStopsBeingWaitedFor) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
consume_command(fixture.door); // the door takes the command, then does nothing
|
|
ASSERT_TRUE(fixture.door.light_command_sent_);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.report_lamp(false); // the lamp is still off, and keeps saying so
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that
|
|
// counts as a change the light never hears about it and swallows the first command.
|
|
TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) {
|
|
LightFixture fixture;
|
|
// A command poll connects the controller without saying anything about the lamp.
|
|
connect_controller(fixture.door);
|
|
fixture.pump();
|
|
ASSERT_FALSE(fixture.door.is_light_known());
|
|
|
|
// Closed, at rest, lamp off: every field matches the defaults the hub started with.
|
|
fixture.report_broadcast(door_broadcast(0x0000, 0x4000));
|
|
ASSERT_TRUE(fixture.door.is_light_known());
|
|
|
|
fixture.command(true);
|
|
auto [light, light_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(light, LIGHT_ON);
|
|
EXPECT_EQ(light_2, LIGHT_ON_2);
|
|
}
|
|
|
|
// Changing the request does not move the deadline of the command already out.
|
|
TEST(HoermannHcpLightPlatformTest, ChangingTheRequestKeepsTheWatchdogArmed) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
consume_command(fixture.door); // the command is fetched but never reported back
|
|
ASSERT_TRUE(fixture.door.light_command_sent_);
|
|
const uint32_t sent_at = fixture.door.light_since_;
|
|
fixture.command(false);
|
|
fixture.command(true);
|
|
ASSERT_EQ(fixture.door.light_since_, sent_at);
|
|
|
|
// The door still says nothing about the lamp, so the wait has to time out on its own.
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
fixture.report_lamp(false);
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// Asking again before the fetch does not move the deadline of the pending request.
|
|
TEST(HoermannHcpLightPlatformTest, AskingAgainKeepsTheFetchDeadline) {
|
|
LightFixture fixture;
|
|
fixture.door.connection_timeout_ms_ = 20;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
ASSERT_TRUE(fixture.door.light_requested_);
|
|
const uint32_t requested_at = fixture.door.light_since_;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
ASSERT_TRUE(fixture.door.set_light(true));
|
|
EXPECT_EQ(fixture.door.light_since_, requested_at);
|
|
}
|
|
|
|
// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would
|
|
// re-enter write_state() on every loop, so the platform would never stop asking to be written.
|
|
TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) {
|
|
LightFixture fixture;
|
|
connect_controller(fixture.door);
|
|
fixture.settle();
|
|
ASSERT_FALSE(fixture.door.is_light_known());
|
|
|
|
// The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed.
|
|
fixture.command(false);
|
|
const int settled_writes = fixture.output.writes;
|
|
fixture.settle();
|
|
EXPECT_EQ(fixture.output.writes, settled_writes);
|
|
}
|
|
|
|
// Booting the entity on replays a lit state the door has never confirmed, so it has to be
|
|
// adopted back to what is known rather than turned into a command.
|
|
TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) {
|
|
LightFixture fixture{/*boot_on=*/true};
|
|
connect_controller(fixture.door);
|
|
fixture.settle();
|
|
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
}
|
|
|
|
// A reversal before the fetch takes the request back, so nothing is sent.
|
|
TEST(HoermannHcpLightPlatformTest, ReversingPressBeforeTheFetchSendsNothing) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
|
|
fixture.command(true);
|
|
ASSERT_TRUE(fixture.door.light_requested_);
|
|
|
|
fixture.command(false);
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_FALSE(fixture.entity_on());
|
|
|
|
// Nothing is left for the controller to fetch, so the lamp stays off as asked.
|
|
auto [idle, idle_2] = poll_command(fixture.door);
|
|
EXPECT_EQ(idle, 0x0000);
|
|
EXPECT_EQ(idle_2, 0x0000);
|
|
}
|
|
|
|
// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag
|
|
// itself rather than command against what was read before.
|
|
TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
ASSERT_TRUE(fixture.door.is_light_known());
|
|
|
|
fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000}));
|
|
|
|
EXPECT_FALSE(fixture.door.is_light_known());
|
|
EXPECT_TRUE(fixture.output.status_has_warning());
|
|
}
|
|
|
|
// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that
|
|
// gap, the write still carries the old value and must not be taken for a request to switch the lamp back.
|
|
TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) {
|
|
LightFixture fixture;
|
|
fixture.bring_up();
|
|
// Lamp unknown, so the request below is refused and published back.
|
|
fixture.door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
|
|
|
|
auto call = fixture.state.make_call();
|
|
call.set_state(true);
|
|
call.perform();
|
|
fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass
|
|
|
|
// The lamp is reported again, switched on at the door, before that publish arrives.
|
|
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
|
fixture.settle();
|
|
|
|
EXPECT_FALSE(fixture.door.light_requested_);
|
|
EXPECT_TRUE(fixture.entity_on());
|
|
EXPECT_EQ(poll_command(fixture.door).first, 0x0000);
|
|
}
|
|
|
|
} // namespace esphome::hoermann_hcp::testing
|