mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[hoermann_hcp] Add garage light control (#18190)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
J. Nick Koston
pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
parent
622942482c
commit
25c0c2c97b
@@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b
|
||||
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
|
||||
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
|
||||
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
|
||||
// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
|
||||
static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
|
||||
|
||||
// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
|
||||
// rest names the button - the low byte for the door commands, the second register for those that do not fit
|
||||
// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
|
||||
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
|
||||
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
|
||||
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
|
||||
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
|
||||
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
|
||||
|
||||
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
|
||||
// its low byte tells a plain stop from the vent position.
|
||||
@@ -58,17 +65,29 @@ void HoermannHcp::update() {
|
||||
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
|
||||
// otherwise block every later one for as long as it keeps broadcasting.
|
||||
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
// Dropping after the press was presented leaves the door without its release value, which is worth saying
|
||||
// apart from a command the controller never looked at.
|
||||
if (this->command_written_at_ != 0) {
|
||||
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
|
||||
this->next_command_->name);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
}
|
||||
this->drop_command_();
|
||||
// Children may have assumed the command would land, so let them re-derive from the door.
|
||||
this->changed_ = true;
|
||||
}
|
||||
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
|
||||
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
|
||||
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
|
||||
this->clear_target_();
|
||||
}
|
||||
// The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
|
||||
if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
|
||||
this->forget_light_toggles_();
|
||||
}
|
||||
if (this->changed_) {
|
||||
this->changed_ = false;
|
||||
this->state_callback_.call();
|
||||
@@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
|
||||
this->on_state_reg_(registers[2]);
|
||||
if (registers.size() > 1)
|
||||
this->on_position_reg_(registers[1]);
|
||||
if (registers.size() > 6) {
|
||||
this->on_light_reg_(registers[6]);
|
||||
return {};
|
||||
}
|
||||
// Nothing refreshes the lamp any more, so what was read before must not be commanded against.
|
||||
this->set_light_seen_(false);
|
||||
if (!this->short_broadcast_logged_) {
|
||||
this->short_broadcast_logged_ = true;
|
||||
ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
this->command_written_at_ = millis();
|
||||
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
|
||||
registers.push_back(command->pressed_value);
|
||||
registers.push_back(0x0000);
|
||||
registers.push_back(command->pressed_value_2);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Still inside the key-press window, so keep presenting 0x0000.
|
||||
// Between the two events there is nothing to report, including in the second register.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
@@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
this->next_command_ = nullptr;
|
||||
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
|
||||
// to wait for, so it must not re-arm the watchdog.
|
||||
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
|
||||
this->light_toggle_released_at_ = millis();
|
||||
registers.push_back(command->released_value);
|
||||
registers.push_back(0x0000);
|
||||
registers.push_back(command->released_value_2);
|
||||
}
|
||||
|
||||
void HoermannHcp::on_position_reg_(uint16_t value) {
|
||||
@@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
|
||||
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
|
||||
}
|
||||
|
||||
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
|
||||
// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
|
||||
void HoermannHcp::on_light_reg_(uint16_t value) {
|
||||
this->set_light_seen_(true);
|
||||
this->set_light_on_((value & 0x0010) != 0);
|
||||
}
|
||||
|
||||
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
if (!this->valid_) {
|
||||
// Queueing now would fire the command whenever the controller comes back, which may be much later.
|
||||
@@ -236,6 +276,7 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
return false;
|
||||
}
|
||||
// A new command supersedes any half-open target the door was still travelling to.
|
||||
if (command.clears_target)
|
||||
this->clear_target_();
|
||||
this->next_command_ = &command;
|
||||
this->command_queued_at_ = millis();
|
||||
@@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
bool HoermannHcp::toggle_light() {
|
||||
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
|
||||
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
|
||||
return false;
|
||||
}
|
||||
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
|
||||
return false;
|
||||
this->light_toggles_in_flight_++;
|
||||
return true;
|
||||
}
|
||||
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
|
||||
|
||||
uint8_t HoermannHcp::unsent_light_toggles_() const {
|
||||
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
bool HoermannHcp::cancel_light_toggle() {
|
||||
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
|
||||
// command can be withdrawn.
|
||||
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
|
||||
return false;
|
||||
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
|
||||
this->drop_command_();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HoermannHcp::stop_door() {
|
||||
if (!is_moving(this->door_state_)) {
|
||||
@@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) {
|
||||
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
|
||||
return false;
|
||||
this->target_position_ = position;
|
||||
this->target_queued_at_ = millis();
|
||||
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
|
||||
// A door already travelling that way is on its way; one moving the other way has to turn around first.
|
||||
this->target_started_ = this->door_state_ == this->target_direction_;
|
||||
@@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) {
|
||||
}
|
||||
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
|
||||
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
|
||||
this->drop_command_();
|
||||
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
|
||||
this->clear_target_();
|
||||
this->forget_light_toggles_();
|
||||
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
|
||||
this->set_light_seen_(false);
|
||||
this->short_broadcast_logged_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::drop_command_() {
|
||||
const bool was_light_toggle = this->is_light_toggle_pending_();
|
||||
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
if (was_light_toggle) {
|
||||
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
|
||||
this->light_toggle_settled_();
|
||||
} else {
|
||||
this->clear_target_();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::light_toggle_settled_() {
|
||||
if (this->light_toggles_in_flight_ == 0)
|
||||
return;
|
||||
this->light_toggles_in_flight_--;
|
||||
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
|
||||
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// The light was showing where the lamp was heading, so it has to be told to look again.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
void HoermannHcp::forget_light_toggles_() {
|
||||
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
|
||||
const uint8_t unsent = this->unsent_light_toggles_();
|
||||
if (this->light_toggles_in_flight_ == unsent)
|
||||
return;
|
||||
this->light_toggles_in_flight_ = unsent;
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_door_state_(DoorState state) {
|
||||
@@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() {
|
||||
this->target_started_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_on_(bool on) {
|
||||
if (this->light_on_ == on)
|
||||
return;
|
||||
this->light_on_ = on;
|
||||
this->changed_ = true;
|
||||
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
|
||||
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
|
||||
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
|
||||
return;
|
||||
}
|
||||
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
|
||||
this->light_toggle_settled_();
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_seen_(bool seen) {
|
||||
if (this->light_seen_ == seen)
|
||||
return;
|
||||
this->light_seen_ = seen;
|
||||
// A resting door changes nothing else, so without this the light would never hear about it.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
|
||||
@@ -22,11 +22,15 @@ enum class DoorState : uint8_t {
|
||||
};
|
||||
|
||||
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
|
||||
// short delay the released value. The second command register remains zero.
|
||||
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
uint16_t released_value;
|
||||
uint16_t pressed_value_2{0x0000};
|
||||
uint16_t released_value_2{0x0000};
|
||||
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
|
||||
bool clears_target{true};
|
||||
};
|
||||
|
||||
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
@@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
bool impulse_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
bool toggle_light();
|
||||
|
||||
DoorState get_door_state() const { return this->door_state_; }
|
||||
float get_current_position() const { return this->current_position_; }
|
||||
bool is_valid() const { return this->valid_; }
|
||||
bool is_light_on() const { return this->light_on_; }
|
||||
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
|
||||
// valid without saying anything about the lamp, so is_light_on() would still be its default.
|
||||
bool is_light_known() const { return this->light_seen_; }
|
||||
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
|
||||
// lamp still reads as its old self, so this is what a request has to be judged against.
|
||||
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
|
||||
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
|
||||
// instead of fighting it. Returns false if there is nothing to cancel.
|
||||
bool cancel_light_toggle();
|
||||
|
||||
protected:
|
||||
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
|
||||
bool is_light_toggle_pending_() const;
|
||||
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
|
||||
uint8_t unsent_light_toggles_() const;
|
||||
void record_response_();
|
||||
// Returns false when the bus controller has not fetched the previous command yet.
|
||||
bool queue_command_(const HoermannHcpCommand &command);
|
||||
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
|
||||
void drop_command_();
|
||||
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
|
||||
void light_toggle_settled_();
|
||||
// Stops expecting the toggles the door has already been shown to reach the lamp.
|
||||
void forget_light_toggles_();
|
||||
// Appends the two key-press registers and advances the pending command's press/release state.
|
||||
void push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
void on_light_reg_(uint16_t value);
|
||||
|
||||
void set_valid_(bool valid);
|
||||
void set_door_state_(DoorState state);
|
||||
@@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
void update_current_position_();
|
||||
bool has_target_() const { return this->target_position_ != 0.0f; }
|
||||
void clear_target_();
|
||||
void set_light_on_(bool on);
|
||||
void set_light_seen_(bool seen);
|
||||
|
||||
CallbackManager<void()> state_callback_;
|
||||
|
||||
@@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
// Pending command / key-press state machine.
|
||||
const HoermannHcpCommand *next_command_{nullptr};
|
||||
uint32_t command_queued_at_{0};
|
||||
// Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline.
|
||||
uint32_t target_queued_at_{0};
|
||||
uint32_t command_written_at_{0};
|
||||
uint32_t last_response_{0};
|
||||
// When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the
|
||||
// wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline.
|
||||
uint32_t light_toggle_released_at_{0};
|
||||
|
||||
// A command is "pressed" for this long before its end value is sent.
|
||||
uint16_t key_press_delay_ms_{100};
|
||||
@@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
DoorState target_direction_{DoorState::STOPPED};
|
||||
// Position as reported by the bus controller, 0..200 across the full travel.
|
||||
uint8_t position_raw_{0};
|
||||
uint8_t light_toggles_in_flight_{0};
|
||||
bool target_started_{false};
|
||||
bool valid_{false};
|
||||
bool changed_{false};
|
||||
bool light_on_{false};
|
||||
bool light_seen_{false};
|
||||
bool short_broadcast_logged_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
HoermannHcpLight = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpLight", light.LightOutput, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
light.light_schema(HoermannHcpLight, light.LightType.BINARY)
|
||||
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
var = await light.new_light(config, parent)
|
||||
await cg.register_component(var, config)
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "hoermann_hcp_light.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.light";
|
||||
|
||||
light::LightTraits HoermannHcpLight::get_traits() {
|
||||
auto traits = light::LightTraits();
|
||||
traits.set_supported_color_modes({light::ColorMode::ON_OFF});
|
||||
return traits;
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup() {
|
||||
// Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then.
|
||||
this->status_set_warning(LOG_STR("waiting for the bus controller"));
|
||||
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; }
|
||||
|
||||
void HoermannHcpLight::write_state(light::LightState *state) {
|
||||
bool binary;
|
||||
state->current_values_as_binary(&binary);
|
||||
// A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on,
|
||||
// so it is recognised by the value it carried rather than by the current one.
|
||||
const optional<bool> published = this->published_state_;
|
||||
this->published_state_.reset();
|
||||
// LightState::setup() always performs a call, so the very first write here is the restored state coming back
|
||||
// rather than a request.
|
||||
const bool restored = !this->boot_replay_done_;
|
||||
this->boot_replay_done_ = true;
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (binary == heading_on)
|
||||
return;
|
||||
if (restored) {
|
||||
ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing");
|
||||
} else if (published != binary) {
|
||||
if (!this->parent_->is_light_known()) {
|
||||
// Commanding a lamp that has not been read could switch off one that is already on.
|
||||
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
|
||||
} else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) {
|
||||
// A toggle the controller has not fetched is withdrawn outright rather than fought with a second one.
|
||||
return;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Light command was not accepted by the door");
|
||||
}
|
||||
}
|
||||
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
void HoermannHcpLight::update_from_state_() {
|
||||
if (this->light_state_ == nullptr)
|
||||
return;
|
||||
if (!this->parent_->is_valid()) {
|
||||
this->status_set_warning(LOG_STR("bus controller not responding"));
|
||||
return;
|
||||
}
|
||||
if (!this->parent_->is_light_known()) {
|
||||
// Commands are refused until the door says, so say so rather than looking healthy and doing nothing.
|
||||
this->status_set_warning(LOG_STR("door has not reported the lamp"));
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (this->light_state_->remote_values.is_on() != heading_on)
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours.
|
||||
void HoermannHcpLight::publish_lamp_state_(bool on) {
|
||||
this->published_state_ = on;
|
||||
auto call = this->light_state_->make_call();
|
||||
call.set_state(on);
|
||||
// The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash.
|
||||
call.set_save(false);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpLight : public light::LightOutput, public Component {
|
||||
public:
|
||||
explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void setup_state(light::LightState *state) override;
|
||||
light::LightTraits get_traits() override;
|
||||
void write_state(light::LightState *state) override;
|
||||
|
||||
protected:
|
||||
void update_from_state_();
|
||||
void publish_lamp_state_(bool on);
|
||||
|
||||
HoermannHcp *const parent_;
|
||||
light::LightState *light_state_{nullptr};
|
||||
// Value last published and not yet seen come back, so the write carrying it is that publish, not a request.
|
||||
optional<bool> published_state_;
|
||||
// Set by the first write_state(), which is always the restored state replayed on boot.
|
||||
bool boot_replay_done_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -2,29 +2,9 @@
|
||||
|
||||
#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
#include "../common.h"
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// Exposes the connection bookkeeping so a drop can be driven without waiting one out.
|
||||
class TestableHoermannHcp : public HoermannHcp {
|
||||
public:
|
||||
using HoermannHcp::set_valid_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
namespace esphome::hoermann_hcp::testing {
|
||||
|
||||
// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected.
|
||||
TEST(HoermannHcpBinarySensorTest, StartsDisconnected) {
|
||||
@@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) {
|
||||
sensor.setup();
|
||||
ASSERT_FALSE(sensor.state);
|
||||
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
connect_controller(door);
|
||||
door.update();
|
||||
EXPECT_TRUE(sensor.state);
|
||||
|
||||
@@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) {
|
||||
int publishes = 0;
|
||||
sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; });
|
||||
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
connect_controller(door);
|
||||
door.update();
|
||||
ASSERT_EQ(publishes, 1);
|
||||
|
||||
@@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) {
|
||||
EXPECT_EQ(publishes, 1);
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <initializer_list>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp::testing {
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t STATE_REG = 0x9CB9;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
|
||||
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
|
||||
|
||||
inline RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// A status broadcast carrying the lamp register, which the door reports at index 6.
|
||||
inline RegisterValues lamp_broadcast(uint16_t lamp_reg) {
|
||||
return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg});
|
||||
}
|
||||
|
||||
// The door only accepts commands once the bus controller has actually talked to it.
|
||||
inline void connect_controller(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
}
|
||||
|
||||
// Runs one command poll (write 2 / read 8) and returns both key-press registers.
|
||||
inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_EQ(response.size(), 8u);
|
||||
if (response.size() != 8u)
|
||||
return {0xFFFF, 0xFFFF};
|
||||
return {response[2], response[3]};
|
||||
}
|
||||
|
||||
// Presents and then releases the queued command, leaving the slot free.
|
||||
inline void consume_command(HoermannHcp &door) {
|
||||
poll_command(door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(door);
|
||||
}
|
||||
|
||||
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
|
||||
class TestableHoermannHcp : public HoermannHcp {
|
||||
public:
|
||||
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
|
||||
|
||||
using HoermannHcp::connection_timeout_ms_;
|
||||
using HoermannHcp::is_light_toggle_pending_;
|
||||
using HoermannHcp::light_toggle_released_at_;
|
||||
using HoermannHcp::light_toggles_in_flight_;
|
||||
using HoermannHcp::set_valid_;
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
@@ -11,3 +11,7 @@ binary_sensor:
|
||||
- platform: hoermann_hcp
|
||||
is_connected:
|
||||
name: Garage Connected
|
||||
|
||||
light:
|
||||
- platform: hoermann_hcp
|
||||
name: Garage Light
|
||||
|
||||
@@ -2,36 +2,9 @@
|
||||
|
||||
#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
#include "../common.h"
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t STATE_REG = 0x9CB9;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// The door only accepts commands once the bus controller has actually talked to it.
|
||||
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
|
||||
|
||||
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
|
||||
uint16_t poll_command(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_EQ(response.size(), 8u);
|
||||
return response.size() == 8u ? response[2] : 0xFFFF;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
namespace esphome::hoermann_hcp::testing {
|
||||
|
||||
// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish.
|
||||
TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) {
|
||||
@@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
|
||||
cover.make_call().set_command_open().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
|
||||
}
|
||||
|
||||
// The same for cover.close, which arrives as a position of 0.0.
|
||||
@@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
|
||||
cover.make_call().set_command_close().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed
|
||||
}
|
||||
|
||||
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
|
||||
cover.make_call().set_command_toggle().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
// The door is opening, so it takes an impulse to stop it.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
cover.make_call().set_command_stop().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// A position between the end stops starts the door in the right direction; it is stopped there later.
|
||||
@@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
|
||||
HoermannHcp door; // starts out fully closed
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
|
||||
cover.make_call().set_position(0.5f).perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
|
||||
}
|
||||
|
||||
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
|
||||
@@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) {
|
||||
|
||||
cover.make_call().set_command_close().perform();
|
||||
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
EXPECT_EQ(publishes, 1);
|
||||
EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
|
||||
}
|
||||
@@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) {
|
||||
cover.setup();
|
||||
EXPECT_TRUE(cover.status_has_warning());
|
||||
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.update();
|
||||
EXPECT_FALSE(cover.status_has_warning());
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
|
||||
@@ -3,51 +3,9 @@
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
|
||||
#include "common.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t STATE_REG = 0x9CB9;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
|
||||
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// The device only accepts commands once the bus controller has actually talked to it.
|
||||
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
|
||||
|
||||
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
|
||||
uint16_t poll_command(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_EQ(response.size(), 8u);
|
||||
return response.size() == 8u ? response[2] : 0xFFFF;
|
||||
}
|
||||
|
||||
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
|
||||
class TestableHoermannHcp : public HoermannHcp {
|
||||
public:
|
||||
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
|
||||
|
||||
using HoermannHcp::connection_timeout_ms_;
|
||||
using HoermannHcp::set_valid_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
namespace esphome::hoermann_hcp::testing {
|
||||
|
||||
// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004.
|
||||
TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) {
|
||||
@@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
|
||||
// A queued control command is injected into the next command poll as a simulated key press.
|
||||
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
|
||||
HoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.open_door();
|
||||
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
|
||||
RegisterValues response;
|
||||
@@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
|
||||
// A command is held for the key-press duration, then released, and only then can the next one be queued.
|
||||
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.open_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
|
||||
// Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120.
|
||||
door.close_door();
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
|
||||
// With the command gone, the next one is accepted again.
|
||||
door.close_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed
|
||||
}
|
||||
|
||||
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
|
||||
TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) {
|
||||
HoermannHcp door;
|
||||
door.open_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
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(door);
|
||||
connect_controller(door);
|
||||
door.open_door();
|
||||
ASSERT_TRUE(door.is_valid());
|
||||
|
||||
@@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
|
||||
EXPECT_FALSE(door.is_valid());
|
||||
|
||||
// The reconnecting poll must not replay the dropped command.
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
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), 0x0220);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0220);
|
||||
}
|
||||
|
||||
// The connection is dropped by update() once the controller stops polling, which is what releases a
|
||||
@@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
|
||||
TestableHoermannHcp door;
|
||||
// Wide enough that a stall cannot expire the connection before the check below runs.
|
||||
door.connection_timeout_ms_ = 10000;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.open_door();
|
||||
|
||||
// Still inside the window: the controller counts as present.
|
||||
@@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
|
||||
door.update();
|
||||
EXPECT_FALSE(door.is_valid());
|
||||
// The pending command went with the connection instead of firing on the reconnecting poll.
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
}
|
||||
|
||||
// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to
|
||||
@@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
|
||||
TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.open_door();
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
@@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
|
||||
|
||||
// With the stale command gone, the door accepts commands again.
|
||||
door.close_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0220);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0220);
|
||||
}
|
||||
|
||||
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
|
||||
@@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) {
|
||||
// A position request below the lower snap threshold becomes a plain close command.
|
||||
TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
|
||||
HoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.set_position(0.02f);
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
@@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
|
||||
// A half-open target starts the door moving towards the requested position.
|
||||
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
|
||||
HoermannHcp door; // starts out fully closed
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.set_position(0.5f);
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
@@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
|
||||
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
|
||||
TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
|
||||
|
||||
// Position 20/200 = 0.1 while opening: short of the target, so the door keeps going.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
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), 0x0240); // COMMAND_IMPULSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
|
||||
// must be read as "already stopped" rather than "still opening".
|
||||
TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
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);
|
||||
@@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
|
||||
// Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
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(door);
|
||||
connect_controller(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
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}));
|
||||
@@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
|
||||
// A later manual open must run freely instead of being stopped at the abandoned target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
}
|
||||
|
||||
// A target armed while the door is still travelling the other way must not be judged by that old direction,
|
||||
// otherwise the very next position it reports counts as reached and stops the door where it stands.
|
||||
TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
// The door is closing, passing 60/200 = 0.3.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
|
||||
|
||||
// Still closing at 58/200 = 0.29: below the target, but not on the way to it.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
|
||||
// Now opening at 62/200 = 0.31, still short of the target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
|
||||
// Past the target at 110/200 = 0.55, so the door is stopped.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// A motor turning around can report a momentary stop; dropping the target there would let the door run on
|
||||
// to the end stop that the reversing command asked for.
|
||||
TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0110);
|
||||
|
||||
// The stop reported on the way from closing to opening.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
|
||||
@@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
|
||||
|
||||
// The door then opens and still has to be stopped at the requested position.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0240);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0240);
|
||||
}
|
||||
|
||||
// A door that never turns around has to lose the target as well, otherwise it would cut a later move short.
|
||||
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect(door);
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0110);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
// The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the
|
||||
@@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
|
||||
// A later manual open must run freely instead of being stopped at the abandoned target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(poll_command(door).first, 0x0000);
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
#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 {
|
||||
|
||||
// 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. ALWAYS_OFF keeps setup() clear of preferences.
|
||||
struct LightFixture {
|
||||
TestableHoermannHcp door;
|
||||
CountingHoermannHcpLight output{&door};
|
||||
light::LightState state{&output};
|
||||
|
||||
explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) {
|
||||
this->state.set_restore_mode(restore_mode);
|
||||
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());
|
||||
}
|
||||
|
||||
// The lamp command is the only one that drives the second command register, on both halves of the press.
|
||||
TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
ASSERT_FALSE(door.is_light_on());
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
auto [released, released_2] = poll_command(door);
|
||||
EXPECT_EQ(released, 0x0800);
|
||||
EXPECT_EQ(released_2, 0x0200);
|
||||
|
||||
// The command is spent, so the next poll carries nothing.
|
||||
auto [idle, idle_2] = poll_command(door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
}
|
||||
|
||||
// Toggling the lamp must not disturb a cover position the door is still travelling to.
|
||||
TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door);
|
||||
|
||||
// Past the target: the door still has to be stopped despite the lamp command in between.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched
|
||||
// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the
|
||||
// door a little overshoot but never loses the stop.
|
||||
TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
// The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(door);
|
||||
|
||||
// The target survived the refusal, so the next position report still stops the door.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100}));
|
||||
auto [stop, stop_2] = poll_command(door);
|
||||
EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(stop_2, 0x0000);
|
||||
}
|
||||
|
||||
// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive.
|
||||
TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// The door is closing, so an opening target is armed but not yet under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
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, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// Without a bus controller the command cannot be delivered, and the caller is told.
|
||||
TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.toggle_light());
|
||||
}
|
||||
|
||||
// Switching the entity on sends one toggle, and the door's own report does not send a second.
|
||||
TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100);
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // release, clearing the slot
|
||||
|
||||
// The lamp is now on, and the resulting broadcast must not queue another toggle.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
auto [idle, idle_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(idle, 0x0000);
|
||||
EXPECT_EQ(idle_2, 0x0000);
|
||||
}
|
||||
|
||||
// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which
|
||||
// would cancel the user's own command.
|
||||
TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
// A door movement sets changed_, firing the state callback while the toggle is still queued.
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
EXPECT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
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 reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up
|
||||
// showing the lamp rather than the request that was refused.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
fixture.command(false);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// A door movement while the refused toggle is still on the wire must not pull the entity back either.
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// The toggle lands and the door confirms it; the entity must already agree.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in
|
||||
// that gap 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);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // release, so nothing is pending any more
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported
|
||||
|
||||
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp
|
||||
// moving, so the entity has to be brought back to what the lamp actually is.
|
||||
TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
|
||||
// The controller keeps broadcasting but never fetches the command, so the connection stays up.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
fixture.pump();
|
||||
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the
|
||||
// controller is back and still reporting the lamp unchanged.
|
||||
TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
fixture.pump(); // the connection times out and the command goes with it
|
||||
ASSERT_FALSE(fixture.door.is_valid());
|
||||
|
||||
connect_controller(fixture.door);
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// 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 toggle 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());
|
||||
}
|
||||
|
||||
// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and
|
||||
// withdraw the one still waiting rather than deciding nothing is needed.
|
||||
TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // the first toggle is released but not reported back
|
||||
fixture.command(false);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
|
||||
|
||||
// Two toggles cancel out, so asking for on again means withdrawing the second one.
|
||||
fixture.command(true);
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// 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 [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
}
|
||||
|
||||
// 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();
|
||||
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 toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet.
|
||||
// A reversing request in that window is a real request and has to be sent, not swallowed.
|
||||
TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
poll_command(fixture.door);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
ASSERT_FALSE(fixture.door.is_light_on());
|
||||
|
||||
fixture.command(false);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// The first toggle lands and is reported, but the entity is already heading for off.
|
||||
fixture.report_lamp(true);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// The second toggle lands too, and the lamp finally agrees with the request.
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(fixture.door);
|
||||
fixture.report_lamp(false);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity
|
||||
// against the next lamp change the door reports.
|
||||
TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) {
|
||||
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 toggle 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 lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone.
|
||||
TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
|
||||
// The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own.
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.update();
|
||||
|
||||
// The target survived the lamp toggle being dropped, so the door is still stopped on the way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A door that takes the key press but never actually switches the lamp must not leave the entity showing the
|
||||
// request for ever; the wait has to end so the entity can settle back on what the door reports.
|
||||
TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
consume_command(fixture.door); // the door takes press and release, then does nothing
|
||||
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
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(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000}));
|
||||
ASSERT_TRUE(fixture.door.is_light_known());
|
||||
|
||||
fixture.command(true);
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
|
||||
EXPECT_EQ(pressed_2, 0x0200);
|
||||
}
|
||||
|
||||
// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards.
|
||||
// Which command happened to be in the slot must not change that.
|
||||
TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 20;
|
||||
connect_controller(door);
|
||||
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
|
||||
ASSERT_TRUE(door.set_position(0.5f));
|
||||
consume_command(door);
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
door.update();
|
||||
ASSERT_FALSE(door.is_valid());
|
||||
|
||||
// Back on the bus and travelling past where the target was: nothing should stop the door now.
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
auto [pressed, pressed_2] = poll_command(door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door
|
||||
// that never reports the lamp would leave the entity waiting for ever.
|
||||
TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) {
|
||||
LightFixture fixture;
|
||||
fixture.door.connection_timeout_ms_ = 20;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
consume_command(fixture.door); // the first toggle is released but never reported back
|
||||
fixture.command(false);
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
|
||||
fixture.command(true); // withdraws the second, leaving the first outstanding
|
||||
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1);
|
||||
|
||||
// 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_EQ(fixture.door.light_toggles_in_flight_, 0);
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing
|
||||
// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning
|
||||
// the next toggle the moment it is queued.
|
||||
TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
poll_command(door); // the door is shown the key press
|
||||
|
||||
// The door acts on it and reports the lamp straight away, which settles the count.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
ASSERT_EQ(door.light_toggles_in_flight_, 0);
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
poll_command(door); // the release, with nothing left to wait for
|
||||
EXPECT_EQ(door.light_toggle_released_at_, 0u);
|
||||
}
|
||||
|
||||
// A restore mode that boots 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{light::LIGHT_ALWAYS_ON};
|
||||
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 reversing press before the toggle is fetched cancels it, so the lamp never moves.
|
||||
TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
|
||||
fixture.command(true);
|
||||
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
|
||||
|
||||
fixture.command(false);
|
||||
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
|
||||
EXPECT_FALSE(fixture.entity_on());
|
||||
|
||||
// Nothing is left for the controller to fetch, so the lamp stays off as asked.
|
||||
auto [pressed, pressed_2] = poll_command(fixture.door);
|
||||
EXPECT_EQ(pressed, 0x0000);
|
||||
EXPECT_EQ(pressed_2, 0x0000);
|
||||
}
|
||||
|
||||
// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even
|
||||
// been shown has to keep counting.
|
||||
TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 1);
|
||||
// The toggle still in the slot will invert what the door just reported.
|
||||
EXPECT_FALSE(door.is_light_heading_on());
|
||||
}
|
||||
|
||||
// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding,
|
||||
// so the wait has to end rather than time out against toggles the door was never shown.
|
||||
TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door); // shown to the door, so the wait for a lamp report starts
|
||||
ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown
|
||||
ASSERT_NE(door.light_toggle_released_at_, 0u);
|
||||
|
||||
// The door reports the lamp change the first toggle caused, leaving only the unsent one.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
|
||||
ASSERT_EQ(door.light_toggles_in_flight_, 1);
|
||||
EXPECT_EQ(door.light_toggle_released_at_, 0u);
|
||||
}
|
||||
|
||||
// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is
|
||||
// going to fire, so it keeps counting.
|
||||
TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) {
|
||||
TestableHoermannHcp door;
|
||||
// Wide enough that the toggle queued after the sleep cannot expire before update() runs.
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door); // shown to the door, which then says nothing about the lamp
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
// Queued just now, so only the wait for the first toggle is overdue.
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
door.update();
|
||||
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 1);
|
||||
EXPECT_TRUE(door.is_light_toggle_pending_());
|
||||
EXPECT_TRUE(door.is_light_heading_on());
|
||||
}
|
||||
|
||||
// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away.
|
||||
TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) {
|
||||
TestableHoermannHcp door;
|
||||
connect_controller(door);
|
||||
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
|
||||
|
||||
// The door takes every key press but never reports the lamp, so nothing is ever confirmed.
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ASSERT_TRUE(door.toggle_light());
|
||||
consume_command(door);
|
||||
}
|
||||
|
||||
EXPECT_FALSE(door.toggle_light());
|
||||
EXPECT_EQ(door.light_toggles_in_flight_, 4);
|
||||
}
|
||||
|
||||
// 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 invert the lamp.
|
||||
TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) {
|
||||
LightFixture fixture;
|
||||
fixture.bring_up();
|
||||
// A door command holds the only command slot, so the request below is refused and the lamp published back.
|
||||
ASSERT_TRUE(fixture.door.open_door());
|
||||
|
||||
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 slot frees up and the lamp is switched on at the door before that publish arrives.
|
||||
consume_command(fixture.door);
|
||||
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
|
||||
fixture.settle();
|
||||
|
||||
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0);
|
||||
EXPECT_TRUE(fixture.entity_on());
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp::testing
|
||||
Reference in New Issue
Block a user