Compare commits

..
27 changed files with 622 additions and 393 deletions

No files matched your search

+1 -1
View File
@@ -741,7 +741,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
@@ -7,6 +7,8 @@
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#include <atomic>
#ifdef USE_ETHERNET
#ifdef USE_ESP32
@@ -366,6 +368,9 @@ class EthernetComponent final : public Component {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#ifdef USE_ESP32
std::atomic<uint8_t> ip_state_pending_{0};
#endif
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
@@ -129,6 +129,14 @@ void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *me
void EthernetComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
// The IP events arrive on the event task; the listeners publish states, so notify them here
if (this->ip_state_pending_.load(std::memory_order_acquire) != 0) {
this->ip_state_pending_.store(0, std::memory_order_relaxed);
this->notify_ip_state_listeners_();
}
#endif
switch (this->state_) {
case EthernetComponentState::STOPPED:
if (this->started_) {
@@ -770,7 +778,8 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->notify_ip_state_listeners_();
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
global_eth_component->enable_loop_soon_any_context();
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -798,6 +807,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
const esp_netif_ip_info_t *ip_info = &event->ip_info;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip));
global_eth_component->got_ipv4_address_ = true;
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
@@ -805,9 +817,6 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
global_eth_component->connected_ = true;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif /* USE_NETWORK_IPV6 */
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#if USE_NETWORK_IPV6
@@ -819,6 +828,9 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -827,9 +839,6 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
global_eth_component->connected_ = global_eth_component->got_ipv4_address_;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#endif /* USE_NETWORK_IPV6 */
+44 -38
View File
@@ -27,13 +27,13 @@ CONF_ACCELERATION_WAIT_TIME = "acceleration_wait_time"
CONF_OVERSHOOT_DURATION = "overshoot_duration"
CONF_OBSTACLE_ROLLBACK = "obstacle_rollback"
feedback_ns = cg.esphome_ns.namespace("feedback")
FeedbackCover = feedback_ns.class_("FeedbackCover", cover.Cover, cg.Component)
endstop_ns = cg.esphome_ns.namespace("feedback")
FeedbackCover = endstop_ns.class_("FeedbackCover", cover.Cover, cg.Component)
def validate_infer_endstop(config: ConfigType) -> ConfigType:
if config[CONF_INFER_ENDSTOP_FROM_MOVEMENT]:
if not config[CONF_HAS_BUILT_IN_ENDSTOP]:
if config[CONF_INFER_ENDSTOP_FROM_MOVEMENT] is True:
if config[CONF_HAS_BUILT_IN_ENDSTOP] is False:
raise cv.Invalid(
f"{CONF_INFER_ENDSTOP_FROM_MOVEMENT} can only be set if {CONF_HAS_BUILT_IN_ENDSTOP} is also set"
)
@@ -119,52 +119,58 @@ async def to_code(config: ConfigType) -> None:
var = await cover.new_cover(config)
await cg.register_component(var, config)
# STOP
await automation.build_automation(
var.get_stop_trigger(), [], config[CONF_STOP_ACTION]
)
# OPEN
await automation.build_automation(
var.get_open_trigger(), [], config[CONF_OPEN_ACTION]
)
cg.add(var.set_open_duration(config[CONF_OPEN_DURATION]))
await cg.add_variable(var.set_open_endstop, config.get(CONF_OPEN_ENDSTOP))
await cg.add_variable(var.set_open_sensor, config.get(CONF_OPEN_SENSOR))
await cg.add_variable(
var.set_open_obstacle_sensor, config.get(CONF_OPEN_OBSTACLE_SENSOR)
)
# CLOSE
await automation.build_automation(
var.get_close_trigger(), [], config[CONF_CLOSE_ACTION]
)
cg.add(var.set_open_duration(config[CONF_OPEN_DURATION]))
cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION]))
if CONF_OPEN_ENDSTOP in config or CONF_CLOSE_ENDSTOP in config:
cg.add_define("USE_FEEDBACK_COVER_ENDSTOP")
await cg.add_variable(var.set_open_endstop, config.get(CONF_OPEN_ENDSTOP))
await cg.add_variable(var.set_close_endstop, config.get(CONF_CLOSE_ENDSTOP))
# validation requires both movement sensors or neither
if CONF_OPEN_SENSOR in config:
cg.add_define("USE_FEEDBACK_COVER_MOVEMENT")
await cg.add_variable(var.set_open_sensor, config[CONF_OPEN_SENSOR])
await cg.add_variable(var.set_close_sensor, config[CONF_CLOSE_SENSOR])
cg.add(var.set_infer_endstop(config[CONF_INFER_ENDSTOP_FROM_MOVEMENT]))
if CONF_OPEN_OBSTACLE_SENSOR in config or CONF_CLOSE_OBSTACLE_SENSOR in config:
cg.add_define("USE_FEEDBACK_COVER_OBSTACLE")
await cg.add_variable(
var.set_open_obstacle_sensor, config.get(CONF_OPEN_OBSTACLE_SENSOR)
)
await cg.add_variable(
var.set_close_obstacle_sensor, config.get(CONF_CLOSE_OBSTACLE_SENSOR)
)
cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK]))
cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION))
cg.add(var.set_has_built_in_endstop(config[CONF_HAS_BUILT_IN_ENDSTOP]))
assumed_state = config.get(CONF_ASSUMED_STATE)
if assumed_state is None:
assumed_state = not (
(CONF_CLOSE_ENDSTOP in config and CONF_OPEN_ENDSTOP in config)
or config[CONF_INFER_ENDSTOP_FROM_MOVEMENT]
)
cg.add(var.set_assumed_state(assumed_state))
cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL]))
cg.add_optional(
var.set_direction_change_waittime, config.get(CONF_DIRECTION_CHANGE_WAIT_TIME)
await cg.add_variable(var.set_close_endstop, config.get(CONF_CLOSE_ENDSTOP))
await cg.add_variable(var.set_close_sensor, config.get(CONF_CLOSE_SENSOR))
await cg.add_variable(
var.set_close_obstacle_sensor, config.get(CONF_CLOSE_OBSTACLE_SENSOR)
)
# OTHER
cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION))
cg.add(var.set_has_built_in_endstop(config[CONF_HAS_BUILT_IN_ENDSTOP]))
if CONF_ASSUMED_STATE in config:
cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE]))
else:
cg.add(
var.set_assumed_state(
not (
(CONF_CLOSE_ENDSTOP in config and CONF_OPEN_ENDSTOP in config)
or config[CONF_INFER_ENDSTOP_FROM_MOVEMENT]
)
)
)
cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL]))
cg.add(var.set_infer_endstop(config[CONF_INFER_ENDSTOP_FROM_MOVEMENT]))
if CONF_DIRECTION_CHANGE_WAIT_TIME in config:
cg.add(
var.set_direction_change_waittime(config[CONF_DIRECTION_CHANGE_WAIT_TIME])
)
cg.add(var.set_acceleration_wait_time(config[CONF_ACCELERATION_WAIT_TIME]))
if (overshoot := config[CONF_OVERSHOOT_DURATION]).total_milliseconds:
cg.add_define("USE_FEEDBACK_COVER_OVERSHOOT")
cg.add(var.set_overshoot_duration(overshoot))
cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK]))
+151 -92
View File
@@ -22,15 +22,15 @@ void FeedbackCover::setup() {
}
this->current_operation = COVER_OPERATION_IDLE;
#ifdef USE_FEEDBACK_COVER_ENDSTOP
#ifdef USE_BINARY_SENSOR
// if available, get position from endstop sensors
if (this->open_endstop_ != nullptr && this->open_endstop_->state) {
this->position = COVER_OPEN;
} else if (this->close_endstop_ != nullptr && this->close_endstop_->state) {
this->position = COVER_CLOSED;
}
#endif
#ifdef USE_FEEDBACK_COVER_MOVEMENT
// if available, get moving state from sensors
if (this->open_feedback_ != nullptr && this->open_feedback_->state) {
this->current_operation = COVER_OPERATION_OPENING;
} else if (this->close_feedback_ != nullptr && this->close_feedback_->state) {
@@ -51,36 +51,30 @@ CoverTraits FeedbackCover::get_traits() {
}
void FeedbackCover::dump_config() {
LOG_COVER("", "Feedback Cover", this);
ESP_LOGCONFIG(TAG,
" Open Duration: %.1fs\n"
" Close Duration: %.1fs",
this->open_duration_ / 1e3f, this->close_duration_ / 1e3f);
#ifdef USE_FEEDBACK_COVER_ENDSTOP
LOG_COVER("", "Endstop Cover", this);
ESP_LOGCONFIG(TAG, " Open Duration: %.1fs", this->open_duration_ / 1e3f);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Open Endstop", this->open_endstop_);
LOG_BINARY_SENSOR(" ", "Close Endstop", this->close_endstop_);
#endif
#ifdef USE_FEEDBACK_COVER_MOVEMENT
LOG_BINARY_SENSOR(" ", "Open Feedback", this->open_feedback_);
LOG_BINARY_SENSOR(" ", "Close Feedback", this->close_feedback_);
#endif
#ifdef USE_FEEDBACK_COVER_OBSTACLE
LOG_BINARY_SENSOR(" ", "Open Obstacle", this->open_obstacle_);
#endif
ESP_LOGCONFIG(TAG, " Close Duration: %.1fs", this->close_duration_ / 1e3f);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Close Endstop", this->close_endstop_);
LOG_BINARY_SENSOR(" ", "Close Feedback", this->close_feedback_);
LOG_BINARY_SENSOR(" ", "Close Obstacle", this->close_obstacle_);
#endif
if (this->has_built_in_endstop_) {
ESP_LOGCONFIG(TAG, " Has builtin endstop: YES");
}
#ifdef USE_FEEDBACK_COVER_MOVEMENT
if (this->infer_endstop_) {
ESP_LOGCONFIG(TAG, " Infer endstop from movement: YES");
}
#endif
if (this->max_duration_ < UINT32_MAX) {
ESP_LOGCONFIG(TAG, " Max Duration: %.1fs", this->max_duration_ / 1e3f);
}
if (this->direction_change_waittime_ != UINT32_MAX) {
ESP_LOGCONFIG(TAG, " Direction change wait time: %.1fs", this->direction_change_waittime_ / 1e3f);
if (this->direction_change_waittime_.has_value()) {
ESP_LOGCONFIG(TAG, " Direction change wait time: %.1fs", *this->direction_change_waittime_ / 1e3f);
}
if (this->acceleration_wait_time_) {
ESP_LOGCONFIG(TAG, " Acceleration wait time: %.1fs", this->acceleration_wait_time_ / 1e3f);
@@ -90,51 +84,60 @@ void FeedbackCover::dump_config() {
ESP_LOGCONFIG(TAG, " Overshoot duration: %.1fs", this->overshoot_duration_ / 1e3f);
}
#endif
#ifdef USE_FEEDBACK_COVER_OBSTACLE
if (this->obstacle_rollback_) {
#ifdef USE_BINARY_SENSOR
if (this->obstacle_rollback_ && (this->open_obstacle_ != nullptr || this->close_obstacle_ != nullptr)) {
ESP_LOGCONFIG(TAG, " Obstacle rollback: %.1f%%", this->obstacle_rollback_ * 100);
}
#endif
}
#ifdef USE_FEEDBACK_COVER_MOVEMENT
#ifdef USE_BINARY_SENSOR
void FeedbackCover::set_open_sensor(binary_sensor::BinarySensor *open_feedback) {
this->open_feedback_ = open_feedback;
open_feedback->add_on_state_callback([this](bool state) { this->on_feedback_(true, state); });
// setup callbacks to react to sensor changes
open_feedback->add_on_state_callback([this](bool state) {
ESP_LOGD(TAG, "'%s' - Open feedback '%s'.", LOG_STR_ARG(this->get_log_name()),
state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED"));
this->recompute_position_();
if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_OPENING) {
this->endstop_reached_(true);
}
this->set_current_operation_(state ? COVER_OPERATION_OPENING : COVER_OPERATION_IDLE, false);
});
}
void FeedbackCover::set_close_sensor(binary_sensor::BinarySensor *close_feedback) {
this->close_feedback_ = close_feedback;
close_feedback->add_on_state_callback([this](bool state) { this->on_feedback_(false, state); });
close_feedback->add_on_state_callback([this](bool state) {
ESP_LOGD(TAG, "'%s' - Close feedback '%s'.", LOG_STR_ARG(this->get_log_name()),
state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED"));
this->recompute_position_();
if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_CLOSING) {
this->endstop_reached_(false);
}
this->set_current_operation_(state ? COVER_OPERATION_CLOSING : COVER_OPERATION_IDLE, false);
});
}
void FeedbackCover::on_feedback_(bool open, bool state) {
const CoverOperation operation = open ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING;
ESP_LOGD(TAG, "'%s' - %s feedback '%s'.", LOG_STR_ARG(this->get_log_name()),
open ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"),
state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED"));
this->recompute_position_();
if (!state && this->infer_endstop_ && this->current_trigger_operation_ == operation) {
this->endstop_reached_(open);
}
this->set_current_operation_(state ? operation : COVER_OPERATION_IDLE, false);
}
#endif
#ifdef USE_FEEDBACK_COVER_ENDSTOP
void FeedbackCover::set_open_endstop(binary_sensor::BinarySensor *open_endstop) {
this->open_endstop_ = open_endstop;
open_endstop->add_on_state_callback([this](bool state) {
if (state)
if (state) {
this->endstop_reached_(true);
}
});
}
void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop) {
this->close_endstop_ = close_endstop;
close_endstop->add_on_state_callback([this](bool state) {
if (state)
if (state) {
this->endstop_reached_(false);
}
});
}
#endif
@@ -144,22 +147,22 @@ void FeedbackCover::endstop_reached_(bool open_endstop) {
this->position = open_endstop ? COVER_OPEN : COVER_CLOSED;
// Only when moving towards it; the cover may be returning from just past it
// only act if endstop activated while moving in the right direction, in case we are coming back
// from a position slightly past the endpoint
if (this->current_trigger_operation_ == (open_endstop ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING)) {
float dur = (now - this->start_dir_time_) / 1e3f;
ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()),
open_endstop ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"), dur);
// if there is no external mechanism, stop the cover
if (!this->has_built_in_endstop_) {
this->start_direction_(COVER_OPERATION_IDLE);
} else {
this->set_current_operation_(COVER_OPERATION_IDLE, true);
}
// Without movement sensors the stop above already published
if (!this->has_movement_sensors_())
return;
}
// always sync position and publish
this->publish_state();
this->last_publish_time_ = now;
}
@@ -167,54 +170,69 @@ void FeedbackCover::endstop_reached_(bool open_endstop) {
void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool is_triggered) {
if (is_triggered) {
this->current_trigger_operation_ = operation;
// With movement sensors they set the actual operation
if (this->has_movement_sensors_())
return;
}
const uint32_t now = App.get_loop_component_start_time();
this->current_operation = operation;
if (operation != COVER_OPERATION_IDLE)
this->enable_loop();
this->start_dir_time_ = this->last_recompute_time_ = now;
this->publish_state();
this->last_publish_time_ = now;
}
// if it is setting the actual operation (not triggered one) or
// if we don't have moving sensor, we operate in optimistic mode, assuming actions take place immediately
// thus, triggered operation always sets current operation.
// otherwise, current operation comes from sensor, and may differ from requested operation
// this might be from delays or complex actions, or because the movement was not trigger by the component
// but initiated externally
#ifdef USE_FEEDBACK_COVER_OBSTACLE
void FeedbackCover::set_open_obstacle_sensor(binary_sensor::BinarySensor *open_obstacle) {
this->open_obstacle_ = open_obstacle;
open_obstacle->add_on_state_callback([this](bool state) { this->on_obstacle_(true, state); });
#ifdef USE_BINARY_SENSOR
if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr))
#endif
{
const uint32_t now = App.get_loop_component_start_time();
this->current_operation = operation;
this->start_dir_time_ = this->last_recompute_time_ = now;
this->publish_state();
this->last_publish_time_ = now;
}
}
#ifdef USE_BINARY_SENSOR
void FeedbackCover::set_close_obstacle_sensor(binary_sensor::BinarySensor *close_obstacle) {
this->close_obstacle_ = close_obstacle;
close_obstacle->add_on_state_callback([this](bool state) { this->on_obstacle_(false, state); });
close_obstacle->add_on_state_callback([this](bool state) {
if (state && (this->current_operation == COVER_OPERATION_CLOSING ||
this->current_trigger_operation_ == COVER_OPERATION_CLOSING)) {
ESP_LOGD(TAG, "'%s' - Close obstacle detected.", LOG_STR_ARG(this->get_log_name()));
this->start_direction_(COVER_OPERATION_IDLE);
if (this->obstacle_rollback_) {
this->target_position_ = clamp(this->position + this->obstacle_rollback_, COVER_CLOSED, COVER_OPEN);
this->start_direction_(COVER_OPERATION_OPENING);
}
}
});
}
void FeedbackCover::on_obstacle_(bool open, bool state) {
const CoverOperation operation = open ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING;
if (!state || (this->current_operation != operation && this->current_trigger_operation_ != operation))
return;
ESP_LOGD(TAG, "'%s' - %s obstacle detected.", LOG_STR_ARG(this->get_log_name()),
open ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"));
this->start_direction_(COVER_OPERATION_IDLE);
void FeedbackCover::set_open_obstacle_sensor(binary_sensor::BinarySensor *open_obstacle) {
this->open_obstacle_ = open_obstacle;
if (this->obstacle_rollback_) {
const float rollback = open ? -this->obstacle_rollback_ : this->obstacle_rollback_;
this->target_position_ = clamp(this->position + rollback, COVER_CLOSED, COVER_OPEN);
this->start_direction_(open ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING);
}
open_obstacle->add_on_state_callback([this](bool state) {
if (state && (this->current_operation == COVER_OPERATION_OPENING ||
this->current_trigger_operation_ == COVER_OPERATION_OPENING)) {
ESP_LOGD(TAG, "'%s' - Open obstacle detected.", LOG_STR_ARG(this->get_log_name()));
this->start_direction_(COVER_OPERATION_IDLE);
if (this->obstacle_rollback_) {
this->target_position_ = clamp(this->position - this->obstacle_rollback_, COVER_CLOSED, COVER_OPEN);
this->start_direction_(COVER_OPERATION_CLOSING);
}
}
});
}
#endif
void FeedbackCover::loop() {
if (this->current_operation == COVER_OPERATION_IDLE) {
this->disable_loop();
if (this->current_operation == COVER_OPERATION_IDLE)
return;
}
const uint32_t now = App.get_loop_component_start_time();
// Recompute position every loop cycle
this->recompute_position_();
// if we initiated the move, check if we reached position or max time
@@ -246,7 +264,9 @@ void FeedbackCover::loop() {
}
}
// Publish intermediate positions without saving them
// update current position at requested interval, regardless of who started the movement
// so that we also update UI if there was an external movement
// don't save intermediate positions
if (now - this->last_publish_time_ > this->update_interval_) {
this->publish_state(false);
this->last_publish_time_ = now;
@@ -254,9 +274,11 @@ void FeedbackCover::loop() {
}
void FeedbackCover::control(const CoverCall &call) {
// stop action logic
if (call.get_stop()) {
this->start_direction_(COVER_OPERATION_IDLE);
} else if (call.get_toggle().has_value()) {
// toggle action logic: OPEN - STOP - CLOSE
if (this->current_trigger_operation_ != COVER_OPERATION_IDLE) {
this->start_direction_(COVER_OPERATION_IDLE);
} else {
@@ -272,15 +294,28 @@ void FeedbackCover::control(const CoverCall &call) {
auto pos_opt = call.get_position();
if (!pos_opt.has_value())
return;
// go to position action
auto pos = *pos_opt;
if (pos == this->position) {
// Resend in case the assumed state is wrong
if (this->has_built_in_endstop_ &&
((pos == COVER_OPEN && !this->has_endstop_(true)) || (pos == COVER_CLOSED && !this->has_endstop_(false)))) {
// already at target,
// for covers with built in end stop, if we don't have sensors we should send the command again
// to make sure the assumed state is not wrong
if (this->has_built_in_endstop_ && ((pos == COVER_OPEN
#ifdef USE_BINARY_SENSOR
&& this->open_endstop_ == nullptr
#endif
&& !this->infer_endstop_) ||
(pos == COVER_CLOSED
#ifdef USE_BINARY_SENSOR
&& this->close_endstop_ == nullptr
#endif
&& !this->infer_endstop_))) {
this->target_position_ = pos;
this->start_direction_(pos == COVER_CLOSED ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING);
} else if (this->current_operation != COVER_OPERATION_IDLE ||
this->current_trigger_operation_ != COVER_OPERATION_IDLE) {
// if we are moving, stop
this->start_direction_(COVER_OPERATION_IDLE);
}
} else {
@@ -291,7 +326,7 @@ void FeedbackCover::control(const CoverCall &call) {
}
void FeedbackCover::stop_prev_trigger_() {
if (this->direction_change_waittime_ != UINT32_MAX) {
if (this->direction_change_waittime_.has_value()) {
this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID);
}
if (this->prev_command_trigger_ != nullptr) {
@@ -301,6 +336,9 @@ void FeedbackCover::stop_prev_trigger_() {
}
bool FeedbackCover::is_at_target_() const {
// if initiated externally, current operation might be different from
// operation that was triggered, thus evaluate position against what was asked
switch (this->current_trigger_operation_) {
case COVER_OPERATION_OPENING:
return this->position >= this->target_position_;
@@ -312,10 +350,10 @@ bool FeedbackCover::is_at_target_() const {
return true;
}
}
void FeedbackCover::start_direction_(CoverOperation dir) {
Trigger<> *trig;
#ifdef USE_FEEDBACK_COVER_OBSTACLE
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *obstacle{nullptr};
#endif
@@ -331,14 +369,14 @@ void FeedbackCover::start_direction_(CoverOperation dir) {
case COVER_OPERATION_OPENING:
this->last_operation_ = dir;
trig = &this->open_trigger_;
#ifdef USE_FEEDBACK_COVER_OBSTACLE
#ifdef USE_BINARY_SENSOR
obstacle = this->open_obstacle_;
#endif
break;
case COVER_OPERATION_CLOSING:
this->last_operation_ = dir;
trig = &this->close_trigger_;
#ifdef USE_FEEDBACK_COVER_OBSTACLE
#ifdef USE_BINARY_SENSOR
obstacle = this->close_obstacle_;
#endif
break;
@@ -348,8 +386,9 @@ void FeedbackCover::start_direction_(CoverOperation dir) {
this->stop_prev_trigger_();
#ifdef USE_FEEDBACK_COVER_OBSTACLE
// An obstacle already present aborts the start
#ifdef USE_BINARY_SENSOR
// check if there is an obstacle to start the new operation -> abort without any change
// the case when an obstacle appears while moving is handled in the callback
if (obstacle != nullptr && obstacle->state) {
ESP_LOGD(TAG, "'%s' - %s obstacle detected. Action not started.", LOG_STR_ARG(this->get_log_name()),
dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"));
@@ -357,12 +396,14 @@ void FeedbackCover::start_direction_(CoverOperation dir) {
}
#endif
if (this->direction_change_waittime_ != UINT32_MAX && dir != COVER_OPERATION_IDLE &&
// if we are moving and need to move in the opposite direction
// check if we have a wait time
if (this->direction_change_waittime_.has_value() && dir != COVER_OPERATION_IDLE &&
this->current_operation != COVER_OPERATION_IDLE && dir != this->current_operation) {
const uint32_t waittime = *this->direction_change_waittime_;
ESP_LOGD(TAG, "'%s' - Reversing direction.", LOG_STR_ARG(this->get_log_name()));
this->start_direction_(COVER_OPERATION_IDLE);
this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, this->direction_change_waittime_,
[this, dir]() { this->start_direction_(dir); });
this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, waittime, [this, dir]() { this->start_direction_(dir); });
} else {
this->set_current_operation_(dir, true);
this->prev_command_trigger_ = trig;
@@ -384,24 +425,42 @@ void FeedbackCover::recompute_position_() {
float min_pos;
float max_pos;
// With an endstop the estimate stops short of the end; the endstop callback sets it
// endstop sensors update position from their callbacks, and sets the fully open/close value
// If we have endstop, estimation never reaches the fully open/closed state.
// but if movement continues past corresponding endstop (inertia), keep the fully open/close state
switch (this->current_operation) {
case COVER_OPERATION_OPENING:
dir = 1.0f;
action_dur = this->open_duration_;
min_pos = COVER_CLOSED;
max_pos = this->has_endstop_(true) && this->position < COVER_OPEN ? 0.99f : COVER_OPEN;
max_pos = (
#ifdef USE_BINARY_SENSOR
this->open_endstop_ != nullptr ||
#endif
this->infer_endstop_) &&
this->position < COVER_OPEN
? 0.99f
: COVER_OPEN;
break;
case COVER_OPERATION_CLOSING:
dir = -1.0f;
action_dur = this->close_duration_;
min_pos = this->has_endstop_(false) && this->position > COVER_CLOSED ? 0.01f : COVER_CLOSED;
min_pos = (
#ifdef USE_BINARY_SENSOR
this->close_endstop_ != nullptr ||
#endif
this->infer_endstop_) &&
this->position > COVER_CLOSED
? 0.01f
: COVER_CLOSED;
max_pos = COVER_OPEN;
break;
default:
return;
}
// check if we have an acceleration_wait_time, and remove from position computation
if (now - this->start_dir_time_ > this->acceleration_wait_time_) {
uint32_t accel_end_time = this->start_dir_time_ + this->acceleration_wait_time_;
uint32_t effective_start;
+9 -45
View File
@@ -20,19 +20,13 @@ class FeedbackCover final : public cover::Cover, public Component {
Trigger<> *get_close_trigger() { return &this->close_trigger_; }
Trigger<> *get_stop_trigger() { return &this->stop_trigger_; }
#ifdef USE_FEEDBACK_COVER_ENDSTOP
#ifdef USE_BINARY_SENSOR
void set_open_endstop(binary_sensor::BinarySensor *open_endstop);
void set_close_endstop(binary_sensor::BinarySensor *close_endstop);
#endif
#ifdef USE_FEEDBACK_COVER_MOVEMENT
void set_open_sensor(binary_sensor::BinarySensor *open_feedback);
void set_close_sensor(binary_sensor::BinarySensor *close_feedback);
void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; }
#endif
#ifdef USE_FEEDBACK_COVER_OBSTACLE
void set_open_obstacle_sensor(binary_sensor::BinarySensor *open_obstacle);
void set_close_endstop(binary_sensor::BinarySensor *close_endstop);
void set_close_sensor(binary_sensor::BinarySensor *close_feedback);
void set_close_obstacle_sensor(binary_sensor::BinarySensor *close_obstacle);
void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; }
#endif
void set_open_duration(uint32_t duration) { this->open_duration_ = duration; }
void set_close_duration(uint32_t duration) { this->close_duration_ = duration; }
@@ -42,7 +36,9 @@ class FeedbackCover final : public cover::Cover, public Component {
#ifdef USE_FEEDBACK_COVER_OVERSHOOT
void set_overshoot_duration(uint32_t overshoot_duration) { this->overshoot_duration_ = overshoot_duration; }
#endif
void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; }
void set_update_interval(uint32_t interval) { this->update_interval_ = interval; }
void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; }
void set_direction_change_waittime(uint32_t waittime) { this->direction_change_waittime_ = waittime; }
void set_acceleration_wait_time(uint32_t waittime) { this->acceleration_wait_time_ = waittime; }
@@ -57,43 +53,15 @@ class FeedbackCover final : public cover::Cover, public Component {
void endstop_reached_(bool open_endstop);
void recompute_position_();
void set_current_operation_(cover::CoverOperation operation, bool is_triggered);
#ifdef USE_FEEDBACK_COVER_MOVEMENT
void on_feedback_(bool open, bool state);
#endif
#ifdef USE_FEEDBACK_COVER_OBSTACLE
void on_obstacle_(bool open, bool state);
#endif
bool has_endstop_(bool open) const {
#ifdef USE_FEEDBACK_COVER_ENDSTOP
if ((open ? this->open_endstop_ : this->close_endstop_) != nullptr)
return true;
#endif
#ifdef USE_FEEDBACK_COVER_MOVEMENT
return this->infer_endstop_;
#else
return false;
#endif
}
bool has_movement_sensors_() const {
#ifdef USE_FEEDBACK_COVER_MOVEMENT
return this->open_feedback_ != nullptr && this->close_feedback_ != nullptr;
#else
return false;
#endif
}
#ifdef USE_FEEDBACK_COVER_ENDSTOP
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *open_endstop_{nullptr};
binary_sensor::BinarySensor *close_endstop_{nullptr};
#endif
#ifdef USE_FEEDBACK_COVER_MOVEMENT
binary_sensor::BinarySensor *open_feedback_{nullptr};
binary_sensor::BinarySensor *close_feedback_{nullptr};
#endif
#ifdef USE_FEEDBACK_COVER_OBSTACLE
binary_sensor::BinarySensor *open_obstacle_{nullptr};
binary_sensor::BinarySensor *close_obstacle_{nullptr};
#endif
Trigger<> open_trigger_;
Trigger<> close_trigger_;
@@ -105,13 +73,12 @@ class FeedbackCover final : public cover::Cover, public Component {
#ifdef USE_FEEDBACK_COVER_OVERSHOOT
uint32_t overshoot_duration_{0};
#endif
uint32_t direction_change_waittime_{UINT32_MAX};
optional<uint32_t> direction_change_waittime_{};
uint32_t acceleration_wait_time_{0};
bool has_built_in_endstop_{false};
bool assumed_state_{false};
#ifdef USE_FEEDBACK_COVER_MOVEMENT
bool infer_endstop_{false};
#endif
float obstacle_rollback_{0};
cover::CoverOperation last_operation_{cover::COVER_OPERATION_OPENING};
cover::CoverOperation current_trigger_operation_{cover::COVER_OPERATION_IDLE};
@@ -123,9 +90,6 @@ class FeedbackCover final : public cover::Cover, public Component {
uint32_t start_overshoot_time_{0};
#endif
float target_position_{0};
#ifdef USE_FEEDBACK_COVER_OBSTACLE
float obstacle_rollback_{0};
#endif
uint32_t update_interval_{1000};
};
+6 -2
View File
@@ -5,13 +5,15 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet", "@arpena"]
CODEOWNERS = ["@glmnet", "@arpena", "@Rodrigoah"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
Model = whirlpool_ns.enum("Model")
MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_")
MODELS = Model.options(
"DG11J1-3A", "DG11J1-91", "HISENSE_ARG", "HITACHI_HCRA31NEWH", prefix="MODEL_"
)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
{
@@ -26,3 +28,5 @@ async def to_code(config: ConfigType) -> None:
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
elif config[CONF_MODEL] == "HITACHI_HCRA31NEWH":
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
+61 -15
View File
@@ -2,6 +2,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::whirlpool {
ESPHOME_LOG_TAG(TAG, "whirlpool.climate");
@@ -44,6 +46,17 @@ static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
// Hitachi HCRA31NEWH model: power is a state in every frame
static constexpr uint8_t HITACHI_BYTE10 = 0x20; // byte 10
static constexpr uint8_t HITACHI_COOL = 0x02; // byte 15
static constexpr uint8_t HITACHI_OFF = 0x01; // byte 15
static constexpr uint8_t HITACHI_BYTE18_ON = 0x38; // byte 18
static constexpr uint8_t HITACHI_BYTE18_OFF = 0x28; // byte 18
static constexpr uint8_t HITACHI_BYTE19 = 0x08; // byte 19
// Header, 21 bytes of 8 bits, two dividers and the footer mark
static constexpr uint32_t WHIRLPOOL_FRAME_TIMINGS = 2 + WHIRLPOOL_STATE_LENGTH * 16 + 2 * 2 + 1;
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -55,19 +68,19 @@ void WhirlpoolClimate::transmit_state() {
this->last_transmit_time_ = millis(); // setting the time of the last transmission.
uint8_t remote_state[WHIRLPOOL_STATE_LENGTH];
memset(remote_state, 0, sizeof(remote_state));
remote_state[0] = 0x83;
remote_state[1] = 0x06;
put_be16(remote_state, this->remote_id_());
remote_state[6] = 0x80;
remote_state[18] = 0x08; // the same on every supported model
remote_state[18] = 0x08;
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
const bool power_toggle = powered_on != this->powered_on_assumed;
const bool powered_on = this->mode != climate::CLIMATE_MODE_OFF;
// Hitachi sends power as a state instead of a toggle
const bool power_toggle = !this->is_hitachi_hcra31newh_() && powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
this->powered_on_assumed = powered_on;
}
this->powered_on_assumed = powered_on;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
// set fan auto
@@ -93,6 +106,10 @@ void WhirlpoolClimate::transmit_state() {
remote_state[15] = 6;
break;
case climate::CLIMATE_MODE_OFF:
// These remotes keep the cool code in the power-off frame
if (this->is_hisense_arg_() || this->is_hitachi_hcra31newh_())
remote_state[3] = WHIRLPOOL_COOL;
break;
default:
break;
}
@@ -102,6 +119,7 @@ void WhirlpoolClimate::transmit_state() {
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
this->target_temperature = temp; // publish what the unit was sent
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
@@ -131,7 +149,6 @@ void WhirlpoolClimate::transmit_state() {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
@@ -139,6 +156,11 @@ void WhirlpoolClimate::transmit_state() {
}
}
if (hisense_arg)
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
if (this->is_hitachi_hcra31newh_())
this->encode_hitachi_hcra31newh_(remote_state);
// Checksum
remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]);
remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]);
@@ -148,6 +170,7 @@ void WhirlpoolClimate::transmit_state() {
// Send code
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->reserve(WHIRLPOOL_FRAME_TIMINGS);
data->set_carrier_frequency(38000);
@@ -180,10 +203,6 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
@@ -212,6 +231,27 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
#endif
}
void WhirlpoolClimate::encode_hitachi_hcra31newh_(uint8_t *remote_state) const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
remote_state[10] = HITACHI_BYTE10;
remote_state[18] = HITACHI_BYTE18_ON;
remote_state[19] = HITACHI_BYTE19;
// The unit takes the whole state from every frame: only the off frame carries the power bit
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state[15] = HITACHI_COOL;
break;
case climate::CLIMATE_MODE_OFF:
remote_state[2] |= WHIRLPOOL_POWER;
remote_state[15] = HITACHI_OFF;
remote_state[18] = HITACHI_BYTE18_OFF;
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -234,7 +274,8 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP))
return false;
}
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) {
// The Hitachi remote always sends all 21 bytes; byte 18 holds its power state
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid() && !this->is_hitachi_hcra31newh_()) {
// Remote control only sent 14 bytes, nothing more to read, not even the footer
ESP_LOGV(TAG, "Remote control only sent %d bytes", i);
skip_footer = true;
@@ -261,13 +302,18 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s");
// verify header remote code
if (remote_state[0] != 0x83 || remote_state[1] != 0x06)
if (encode_uint16(remote_state[0], remote_state[1]) != this->remote_id_())
return false;
// powr on/off button
ESP_LOGV(TAG, "Power: %02X", (remote_state[2] & WHIRLPOOL_POWER));
if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
if (this->is_hitachi_hcra31newh_()) {
// Power is a state: byte 18 says whether the unit is off
this->powered_on_assumed = remote_state[18] != HITACHI_BYTE18_OFF;
if (!this->powered_on_assumed)
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on) {
@@ -309,7 +355,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp += static_cast<int>(this->temperature_min_());
temp = std::min(temp + static_cast<int>(this->temperature_min_()), static_cast<int>(this->temperature_max_()));
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
+19 -4
View File
@@ -6,9 +6,10 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_HITACHI_HCRA31NEWH = 3, /// Hitachi with the HCRA31NEWH remote, 16 to 30
};
// Temperature
@@ -29,7 +30,9 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
send_swing_cmd_ = call.get_swing_mode().has_value();
// The toggle remotes flip swing on every press, so only send one when the mode really changes
auto swing = call.get_swing_mode();
this->send_swing_cmd_ = swing.has_value() && *swing != this->swing_mode;
climate_ir::ClimateIR::control(call);
}
@@ -55,10 +58,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
return false;
#endif
}
/// False when the model is compiled out, so the compiler drops the Hitachi branches.
bool is_hitachi_hcra31newh_() const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
return this->model_ == MODEL_HITACHI_HCRA31NEWH;
#else
return false;
#endif
}
/// The remote ID sent in bytes 0 and 1.
uint16_t remote_id_() const { return this->is_hitachi_hcra31newh_() ? 0xA959 : 0x8306; }
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Add the Hitachi HCRA31NEWH bytes on top of the common whirlpool frame.
void encode_hitachi_hcra31newh_(uint8_t *remote_state) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
+1 -1
View File
@@ -563,7 +563,7 @@ class WiFiComponent final : public Component {
// (In most use cases you won't need these)
/// Setup WiFi interface.
void setup() override;
#if defined(USE_LN882X) || defined(USE_BK72XX)
#if defined(USE_ESP32) || defined(USE_LN882X) || defined(USE_BK72XX)
void on_powerdown() override;
#endif
void start();
@@ -1635,6 +1635,21 @@ bool WiFiComponent::wifi_dpp_start_listen_() {
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
void WiFiComponent::on_powerdown() {
// Leave the AP before esp_restart() or deep sleep stops Wi-Fi. esp_wifi_stop() while associated
// makes the ESP32 retransmit its Disassociation up to the retry limit although the AP ACKs it
// (32 copies with protected management frames), which some APs handle badly.
// Upstream bug: https://github.com/espressif/esp-idf/issues/19443 - remove this once it is fixed
// in the ESP-IDF versions we support.
// Ask the driver: s_sta_connected lags until the main loop has handled the connect event.
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
return;
ESP_LOGD(TAG, "Disconnecting before powerdown");
esp_wifi_disconnect();
delay(100); // NOLINT: once per reboot or deep sleep, lets the disassociation go out
}
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
+2
View File
@@ -294,6 +294,8 @@ void Application::reboot() {
for (auto &component : std::ranges::reverse_view(this->components_)) {
component->on_shutdown();
}
// No teardown on a forced reboot, but still power hardware down (Wi-Fi leaves the AP)
this->run_powerdown_hooks();
arch_restart();
}
void Application::safe_reboot() {
+4 -2
View File
@@ -185,10 +185,12 @@ class Component {
*/
virtual bool teardown() { return true; }
/** Called after teardown is complete to power down hardware.
/** Called last before a reboot or deep sleep to power down hardware.
*
* This is called after all components have finished their teardown process,
* On a safe shutdown this is called after all components have finished their teardown process,
* making it safe to power down hardware like ethernet PHY.
* On a forced reboot (Application::reboot()) it is called right after on_shutdown() with no
* teardown, so implementations must not assume teardown() has run.
*/
virtual void on_powerdown() {}
+1 -3
View File
@@ -109,9 +109,6 @@
#define USE_EZSP_PROXY_TAP
#define USE_FAN
#define USE_FONT
#define USE_FEEDBACK_COVER_ENDSTOP
#define USE_FEEDBACK_COVER_MOVEMENT
#define USE_FEEDBACK_COVER_OBSTACLE
#define USE_FEEDBACK_COVER_OVERSHOOT
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
#define USE_GPIO_SWITCH_INTERLOCK
@@ -390,6 +387,7 @@
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_WHIRLPOOL_HITACHI_HCRA31NEWH
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
@@ -2,3 +2,7 @@ packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
# IPv6 with an IP sensor: compiles the IPv6 event handler's listener notification
network:
enable_ipv6: true
@@ -1,13 +0,0 @@
# No endstop, movement or obstacle sensors, so their code is compiled out
cover:
- platform: feedback
id: feedback_minimal
name: Minimal Feedback Cover
open_action:
- logger.log: open
open_duration: 10s
close_action:
- logger.log: close
close_duration: 10s
stop_action:
- logger.log: stop
+2 -1
View File
@@ -10,7 +10,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
# Compiles in the Hisense ARG and Hitachi models, which climate.py adds only when configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
manifest.to_code = to_code_testing
+5
View File
@@ -8,3 +8,8 @@ climate:
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
- platform: whirlpool
name: Whirlpool Hitachi HCRA31NEWH Climate
model: HITACHI_HCRA31NEWH
transmitter_id: xmitr
receiver_id: rcvr
@@ -252,6 +252,9 @@ TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already off";
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
@@ -261,6 +264,9 @@ TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already vertical";
}
// Cool 24 C (step 8), fan low, powered on assumed
@@ -385,4 +391,90 @@ TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
}
}
// Cool 24 C (step 8), fan low, unit on
static Frame hitachi_cool_frame() {
return with_checksums({0xA9, 0x59, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x38, 0x08, 0x00});
}
// The off frame: power bit, cool code, byte 15 0x01 and byte 18 0x28
static Frame hitachi_off_frame() {
return with_checksums({0xA9, 0x59, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x28, 0x08, 0x00});
}
TEST(WhirlpoolHitachiHCRA31NEWH, CoolAndOffMatchKnownFrames) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// Turning on sends the full state with no power bit
expect_frame(f.last_frame(), hitachi_cool_frame());
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
expect_frame(f.last_frame(), hitachi_off_frame());
// A second off command still sends the power bit
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(f.last_frame()[2] & 0x04, 0x04);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x04, 0);
EXPECT_EQ(frame[3], 0x80);
EXPECT_EQ(frame[15], 0x06);
EXPECT_EQ(frame[18], 0x38);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivePowerIsAState) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// An on frame restores the mode even though it has no power bit
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
// The same frame again keeps it on
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// A repeated off frame does not toggle
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveChecksRemoteId) {
Fixture hitachi(MODEL_HITACHI_HCRA31NEWH);
EXPECT_FALSE(receive(hitachi.climate, hisense_cool_frame()));
Fixture whirlpool(MODEL_DG11J1_91);
EXPECT_FALSE(receive(whirlpool.climate, hitachi_cool_frame()));
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivedTemperatureIsClamped) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
Frame frame = hitachi_cool_frame();
frame[3] = 0xF2; // step 15 would be 31 C
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 30);
}
TEST(WhirlpoolDG11J13A, TargetIsClampedAndRoundedToWhatWasSent) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 35, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 32);
f.command(climate::CLIMATE_MODE_COOL, 24.4f, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveRejectsShortFrame) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
remote_base::RawTimings raw = waveform(hitachi_cool_frame());
// Cut after the second divider: 14 bytes, no footer
raw.resize(2 + 14 * 16 + 2 * 2);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
EXPECT_FALSE(static_cast<remote_base::RemoteReceiverListener &>(f.climate).on_receive(data));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::whirlpool::testing
@@ -0,0 +1,23 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
reboot_hook_recorder_ns = cg.esphome_ns.namespace("reboot_hook_recorder")
RebootHookRecorder = reboot_hook_recorder_ns.class_("RebootHookRecorder", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RebootHookRecorder),
cv.Required(CONF_NAME): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_name(config[CONF_NAME]))
@@ -0,0 +1,10 @@
#include "reboot_hook_recorder.h"
#include "esphome/core/log.h"
namespace esphome::reboot_hook_recorder {
ESPHOME_LOG_TAG(TAG, "reboot_hook_recorder");
void RebootHookRecorder::record_(const char *hook) { ESP_LOGI(TAG, "HOOK %s %s", this->name_, hook); }
} // namespace esphome::reboot_hook_recorder
@@ -0,0 +1,33 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::reboot_hook_recorder {
/// Logs every shutdown lifecycle hook it receives, so a test can check their order.
class RebootHookRecorder : public Component {
public:
void set_name(const char *name) { this->name_ = name; }
void on_safe_shutdown() override { this->record_("on_safe_shutdown"); }
void on_shutdown() override { this->record_("on_shutdown"); }
// Needs a second call to finish, like a component waiting for a connection to close
bool teardown() override {
if (++this->teardown_calls_ < 2) {
this->record_("teardown_pending");
return false;
}
if (this->teardown_calls_ == 2)
this->record_("teardown_done");
return true;
}
void on_powerdown() override { this->record_("on_powerdown"); }
protected:
void record_(const char *hook);
const char *name_{""};
uint8_t teardown_calls_{0};
};
} // namespace esphome::reboot_hook_recorder
@@ -1,69 +0,0 @@
esphome:
name: feedback-loop-wake
host:
api:
batch_delay: 0ms
logger:
level: DEBUG
# Stand in for a motor started outside ESPHome
switch:
- platform: template
name: "External Open"
id: external_open
optimistic: true
- platform: template
name: "External Close"
id: external_close
optimistic: true
binary_sensor:
- platform: template
id: open_moving
internal: true
condition:
switch.is_on: external_open
- platform: template
id: close_moving
internal: true
condition:
switch.is_on: external_close
cover:
- platform: feedback
name: "Time Cover"
open_action:
- logger.log: "time_cover open"
close_action:
- logger.log: "time_cover close"
stop_action:
- logger.log: "time_cover stop"
open_duration: 2s
close_duration: 2s
- platform: feedback
name: "Max Duration Cover"
open_action:
- logger.log: "max_cover open"
close_action:
- logger.log: "max_cover close"
stop_action:
- logger.log: "max_cover stop"
open_duration: 3s
close_duration: 3s
max_duration: 1s
- platform: feedback
name: "Sensor Cover"
open_action:
- logger.log: "sensor_cover open"
close_action:
- logger.log: "sensor_cover close"
stop_action:
- logger.log: "sensor_cover stop"
open_duration: 4s
close_duration: 4s
open_sensor: open_moving
close_sensor: close_moving
update_interval: 200ms
@@ -0,0 +1,19 @@
esphome:
name: reboot-hooks-forced
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -0,0 +1,19 @@
esphome:
name: reboot-hooks-safe
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.safe_reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -1,100 +0,0 @@
"""Integration test that every way a feedback cover starts moving wakes its idle loop."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from aioesphomeapi import CoverInfo, CoverOperation, CoverState, EntityState, SwitchInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import StateWaiter, require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
def _expect_idle(waiter: StateWaiter, cover: CoverInfo) -> Awaitable[EntityState]:
return waiter.expect(
lambda s: (
isinstance(s, CoverState)
and s.key == cover.key
and s.current_operation == CoverOperation.IDLE
),
label=f"{cover.name} IDLE",
)
@pytest.mark.asyncio
async def test_feedback_cover_loop_wake(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that target position, max_duration and movement sensors still work with the loop idled."""
lines = LineWaiter()
max_stopped = lines.expect("Max duration reached")
async with (
run_compiled(yaml_config, line_callback=lines.callback),
api_client_connected() as client,
):
sensor_states: list[CoverState] = []
entities, _ = await client.list_entities_services()
time_cover = require_entity(entities, "time_cover", CoverInfo)
max_cover = require_entity(entities, "max_duration_cover", CoverInfo)
sensor_cover = require_entity(entities, "sensor_cover", CoverInfo)
def on_state(state: EntityState) -> None:
if isinstance(state, CoverState) and state.key == sensor_cover.key:
sensor_states.append(state)
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
waiter = sub.waiter
# Positions are restored from the previous run, so pick targets away from them
start = sub.initial_states[time_cover.key].position
target = 0.2 if start >= 0.5 else 0.8
# Only loop() stops a time-only cover at its target
done = _expect_idle(waiter, time_cover)
client.cover_command(key=time_cover.key, position=target)
state = await done
assert state.position == pytest.approx(target, abs=0.05)
# Stop mid move, then a new command must move it again and stop at the target
stopped = _expect_idle(waiter, time_cover)
client.cover_command(key=time_cover.key, position=start)
await asyncio.sleep(0.3)
client.cover_command(key=time_cover.key, stop=True)
mid = (await stopped).position
assert mid != pytest.approx(target, abs=0.05)
done = _expect_idle(waiter, time_cover)
client.cover_command(key=time_cover.key, position=target)
state = await done
assert state.position == pytest.approx(target, abs=0.05)
# 1s max_duration against a 3s travel
max_start = sub.initial_states[max_cover.key].position
max_target = 0.0 if max_start >= 0.5 else 1.0
done = _expect_idle(waiter, max_cover)
client.cover_command(key=max_cover.key, position=max_target)
state = await done
await asyncio.wait_for(max_stopped, 1.0)
assert abs(state.position - max_start) == pytest.approx(1 / 3, abs=0.1)
# Moved by the movement sensor alone, no command
sensor_start = sub.initial_states[sensor_cover.key].position
switch_name = "external_close" if sensor_start >= 0.5 else "external_open"
switch = require_entity(entities, switch_name, SwitchInfo)
sensor_states.clear()
client.switch_command(switch.key, True)
await asyncio.sleep(1.2)
settled = _expect_idle(waiter, sensor_cover)
client.switch_command(switch.key, False)
await settled
moving = {
round(s.position, 3)
for s in sensor_states
if s.current_operation != CoverOperation.IDLE
}
# One state from the sensor callback, the rest are loop() updates every 200ms
assert len(moving) >= 4, sensor_states
@@ -0,0 +1,80 @@
"""Test the order of the shutdown lifecycle hooks on a forced and on a safe reboot."""
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
HOOK_PATTERN = re.compile(r"HOOK (\w+) (\w+)")
# Hooks run in reverse component order, so "first" gets each hook last.
LAST_HOOK = "HOOK first on_powerdown"
async def _record_reboot_hooks(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> list[tuple[str, str]]:
"""Connect, which makes the device reboot, and return its (component, hook) calls in order."""
waiter = LineWaiter()
hooks: list[tuple[str, str]] = []
def on_line(line: str) -> None:
if match := HOOK_PATTERN.search(line):
hooks.append((match[1], match[2]))
waiter.callback(line)
async with (
run_compiled(yaml_config, line_callback=on_line),
api_client_connected(),
):
await wait_or_fail(
waiter.wait_for(LAST_HOOK),
5.0,
"Device did not run the powerdown hooks",
hooks=hooks,
)
return hooks
@pytest.mark.asyncio
async def test_reboot_hook_order_forced(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A forced reboot runs on_shutdown then on_powerdown, with no safe shutdown or teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
@pytest.mark.asyncio
async def test_reboot_hook_order_safe(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A safe reboot powers down only after every component has finished its teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_safe_shutdown"),
("first", "on_safe_shutdown"),
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "teardown_pending"),
("first", "teardown_pending"),
("second", "teardown_done"),
("first", "teardown_done"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]