mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 01:25:57 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7a8ef338 | ||
|
|
904f0df920 | ||
|
|
7128d22f07 | ||
|
|
825e2c3dd0 | ||
|
|
170a3b7374 | ||
|
|
0eee107373 | ||
|
|
2347e0dcef |
No files matched your search
@@ -123,17 +123,10 @@ Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and fo
|
||||
their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first
|
||||
callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
|
||||
|
||||
A filter that keeps state between calls (timing, edges) still needs no Trigger class: give each automation a
|
||||
small object with constant initializers, created from its `CONF_TRIGGER_ID` before the automations are built,
|
||||
and let `when` ask it, e.g. `ApplyCall("{}->check(state, {}, {})", ((CONF_TRIGGER_ID, ...), ...))`. An `ID`
|
||||
value in an `ApplyCall` is written as `::id`, so a callback parameter cannot hide it. `on_click` in
|
||||
`esphome/components/binary_sensor/__init__.py` is the in-tree example; once a second component needs this,
|
||||
move the object creation into `CallbackAutomation`.
|
||||
|
||||
## Triggers: Trigger class method
|
||||
|
||||
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the state must be set up from the
|
||||
parent (in the constructor or in `setup()`), or the trigger needs its own timers.
|
||||
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state
|
||||
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -134,8 +134,10 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_(
|
||||
BinarySensorPtr = BinarySensor.operator("ptr")
|
||||
|
||||
# Triggers
|
||||
ClickDetector = binary_sensor_ns.class_("ClickDetector")
|
||||
DoubleClickDetector = binary_sensor_ns.class_("DoubleClickDetector")
|
||||
ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template())
|
||||
DoubleClickTrigger = binary_sensor_ns.class_(
|
||||
"DoubleClickTrigger", automation.Trigger.template()
|
||||
)
|
||||
MultiClickTrigger = binary_sensor_ns.class_(
|
||||
"MultiClickTrigger", automation.Trigger.template(), cg.Component
|
||||
)
|
||||
@@ -448,23 +450,6 @@ def validate_click_timing(value):
|
||||
return value
|
||||
|
||||
|
||||
def _click_automation_schema(detector: MockObjClass) -> cv.All:
|
||||
return cv.All(
|
||||
automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(detector),
|
||||
cv.Optional(
|
||||
CONF_MIN_LENGTH, default="50ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_MAX_LENGTH, default="350ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
),
|
||||
validate_click_timing,
|
||||
)
|
||||
|
||||
|
||||
def validate_publish_initial_state(value):
|
||||
value = cv.boolean(value)
|
||||
_LOGGER.warning(
|
||||
@@ -497,9 +482,35 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
): validate_filters,
|
||||
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_CLICK): _click_automation_schema(ClickDetector),
|
||||
cv.Optional(CONF_ON_DOUBLE_CLICK): _click_automation_schema(
|
||||
DoubleClickDetector
|
||||
cv.Optional(CONF_ON_CLICK): cv.All(
|
||||
automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClickTrigger),
|
||||
cv.Optional(
|
||||
CONF_MIN_LENGTH, default="50ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_MAX_LENGTH, default="350ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
),
|
||||
validate_click_timing,
|
||||
),
|
||||
cv.Optional(CONF_ON_DOUBLE_CLICK): cv.All(
|
||||
automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
DoubleClickTrigger
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_MIN_LENGTH, default="50ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_MAX_LENGTH, default="350ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
),
|
||||
validate_click_timing,
|
||||
),
|
||||
cv.Optional(CONF_ON_MULTI_CLICK): automation.validate_automation(
|
||||
{
|
||||
@@ -553,11 +564,6 @@ def binary_sensor_schema(
|
||||
return _BINARY_SENSOR_SCHEMA.extend(schema)
|
||||
|
||||
|
||||
_CLICK_DETECTORS = (
|
||||
(CONF_ON_CLICK, ClickDetector),
|
||||
(CONF_ON_DOUBLE_CLICK, DoubleClickDetector),
|
||||
)
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PRESS,
|
||||
@@ -580,40 +586,32 @@ _CALLBACK_AUTOMATIONS = (
|
||||
"add_full_state_callback",
|
||||
[(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")],
|
||||
),
|
||||
*(
|
||||
automation.CallbackAutomation(
|
||||
conf_key,
|
||||
"add_on_state_callback",
|
||||
params=[(bool, "state")],
|
||||
forward=[],
|
||||
when=automation.ApplyCall(
|
||||
"{}->check(state, {}, {})",
|
||||
(
|
||||
(CONF_TRIGGER_ID, detector.operator("ptr")),
|
||||
(CONF_MIN_LENGTH, cg.uint32),
|
||||
(CONF_MAX_LENGTH, cg.uint32),
|
||||
),
|
||||
),
|
||||
)
|
||||
for conf_key, detector in _CLICK_DETECTORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.AUTOMATION)
|
||||
async def _build_binary_sensor_automations(var, config):
|
||||
# The detectors must exist before the automations that name them are built
|
||||
if clicks := [c for key, _ in _CLICK_DETECTORS for c in config.get(key, ())]:
|
||||
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
|
||||
for conf in clicks:
|
||||
cg.new_Pvariable(conf[CONF_TRIGGER_ID])
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
|
||||
if config.get(CONF_ON_MULTI_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
|
||||
if config.get(CONF_ON_RAW_VALUE):
|
||||
cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK")
|
||||
|
||||
for conf in config.get(CONF_ON_CLICK, []):
|
||||
trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
|
||||
)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
for conf in config.get(CONF_ON_DOUBLE_CLICK, []):
|
||||
trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
|
||||
)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
for conf in config.get(CONF_ON_MULTI_CLICK, []):
|
||||
timings = [
|
||||
cg.StructInitializer(
|
||||
|
||||
@@ -136,28 +136,6 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
|
||||
return length >= min_length && length <= max_length;
|
||||
}
|
||||
}
|
||||
|
||||
bool ClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
|
||||
if (state) {
|
||||
this->start_time_ = millis();
|
||||
return false;
|
||||
}
|
||||
return match_interval(min_length, max_length, millis() - this->start_time_);
|
||||
}
|
||||
|
||||
bool DoubleClickDetector::check(bool state, uint32_t min_length, uint32_t max_length) {
|
||||
const uint32_t now = millis();
|
||||
if (state && this->start_time_ != 0 && this->end_time_ != 0 &&
|
||||
match_interval(min_length, max_length, this->end_time_ - this->start_time_) &&
|
||||
match_interval(min_length, max_length, now - this->end_time_)) {
|
||||
this->start_time_ = 0;
|
||||
this->end_time_ = 0;
|
||||
return true;
|
||||
}
|
||||
this->start_time_ = this->end_time_;
|
||||
this->end_time_ = now;
|
||||
return false;
|
||||
}
|
||||
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
|
||||
} // namespace esphome::binary_sensor
|
||||
|
||||
@@ -24,25 +24,54 @@ static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t>
|
||||
|
||||
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
|
||||
|
||||
/// Times the presses of one on_click automation.
|
||||
class ClickDetector {
|
||||
class ClickTrigger final : public Trigger<> {
|
||||
public:
|
||||
/// Whether `state` ends a press of min_length to max_length ms; a max_length of 0 means no maximum.
|
||||
bool check(bool state, uint32_t min_length, uint32_t max_length);
|
||||
explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
|
||||
: min_length_(min_length), max_length_(max_length) {
|
||||
parent->add_on_state_callback([this](bool state) {
|
||||
if (state) {
|
||||
this->start_time_ = millis();
|
||||
} else {
|
||||
const uint32_t length = millis() - this->start_time_;
|
||||
if (match_interval(this->min_length_, this->max_length_, length))
|
||||
this->trigger();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected:
|
||||
uint32_t start_time_{0}; ///< The millis() time when the press started.
|
||||
uint32_t start_time_{0}; /// The millis() time when the click started.
|
||||
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
|
||||
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
|
||||
};
|
||||
|
||||
/// Times the presses of one on_double_click automation.
|
||||
class DoubleClickDetector {
|
||||
class DoubleClickTrigger final : public Trigger<> {
|
||||
public:
|
||||
/// Whether `state` is the second press, with the first press and the gap both in the window.
|
||||
bool check(bool state, uint32_t min_length, uint32_t max_length);
|
||||
explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length)
|
||||
: min_length_(min_length), max_length_(max_length) {
|
||||
parent->add_on_state_callback([this](bool state) {
|
||||
const uint32_t now = millis();
|
||||
|
||||
if (state && this->start_time_ != 0 && this->end_time_ != 0) {
|
||||
if (match_interval(this->min_length_, this->max_length_, this->end_time_ - this->start_time_) &&
|
||||
match_interval(this->min_length_, this->max_length_, now - this->end_time_)) {
|
||||
this->trigger();
|
||||
this->start_time_ = 0;
|
||||
this->end_time_ = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this->start_time_ = this->end_time_;
|
||||
this->end_time_ = now;
|
||||
});
|
||||
}
|
||||
|
||||
protected:
|
||||
uint32_t start_time_{0}; ///< The millis() time of the edge before the last one.
|
||||
uint32_t end_time_{0}; ///< The millis() time of the last edge.
|
||||
uint32_t start_time_{0};
|
||||
uint32_t end_time_{0};
|
||||
uint32_t min_length_; /// Minimum length of click. 0 means no minimum.
|
||||
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
|
||||
};
|
||||
|
||||
class MultiClickTrigger final : public Trigger<>, public Component {
|
||||
|
||||
@@ -95,4 +95,19 @@ void ClimateIR::dump_config() {
|
||||
YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)));
|
||||
}
|
||||
|
||||
int find_ir_code_index(const IRCode *table, size_t count, uint8_t key, bool match_code) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (progmem_read_byte(match_code ? &table[i].code : &table[i].value) == key)
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t lookup_ir_code(const IRCode *table, size_t count, uint8_t key, bool match_code, uint8_t fallback) {
|
||||
const int index = find_ir_code_index(table, count, key, match_code);
|
||||
if (index < 0)
|
||||
return fallback;
|
||||
return progmem_read_byte(match_code ? &table[index].value : &table[index].code);
|
||||
}
|
||||
|
||||
} // namespace esphome::climate_ir
|
||||
@@ -6,9 +6,42 @@
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/components/remote_transmitter/remote_transmitter.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/optional.h"
|
||||
|
||||
namespace esphome::climate_ir {
|
||||
|
||||
/// One entry of a PROGMEM table that maps a climate enum value to the unit's code.
|
||||
struct IRCode {
|
||||
uint8_t value; // a climate enum; they are all uint8_t
|
||||
uint8_t code;
|
||||
};
|
||||
|
||||
/// Index of the entry whose value (or, with match_code, code) is key, or -1. Reads the table from PROGMEM.
|
||||
int find_ir_code_index(const IRCode *table, size_t count, uint8_t key, bool match_code);
|
||||
|
||||
/// The entry whose value (or, with match_code, code) is key, read from PROGMEM.
|
||||
template<size_t N>
|
||||
ESPHOME_ALWAYS_INLINE optional<IRCode> find_ir_code(const IRCode (&table)[N], uint8_t key, bool match_code) {
|
||||
const int index = find_ir_code_index(table, N, key, match_code);
|
||||
if (index < 0)
|
||||
return {};
|
||||
return IRCode{progmem_read_byte(&table[index].value), progmem_read_byte(&table[index].code)};
|
||||
}
|
||||
|
||||
/// The code for value (or, with match_code, the value for code), or fallback when the table has no such entry.
|
||||
uint8_t lookup_ir_code(const IRCode *table, size_t count, uint8_t key, bool match_code, uint8_t fallback);
|
||||
|
||||
/// The code for value, or fallback when the table has no such value.
|
||||
template<size_t N> uint8_t to_ir_code(const IRCode (&table)[N], uint8_t value, uint8_t fallback) {
|
||||
return lookup_ir_code(table, N, value, false, fallback);
|
||||
}
|
||||
/// The value for code, or fallback when the table has no such code; pass the current state to keep it.
|
||||
template<typename T, size_t N> T from_ir_code(const IRCode (&table)[N], uint8_t code, T fallback) {
|
||||
static_assert(sizeof(T) == sizeof(uint8_t), "IRCode values are one byte");
|
||||
return static_cast<T>(lookup_ir_code(table, N, code, true, static_cast<uint8_t>(fallback)));
|
||||
}
|
||||
|
||||
/* A base for climate which works by sending (and receiving) IR codes
|
||||
|
||||
To send IR codes implement
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -50,34 +50,25 @@ static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = {
|
||||
static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF,
|
||||
255 - BYTE3_POWER_OFF};
|
||||
|
||||
struct Code {
|
||||
uint8_t value; // a climate enum, all of which are uint8_t
|
||||
uint8_t code;
|
||||
};
|
||||
static constexpr Code MODES[] PROGMEM = {
|
||||
using climate_ir::find_ir_code;
|
||||
using climate_ir::from_ir_code;
|
||||
using climate_ir::IRCode;
|
||||
using climate_ir::to_ir_code;
|
||||
|
||||
// A received mode that is not listed is ignored
|
||||
static constexpr IRCode MODES[] PROGMEM = {
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO},
|
||||
{climate::CLIMATE_MODE_COOL, 0x80},
|
||||
{climate::CLIMATE_MODE_DRY, 0x40},
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, 0xC0},
|
||||
{climate::CLIMATE_MODE_HEAT, 0x20},
|
||||
};
|
||||
static constexpr Code FANS[] PROGMEM = {
|
||||
static constexpr IRCode FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80},
|
||||
{climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0},
|
||||
{climate::CLIMATE_FAN_QUIET, 0x20},
|
||||
};
|
||||
|
||||
// The entry whose value (or code) matches key. Entries are copied out of PROGMEM.
|
||||
template<size_t N> static optional<Code> find_entry(const Code (&table)[N], uint8_t key, bool match_code) {
|
||||
for (const Code &slot : table) {
|
||||
Code entry;
|
||||
progmem_memcpy(&entry, &slot, sizeof(entry));
|
||||
if ((match_code ? entry.code : entry.value) == key)
|
||||
return entry;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse.
|
||||
static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; }
|
||||
|
||||
@@ -151,14 +142,11 @@ void FriedrichClimate::transmit_state() {
|
||||
temperature += temperature % 2;
|
||||
this->target_temperature = temperature;
|
||||
|
||||
const auto mode = find_entry(MODES, this->mode, false);
|
||||
const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false);
|
||||
|
||||
uint8_t frame[STATE_MESSAGE_LENGTH];
|
||||
progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame));
|
||||
frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP);
|
||||
frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO;
|
||||
frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO;
|
||||
frame[7] = to_ir_code(MODES, this->mode, BYTE7_MODE_AUTO);
|
||||
frame[8] = to_ir_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), BYTE8_FAN_AUTO);
|
||||
if (this->swing_mode != climate::CLIMATE_SWING_OFF)
|
||||
frame[8] |= BYTE8_FAN_SWING;
|
||||
frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame);
|
||||
@@ -209,7 +197,7 @@ bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) {
|
||||
return false;
|
||||
|
||||
// Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY)
|
||||
const auto mode = find_entry(MODES, d[7], true);
|
||||
const auto mode = find_ir_code(MODES, d[7], true);
|
||||
if (!mode.has_value()) {
|
||||
ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]);
|
||||
return false;
|
||||
@@ -224,8 +212,7 @@ bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) {
|
||||
this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP;
|
||||
|
||||
this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
|
||||
const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true);
|
||||
this->fan_mode = fan.has_value() ? static_cast<climate::ClimateFanMode>(fan->value) : climate::CLIMATE_FAN_AUTO;
|
||||
this->fan_mode = from_ir_code(FANS, d[8] & ~BYTE8_FAN_SWING, climate::CLIMATE_FAN_AUTO);
|
||||
|
||||
this->publish_state();
|
||||
return true;
|
||||
|
||||
@@ -21,19 +21,16 @@ constexpr uint8_t MISTRAL_POWER_ON = 0x20;
|
||||
constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F;
|
||||
constexpr uint8_t MISTRAL_MODE_AUTO = 0x10;
|
||||
|
||||
// The tables live in flash, so entries are copied out before use
|
||||
struct ModeCode {
|
||||
climate::ClimateMode mode;
|
||||
uint8_t code;
|
||||
};
|
||||
static const ModeCode MODES[] PROGMEM = {
|
||||
// OFF sends the auto mode code and only clears the power byte
|
||||
static constexpr climate_ir::IRCode MODES[] PROGMEM = {
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO},
|
||||
{climate::CLIMATE_MODE_COOL, 0xC0},
|
||||
{climate::CLIMATE_MODE_DRY, 0x40},
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, 0x80},
|
||||
{climate::CLIMATE_MODE_HEAT, 0x60},
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO},
|
||||
};
|
||||
|
||||
// Each fan speed sets two bytes; the table lives in flash, so entries are copied out before use
|
||||
struct FanCode {
|
||||
climate::ClimateFanMode fan;
|
||||
uint8_t byte1;
|
||||
@@ -78,7 +75,6 @@ static bool is_valid_frame(const uint8_t *frame) {
|
||||
}
|
||||
|
||||
void MistralIR::transmit_state() {
|
||||
const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; });
|
||||
const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO);
|
||||
const FanCode fan =
|
||||
find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0]));
|
||||
@@ -88,7 +84,7 @@ void MistralIR::transmit_state() {
|
||||
progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame));
|
||||
frame[1] = fan.byte1;
|
||||
frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON;
|
||||
frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO;
|
||||
frame[4] = climate_ir::to_ir_code(MODES, this->mode, MISTRAL_MODE_AUTO);
|
||||
frame[5] = reverse_bits(static_cast<uint8_t>(MISTRAL_TEMP_OFFSET - temp));
|
||||
frame[6] = fan.byte6;
|
||||
frame[11] = compute_checksum(frame);
|
||||
@@ -116,12 +112,12 @@ bool MistralIR::on_receive(remote_base::RemoteReceiveData data) {
|
||||
return false;
|
||||
|
||||
// Decode everything before touching the state so an unknown code leaves it untouched
|
||||
const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; });
|
||||
const auto mode = climate_ir::find_ir_code(MODES, frame[4], true);
|
||||
const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; });
|
||||
if (!mode.has_value() || !fan.has_value())
|
||||
return false;
|
||||
|
||||
this->mode = mode->mode;
|
||||
this->mode = static_cast<climate::ClimateMode>(mode->value);
|
||||
this->target_temperature =
|
||||
clamp<float>(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX);
|
||||
this->fan_mode = fan->fan;
|
||||
|
||||
@@ -47,46 +47,28 @@ static bool is_powered_on(const uint8_t *state) {
|
||||
return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON;
|
||||
}
|
||||
|
||||
struct Code {
|
||||
uint8_t value; // a climate enum, all of which are uint8_t
|
||||
uint8_t code;
|
||||
};
|
||||
// The first entry of each table is the fallback in both directions
|
||||
static constexpr Code MODES[] PROGMEM = {
|
||||
using climate_ir::from_ir_code;
|
||||
using climate_ir::IRCode;
|
||||
using climate_ir::to_ir_code;
|
||||
|
||||
// Codes not listed fall back to auto (mode and fan) and to swing off
|
||||
static constexpr IRCode MODES[] PROGMEM = {
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2},
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4},
|
||||
};
|
||||
static constexpr Code FANS[] PROGMEM = {
|
||||
static constexpr IRCode FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, 0},
|
||||
{climate::CLIMATE_FAN_LOW, 2},
|
||||
{climate::CLIMATE_FAN_MEDIUM, 4},
|
||||
{climate::CLIMATE_FAN_HIGH, 5},
|
||||
};
|
||||
static constexpr Code SWINGS[] PROGMEM = {
|
||||
static constexpr IRCode SWINGS[] PROGMEM = {
|
||||
{climate::CLIMATE_SWING_OFF, 0b111},
|
||||
{climate::CLIMATE_SWING_VERTICAL, 0b010},
|
||||
{climate::CLIMATE_SWING_HORIZONTAL, 0b011},
|
||||
{climate::CLIMATE_SWING_BOTH, 0b100},
|
||||
};
|
||||
|
||||
// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM.
|
||||
static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) {
|
||||
Code entry{};
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
progmem_memcpy(&entry, &table[i], sizeof(entry));
|
||||
if ((match_code ? entry.code : entry.value) == key)
|
||||
return entry;
|
||||
}
|
||||
progmem_memcpy(&entry, table, sizeof(entry));
|
||||
return entry;
|
||||
}
|
||||
template<size_t N> static uint8_t to_code(const Code (&table)[N], uint8_t value) {
|
||||
return find_entry(table, N, value, false).code;
|
||||
}
|
||||
template<typename T, size_t N> static T from_code(const Code (&table)[N], uint8_t code) {
|
||||
return static_cast<T>(find_entry(table, N, code, true).value);
|
||||
}
|
||||
|
||||
// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself.
|
||||
// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947
|
||||
static uint8_t section_checksum(const uint8_t *section) {
|
||||
@@ -123,13 +105,13 @@ void SamsungClimate::transmit_state() {
|
||||
|
||||
if (on) {
|
||||
progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH);
|
||||
set_field(state, MODE, to_code(MODES, this->mode));
|
||||
set_field(state, MODE, to_ir_code(MODES, this->mode, 0));
|
||||
} // else keep the last mode so the power off frame still carries a valid state
|
||||
|
||||
const uint8_t temp = clamp<uint8_t>(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX);
|
||||
set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN);
|
||||
set_field(state, SWING, to_code(SWINGS, this->swing_mode));
|
||||
set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)));
|
||||
set_field(state, SWING, to_ir_code(SWINGS, this->swing_mode, 0b111));
|
||||
set_field(state, FAN, to_ir_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), 0));
|
||||
set_field(state, POWER_1, on ? POWER_ON : 0);
|
||||
set_field(state, POWER_2, on ? POWER_ON : 0);
|
||||
|
||||
@@ -212,11 +194,11 @@ bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH);
|
||||
memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH);
|
||||
|
||||
this->swing_mode = from_code<climate::ClimateSwingMode>(SWINGS, get_field(state, SWING));
|
||||
this->swing_mode = from_ir_code(SWINGS, get_field(state, SWING), climate::CLIMATE_SWING_OFF);
|
||||
this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN;
|
||||
this->fan_mode = from_code<climate::ClimateFanMode>(FANS, get_field(state, FAN));
|
||||
this->mode =
|
||||
is_powered_on(state) ? from_code<climate::ClimateMode>(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF;
|
||||
this->fan_mode = from_ir_code(FANS, get_field(state, FAN), climate::CLIMATE_FAN_AUTO);
|
||||
this->mode = is_powered_on(state) ? from_ir_code(MODES, get_field(state, MODE), climate::CLIMATE_MODE_HEAT_COOL)
|
||||
: climate::CLIMATE_MODE_OFF;
|
||||
|
||||
ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state)));
|
||||
this->publish_state();
|
||||
|
||||
@@ -6,70 +6,32 @@ namespace esphome::siemens_ira211 {
|
||||
|
||||
using namespace remote_base;
|
||||
|
||||
struct PresetMapping {
|
||||
climate::ClimatePreset preset;
|
||||
IRA211Mode mode;
|
||||
// Presets map to IRA211Mode and fan modes to IRA211Fan. Values the other side does not know fall back to comfort
|
||||
// and auto.
|
||||
static constexpr climate_ir::IRCode PRESETS[] PROGMEM = {
|
||||
{climate::CLIMATE_PRESET_COMFORT, static_cast<uint8_t>(IRA211Mode::IRA211_MODE_COMFORT)},
|
||||
{climate::CLIMATE_PRESET_ECO, static_cast<uint8_t>(IRA211Mode::IRA211_MODE_TIMER)},
|
||||
};
|
||||
static constexpr climate_ir::IRCode FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, static_cast<uint8_t>(IRA211Fan::IRA211_FAN_AUTO)},
|
||||
{climate::CLIMATE_FAN_LOW, static_cast<uint8_t>(IRA211Fan::IRA211_FAN_LOW)},
|
||||
{climate::CLIMATE_FAN_MEDIUM, static_cast<uint8_t>(IRA211Fan::IRA211_FAN_MEDIUM)},
|
||||
{climate::CLIMATE_FAN_HIGH, static_cast<uint8_t>(IRA211Fan::IRA211_FAN_HIGH)},
|
||||
};
|
||||
|
||||
static const PresetMapping PRESETS[] PROGMEM = {
|
||||
{climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT},
|
||||
{climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER},
|
||||
};
|
||||
|
||||
struct FanMapping {
|
||||
climate::ClimateFanMode fan_mode;
|
||||
IRA211Fan fan;
|
||||
};
|
||||
|
||||
static const FanMapping FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO},
|
||||
{climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW},
|
||||
{climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM},
|
||||
{climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH},
|
||||
};
|
||||
|
||||
// The tables live in flash, so entries are copied out before use
|
||||
template<typename T> static T load_entry(const T &slot) {
|
||||
T entry;
|
||||
progmem_memcpy(&entry, &slot, sizeof(T));
|
||||
return entry;
|
||||
}
|
||||
|
||||
// The first entry of each table is the fallback for values the other side does not know
|
||||
static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) {
|
||||
for (const auto &slot : PRESETS) {
|
||||
const auto entry = load_entry(slot);
|
||||
if (entry.preset == preset)
|
||||
return entry.mode;
|
||||
}
|
||||
return load_entry(PRESETS[0]).mode;
|
||||
return static_cast<IRA211Mode>(
|
||||
climate_ir::to_ir_code(PRESETS, preset, static_cast<uint8_t>(IRA211Mode::IRA211_MODE_COMFORT)));
|
||||
}
|
||||
|
||||
static climate::ClimatePreset to_climate_preset(IRA211Mode mode) {
|
||||
for (const auto &slot : PRESETS) {
|
||||
const auto entry = load_entry(slot);
|
||||
if (entry.mode == mode)
|
||||
return entry.preset;
|
||||
}
|
||||
return load_entry(PRESETS[0]).preset;
|
||||
return climate_ir::from_ir_code(PRESETS, static_cast<uint8_t>(mode), climate::CLIMATE_PRESET_COMFORT);
|
||||
}
|
||||
|
||||
static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) {
|
||||
for (const auto &slot : FANS) {
|
||||
const auto entry = load_entry(slot);
|
||||
if (entry.fan_mode == fan_mode)
|
||||
return entry.fan;
|
||||
}
|
||||
return load_entry(FANS[0]).fan;
|
||||
return static_cast<IRA211Fan>(
|
||||
climate_ir::to_ir_code(FANS, fan_mode, static_cast<uint8_t>(IRA211Fan::IRA211_FAN_AUTO)));
|
||||
}
|
||||
|
||||
static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) {
|
||||
for (const auto &slot : FANS) {
|
||||
const auto entry = load_entry(slot);
|
||||
if (entry.fan == fan)
|
||||
return entry.fan_mode;
|
||||
}
|
||||
return load_entry(FANS[0]).fan_mode;
|
||||
return climate_ir::from_ir_code(FANS, static_cast<uint8_t>(fan), climate::CLIMATE_FAN_AUTO);
|
||||
}
|
||||
|
||||
void SiemensIRA211Climate::setup() {
|
||||
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
|
||||
@@ -387,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
|
||||
|
||||
@@ -8,24 +8,18 @@ from esphome.core import CORE
|
||||
|
||||
|
||||
def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Each trigger gets a detector and a callback with its timing in milliseconds, defaults 50 and 350."""
|
||||
"""Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation."""
|
||||
main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml")
|
||||
|
||||
kinds = dict(
|
||||
re.findall(
|
||||
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Detector\(\);", main_cpp
|
||||
)
|
||||
triggers = re.findall(
|
||||
r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);",
|
||||
main_cpp,
|
||||
)
|
||||
calls = re.findall(r"if \(!\(::(\w+)->check\(state, (\d+), (\d+)\)\)\)", main_cpp)
|
||||
assert [(kinds[detector_id], *timing) for detector_id, *timing in calls] == [
|
||||
assert [trigger[1:] for trigger in triggers] == [
|
||||
("Click", "50", "350"),
|
||||
("Click", "1000", "3000"),
|
||||
("DoubleClick", "20", "400"),
|
||||
]
|
||||
assert main_cpp.count("button_a->add_on_state_callback([](") == 3
|
||||
# Only the automation with a delay keeps an Automation object
|
||||
assert re.findall(r"new\((\w+)\) Automation<>\(\);", main_cpp) == [
|
||||
"automation_id_2"
|
||||
]
|
||||
assert "::automation_id_2->trigger();" in main_cpp
|
||||
for trigger_id, *_ in triggers:
|
||||
assert f"Automation<>({trigger_id});" in main_cpp
|
||||
assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines}
|
||||
@@ -21,9 +21,7 @@ binary_sensor:
|
||||
- delay: 1s
|
||||
- logger.log: a long click done
|
||||
on_double_click:
|
||||
# Named like the callback parameter, which must not hide it
|
||||
- trigger_id: state
|
||||
min_length: 20ms
|
||||
- min_length: 20ms
|
||||
max_length: 400ms
|
||||
then:
|
||||
- logger.log: a double click
|
||||
@@ -1,15 +1,16 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
#include <memory>
|
||||
|
||||
#include "esphome/components/binary_sensor/automation.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/binary_sensor/filter.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/base_automation.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::binary_sensor::testing {
|
||||
|
||||
// The click detectors read the real millis(), so every wait is at least 80 ms away from a window edge
|
||||
// The triggers read the real millis(), so every wait is at least 80 ms away from a window edge
|
||||
static constexpr uint32_t MIN_MS = 100;
|
||||
static constexpr uint32_t MAX_MS = 300;
|
||||
static constexpr uint32_t SHORT_MS = 20;
|
||||
@@ -23,24 +24,18 @@ class ClickTriggerTest : public ::testing::Test {
|
||||
void SetUp() override {
|
||||
fired = 0;
|
||||
this->sensor_.publish_state(false);
|
||||
this->automation_.add_action(&this->action_);
|
||||
}
|
||||
|
||||
// As codegen builds on_click and on_double_click
|
||||
void add_click_(uint32_t min_length = MIN_MS, uint32_t max_length = MAX_MS, BinarySensor *sensor = nullptr) {
|
||||
this->watch_(this->click_, min_length, max_length, sensor != nullptr ? *sensor : this->sensor_);
|
||||
this->click_ = std::make_unique<ClickTrigger>(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length);
|
||||
this->click_->set_automation_parent(&this->automation_);
|
||||
}
|
||||
void add_double_click_(std::function<void()> on_fire = count_fire) {
|
||||
this->watch_(this->double_click_, MIN_MS, MAX_MS, this->sensor_, std::move(on_fire));
|
||||
void add_double_click_() {
|
||||
this->double_click_ = std::make_unique<DoubleClickTrigger>(&this->sensor_, MIN_MS, MAX_MS);
|
||||
this->double_click_->set_automation_parent(&this->automation_);
|
||||
}
|
||||
template<typename D>
|
||||
void watch_(D &detector, uint32_t min_length, uint32_t max_length, BinarySensor &sensor,
|
||||
std::function<void()> on_fire = count_fire) {
|
||||
sensor.add_on_state_callback([&detector, min_length, max_length, on_fire = std::move(on_fire)](bool state) {
|
||||
if (detector.check(state, min_length, max_length))
|
||||
on_fire();
|
||||
});
|
||||
}
|
||||
static void count_fire() { fired++; }
|
||||
|
||||
void press_for_(uint32_t length) {
|
||||
this->sensor_.publish_state(true);
|
||||
@@ -49,8 +44,10 @@ class ClickTriggerTest : public ::testing::Test {
|
||||
}
|
||||
|
||||
BinarySensor sensor_;
|
||||
ClickDetector click_;
|
||||
DoubleClickDetector double_click_;
|
||||
StatelessLambdaAction<> action_{[]() { fired++; }};
|
||||
Automation<> automation_;
|
||||
std::unique_ptr<ClickTrigger> click_;
|
||||
std::unique_ptr<DoubleClickTrigger> double_click_;
|
||||
};
|
||||
|
||||
TEST(ClickMatchInterval, BoundsAreInclusive) {
|
||||
@@ -201,23 +198,4 @@ TEST_F(ClickTriggerTest, DoubleClickDoesNotFireTwiceWithoutReset) {
|
||||
EXPECT_EQ(fired, 1);
|
||||
}
|
||||
|
||||
TEST_F(ClickTriggerTest, DoubleClickKeepsPressPublishedByItsActions) {
|
||||
// The times reset before the actions run, so a press the first action publishes is kept
|
||||
this->add_double_click_([this]() {
|
||||
if (++fired == 1) {
|
||||
this->sensor_.publish_state(false);
|
||||
this->sensor_.publish_state(true);
|
||||
}
|
||||
});
|
||||
this->press_for_(INSIDE_MS);
|
||||
delay(INSIDE_MS);
|
||||
this->sensor_.publish_state(true);
|
||||
EXPECT_EQ(fired, 1);
|
||||
delay(INSIDE_MS);
|
||||
this->sensor_.publish_state(false);
|
||||
delay(INSIDE_MS);
|
||||
this->sensor_.publish_state(true);
|
||||
EXPECT_EQ(fired, 2);
|
||||
}
|
||||
|
||||
} // namespace esphome::binary_sensor::testing
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
|
||||
namespace esphome::climate_ir::testing {
|
||||
|
||||
static constexpr IRCode FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, 0x10},
|
||||
{climate::CLIMATE_FAN_LOW, 0x20},
|
||||
{climate::CLIMATE_FAN_HIGH, 0x30},
|
||||
};
|
||||
|
||||
TEST(ClimateIRCodeTest, FindByValue) {
|
||||
const auto entry = find_ir_code(FANS, climate::CLIMATE_FAN_LOW, false);
|
||||
ASSERT_TRUE(entry.has_value());
|
||||
EXPECT_EQ(entry->value, climate::CLIMATE_FAN_LOW);
|
||||
EXPECT_EQ(entry->code, 0x20);
|
||||
}
|
||||
|
||||
TEST(ClimateIRCodeTest, FindByCode) {
|
||||
const auto entry = find_ir_code(FANS, 0x30, true);
|
||||
ASSERT_TRUE(entry.has_value());
|
||||
EXPECT_EQ(entry->value, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
|
||||
TEST(ClimateIRCodeTest, FindMissing) {
|
||||
EXPECT_FALSE(find_ir_code(FANS, climate::CLIMATE_FAN_MEDIUM, false).has_value());
|
||||
// A code that is also a listed value must not match when looking up codes
|
||||
EXPECT_FALSE(find_ir_code(FANS, climate::CLIMATE_FAN_LOW, true).has_value());
|
||||
}
|
||||
|
||||
TEST(ClimateIRCodeTest, ToCode) {
|
||||
EXPECT_EQ(to_ir_code(FANS, climate::CLIMATE_FAN_HIGH, 0x00), 0x30);
|
||||
EXPECT_EQ(to_ir_code(FANS, climate::CLIMATE_FAN_MEDIUM, 0x00), 0x00);
|
||||
}
|
||||
|
||||
TEST(ClimateIRCodeTest, FromCode) {
|
||||
EXPECT_EQ(from_ir_code(FANS, 0x20, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW);
|
||||
EXPECT_EQ(from_ir_code(FANS, 0x99, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_AUTO);
|
||||
// The current state as the fallback leaves it unchanged on an unknown code
|
||||
EXPECT_EQ(from_ir_code(FANS, 0x99, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM);
|
||||
}
|
||||
|
||||
} // namespace esphome::climate_ir::testing
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
+10
@@ -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
|
||||
+33
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
Reference in new issue
Block a user