#include #include #include #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