Compare commits

..
31 changed files with 731 additions and 370 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 */
+43 -90
View File
@@ -1,9 +1,7 @@
from collections.abc import Callable
import errno
import functools
from importlib import resources
import logging
from typing import Any, NamedTuple
import tzlocal
@@ -27,6 +25,7 @@ from esphome.const import (
CONF_SECOND,
CONF_SECONDS,
CONF_TIMEZONE,
CONF_TRIGGER_ID,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
PLATFORM_BK72XX,
@@ -39,7 +38,6 @@ from esphome.const import (
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.helpers import cpp_string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -49,7 +47,7 @@ DOMAIN = "time"
time_ns = cg.esphome_ns.namespace("time")
RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent)
_request_on_time_slot = cg.slot_counter("TIME_ON_TIME_COUNT")
CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component)
# C++ types for pre-parsed timezone struct generation
DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True)
@@ -223,8 +221,7 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
raise cv.Invalid(
f"{name} {v} is out of range (min={min_value} max={max_value})."
)
# YAML true/false pass as 1/0
return sorted({int(v) for v in value})
return sorted(value)
value = cv.string(value)
values = set()
for part in value.split(","):
@@ -234,28 +231,12 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
return validator
class CronField(NamedTuple):
"""An on_time schedule key, the ESPTime member it checks, its value range and value names."""
key: str
member: str
low: int
high: int
names: dict[str, int] | None = None
def validator(self) -> Callable[[Any], list[int]]:
return cron_expression_validator(
self.key.replace("_", " "), self.low, self.high, self.names
)
SECONDS_FIELD = CronField(CONF_SECONDS, "second", 0, 60)
MINUTES_FIELD = CronField(CONF_MINUTES, "minute", 0, 59)
HOURS_FIELD = CronField(CONF_HOURS, "hour", 0, 23)
DAYS_OF_MONTH_FIELD = CronField(CONF_DAYS_OF_MONTH, "day_of_month", 1, 31)
MONTHS_FIELD = CronField(
CONF_MONTHS,
"month",
validate_cron_seconds = cron_expression_validator("seconds", 0, 60)
validate_cron_minutes = cron_expression_validator("minutes", 0, 59)
validate_cron_hours = cron_expression_validator("hours", 0, 23)
validate_cron_days_of_month = cron_expression_validator("days of month", 1, 31)
validate_cron_months = cron_expression_validator(
"months",
1,
12,
{
@@ -273,29 +254,20 @@ MONTHS_FIELD = CronField(
"DEC": 12,
},
)
DAYS_OF_WEEK_FIELD = CronField(
CONF_DAYS_OF_WEEK,
"day_of_week",
validate_cron_days_of_week = cron_expression_validator(
"days of week",
1,
7,
{"SUN": 1, "MON": 2, "TUE": 3, "WED": 4, "THU": 5, "FRI": 6, "SAT": 7},
)
CRON_FIELDS = (
SECONDS_FIELD,
MINUTES_FIELD,
HOURS_FIELD,
DAYS_OF_MONTH_FIELD,
MONTHS_FIELD,
DAYS_OF_WEEK_FIELD,
)
CRON_KEYS = [field.key for field in CRON_FIELDS]
validate_cron_seconds = SECONDS_FIELD.validator()
validate_cron_minutes = MINUTES_FIELD.validator()
validate_cron_hours = HOURS_FIELD.validator()
validate_cron_days_of_month = DAYS_OF_MONTH_FIELD.validator()
validate_cron_months = MONTHS_FIELD.validator()
validate_cron_days_of_week = DAYS_OF_WEEK_FIELD.validator()
CRON_KEYS = [
CONF_SECONDS,
CONF_MINUTES,
CONF_HOURS,
CONF_DAYS_OF_MONTH,
CONF_MONTHS,
CONF_DAYS_OF_WEEK,
]
def validate_cron_raw(value):
@@ -319,9 +291,9 @@ def validate_cron_raw(value):
def validate_time_at(value):
value = cv.time_of_day(value)
return {
CONF_HOURS: validate_cron_hours([value[CONF_HOUR]]),
CONF_MINUTES: validate_cron_minutes([value[CONF_MINUTE]]),
CONF_SECONDS: validate_cron_seconds([value[CONF_SECOND]]),
CONF_HOURS: [value[CONF_HOUR]],
CONF_MINUTES: [value[CONF_MINUTE]],
CONF_SECONDS: [value[CONF_SECOND]],
CONF_DAYS_OF_MONTH: validate_cron_days_of_month("*"),
CONF_MONTHS: validate_cron_months("*"),
CONF_DAYS_OF_WEEK: validate_cron_days_of_week("*"),
@@ -350,33 +322,6 @@ def validate_cron_keys(value):
return cv.has_at_least_one_key(*CRON_KEYS)(value)
def cron_field_check(field: CronField, values: list[int]) -> str | None:
"""C++ check that valid ``x.<member>`` is one of the validated ``values``; None for the full range.
An empty list never matches, one value is compared, more use a mask as wide as the field's range.
"""
if values == list(range(field.low, field.high + 1)):
return None
if not values:
return "false"
member = f"x.{field.member}"
if len(values) == 1:
return f"{member} == {values[0]}"
mask = sum(1 << value for value in values)
suffix = "ULL" if field.high >= 32 else "U"
return f"(({mask:#x}{suffix} >> {member}) & 1)"
def on_time_check(conf: ConfigType) -> str | None:
checks = []
for field in CRON_FIELDS:
if (values := conf.get(field.key)) is None:
continue
if (check := cron_field_check(field, values)) is not None:
checks.append(check)
return " && ".join(checks) or None
def validate_tz(value: str) -> str:
value = cv.string_strict(value)
@@ -414,6 +359,7 @@ TIME_SCHEMA = cv.Schema(
),
cv.Optional(CONF_ON_TIME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CronTrigger),
cv.Optional(CONF_SECONDS): validate_cron_seconds,
cv.Optional(CONF_MINUTES): validate_cron_minutes,
cv.Optional(CONF_HOURS): validate_cron_hours,
@@ -492,18 +438,23 @@ async def setup_time_core_(time_var, config):
cg.add_define("USE_TIME_TRIGGERS")
for conf in on_time:
_request_on_time_slot(str(time_var))
await automation.build_callback_automation(
time_var,
"add_on_time_callback",
[],
conf,
params=[(cg.ESPTime, "x")],
forward=[],
when=on_time_check(conf),
)
if on_time:
cg.add(time_var.start_on_time_checks())
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
seconds = conf.get(CONF_SECONDS, list(range(61)))
cg.add(trigger.add_seconds(seconds))
minutes = conf.get(CONF_MINUTES, list(range(60)))
cg.add(trigger.add_minutes(minutes))
hours = conf.get(CONF_HOURS, list(range(24)))
cg.add(trigger.add_hours(hours))
days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32)))
cg.add(trigger.add_days_of_month(days_of_month))
months = conf.get(CONF_MONTHS, list(range(1, 13)))
cg.add(trigger.add_months(months))
days_of_week = conf.get(CONF_DAYS_OF_WEEK, list(range(1, 8)))
cg.add(trigger.add_days_of_week(days_of_week))
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_TIME_SYNC, []):
await automation.build_callback_automation(
@@ -541,9 +492,11 @@ automation.register_apply_condition(
# posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a
# timezone is configured or detected.
# timezone is configured or detected; automation.cpp holds the on_time
# trigger and is #ifdef'd on USE_TIME_TRIGGERS.
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"posix_tz.cpp": "USE_TIME_TIMEZONE",
"automation.cpp": "USE_TIME_TRIGGERS",
}
)
+99
View File
@@ -0,0 +1,99 @@
#include "automation.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::time {
ESPHOME_LOG_TAG(TAG, "automation");
static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider
// there has been a drastic time synchronization
void CronTrigger::add_second(uint8_t second) { this->seconds_[second] = true; }
void CronTrigger::add_minute(uint8_t minute) { this->minutes_[minute] = true; }
void CronTrigger::add_hour(uint8_t hour) { this->hours_[hour] = true; }
void CronTrigger::add_day_of_month(uint8_t day_of_month) { this->days_of_month_[day_of_month] = true; }
void CronTrigger::add_month(uint8_t month) { this->months_[month] = true; }
void CronTrigger::add_day_of_week(uint8_t day_of_week) { this->days_of_week_[day_of_week] = true; }
bool CronTrigger::matches(const ESPTime &time) {
return time.is_valid() && this->seconds_[time.second] && this->minutes_[time.minute] && this->hours_[time.hour] &&
this->days_of_month_[time.day_of_month] && this->months_[time.month] && this->days_of_week_[time.day_of_week];
}
void CronTrigger::setup() {
// Cron resolution is 1 second — check once per second instead of every loop iteration
this->set_interval(1000, [this]() { this->check_time_(); });
}
void CronTrigger::check_time_() {
ESPTime time = this->rtc_->now();
if (!time.is_valid())
return;
if (this->last_check_.has_value()) {
auto &last_check = *this->last_check_;
if (last_check > time && last_check.timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) {
// We went back in time (a lot), probably caused by time synchronization
ESP_LOGW(TAG, "Time has jumped back!");
} else if (last_check >= time) {
// already handled this one
return;
} else if (time > last_check && time.timestamp - last_check.timestamp > MAX_TIMESTAMP_DRIFT) {
// We went ahead in time (a lot), probably caused by time synchronization
ESP_LOGW(TAG, "Time has jumped ahead!");
this->last_check_ = time;
return;
}
while (true) {
last_check.increment_second();
if (last_check >= time)
break;
if (this->matches(last_check))
this->trigger();
}
}
this->last_check_ = time;
if (!time.fields_in_range()) {
ESP_LOGW(TAG, "Time is out of range!");
ESP_LOGD(TAG, "Second=%02u Minute=%02u Hour=%02u DayOfWeek=%u DayOfMonth=%u DayOfYear=%u Month=%u time=%" PRId64,
time.second, time.minute, time.hour, time.day_of_week, time.day_of_month, time.day_of_year, time.month,
(int64_t) time.timestamp);
}
if (this->matches(time))
this->trigger();
}
CronTrigger::CronTrigger(RealTimeClock *rtc) : rtc_(rtc) {}
void CronTrigger::add_seconds(const std::vector<uint8_t> &seconds) {
for (uint8_t it : seconds)
this->add_second(it);
}
void CronTrigger::add_minutes(const std::vector<uint8_t> &minutes) {
for (uint8_t it : minutes)
this->add_minute(it);
}
void CronTrigger::add_hours(const std::vector<uint8_t> &hours) {
for (uint8_t it : hours)
this->add_hour(it);
}
void CronTrigger::add_days_of_month(const std::vector<uint8_t> &days_of_month) {
for (uint8_t it : days_of_month)
this->add_day_of_month(it);
}
void CronTrigger::add_months(const std::vector<uint8_t> &months) {
for (uint8_t it : months)
this->add_month(it);
}
void CronTrigger::add_days_of_week(const std::vector<uint8_t> &days_of_week) {
for (uint8_t it : days_of_week)
this->add_day_of_week(it);
}
float CronTrigger::get_setup_priority() const { return setup_priority::HARDWARE; }
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/time.h"
#include "real_time_clock.h"
#include <vector>
namespace esphome::time {
class CronTrigger final : public Trigger<>, public Component {
public:
explicit CronTrigger(RealTimeClock *rtc);
void add_second(uint8_t second);
void add_seconds(const std::vector<uint8_t> &seconds);
void add_minute(uint8_t minute);
void add_minutes(const std::vector<uint8_t> &minutes);
void add_hour(uint8_t hour);
void add_hours(const std::vector<uint8_t> &hours);
void add_day_of_month(uint8_t day_of_month);
void add_days_of_month(const std::vector<uint8_t> &days_of_month);
void add_month(uint8_t month);
void add_months(const std::vector<uint8_t> &months);
void add_day_of_week(uint8_t day_of_week);
void add_days_of_week(const std::vector<uint8_t> &days_of_week);
bool matches(const ESPTime &time);
void setup() override;
float get_setup_priority() const override;
protected:
void check_time_();
std::bitset<61> seconds_;
std::bitset<60> minutes_;
std::bitset<24> hours_;
std::bitset<32> days_of_month_;
std::bitset<13> months_;
std::bitset<8> days_of_week_;
RealTimeClock *rtc_;
optional<ESPTime> last_check_;
};
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
+13 -41
View File
@@ -1,8 +1,5 @@
#include "real_time_clock.h"
#include "esphome/core/log.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/application.h"
#endif
#ifdef USE_HOST
#include <sys/time.h>
#elif defined(USE_ZEPHYR)
@@ -26,7 +23,19 @@ ESPHOME_LOG_TAG(TAG, "time");
RealTimeClock::RealTimeClock() = default;
ESPTime __attribute__((noinline)) RealTimeClock::now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
ESPTime __attribute__((noinline)) RealTimeClock::now() {
#ifdef USE_TIME_TIMEZONE
time_t epoch = this->timestamp_now();
struct tm local_tm;
if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) {
return ESPTime::from_c_tm(&local_tm, epoch);
}
// Fallback to UTC if parsing failed
return ESPTime::from_epoch_utc(epoch);
#else
return ESPTime::from_epoch_local(this->timestamp_now());
#endif
}
void RealTimeClock::dump_config() {
#ifdef USE_TIME_TIMEZONE
@@ -99,41 +108,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
this->time_sync_callback_.call();
}
#ifdef USE_TIME_TRIGGERS
// How far the clock may move between checks before it counts as a time sync instead of skipped seconds
static constexpr uint32_t MAX_TIMESTAMP_DRIFT = 900;
void RealTimeClock::start_on_time_checks() {
App.scheduler.set_interval(this, 1000, [this]() { this->check_time_(); });
}
void RealTimeClock::check_time_() {
ESPTime time = this->now();
if (!time.is_valid())
return;
const uint32_t epoch = static_cast<uint32_t>(time.timestamp);
const uint32_t last = this->last_check_;
if (last != 0) {
if (last > epoch && last - epoch > MAX_TIMESTAMP_DRIFT) {
ESP_LOGW(TAG, "Time has jumped back!");
} else if (last >= epoch) {
return;
} else if (epoch - last > MAX_TIMESTAMP_DRIFT) {
ESP_LOGW(TAG, "Time has jumped ahead!");
this->last_check_ = epoch;
return;
} else if (epoch - last > 1) {
for (ESPTime skipped = ESPTime::from_epoch_local(last + 1); skipped < time; skipped.increment_second()) {
if (skipped.is_valid())
this->time_callback_.call(skipped);
}
}
}
this->last_check_ = epoch;
this->time_callback_.call(time);
}
#endif
} // namespace esphome::time
+1 -14
View File
@@ -1,5 +1,6 @@
#pragma once
#include <bitset>
#include <cstdlib>
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -34,14 +35,6 @@ class RealTimeClock : public PollingComponent {
this->time_sync_callback_.add(std::forward<F>(callback));
}
#ifdef USE_TIME_TRIGGERS
/// Called once per second with the local time, and once for each second skipped since the last call
/// (up to 15 minutes; a larger jump is treated as a time sync). Only called with a valid time.
template<typename F> void add_on_time_callback(F &&callback) { this->time_callback_.add(std::forward<F>(callback)); }
/// Start the once-a-second check; it keeps running even if this platform fails setup.
void start_on_time_checks();
#endif
void dump_config() override;
protected:
@@ -49,12 +42,6 @@ class RealTimeClock : public PollingComponent {
void synchronize_epoch_(uint32_t epoch);
LazyCallbackManager<void()> time_sync_callback_;
#ifdef USE_TIME_TRIGGERS
void check_time_();
StaticCallbackManager<TIME_ON_TIME_COUNT, void(const ESPTime &)> time_callback_;
uint32_t last_check_{0}; // epoch, 0 until the first valid time
#endif
};
/// How the weekday byte of a BCD calendar names the day, Sunday first.
+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 -1
View File
@@ -369,7 +369,6 @@
#define USE_TEXT_SENSOR_FILTER_RESET
#define USE_TIME
#define USE_TIME_TRIGGERS
#define TIME_ON_TIME_COUNT 2
#define USE_TM1638_KEYS_PUBLISHER
#define USE_TMP102_BINARY_SENSOR
#define USE_TMP102_CONFIGURE
@@ -388,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
@@ -1,34 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
api:
time:
- platform: sntp
id: sntp_time
timezone: UTC0
on_time:
- seconds: 0
then:
- logger.log: sntp_first
- seconds: 30
then:
- logger.log: sntp_second
- platform: homeassistant
id: ha_time
timezone: UTC0
on_time:
- seconds: 15
then:
- logger.log: ha_only
+45 -129
View File
@@ -6,32 +6,17 @@ from typing import Any
import pytest
from esphome.components.time import (
CRON_KEYS,
DAYS_OF_MONTH_FIELD,
DAYS_OF_WEEK_FIELD,
HOURS_FIELD,
MINUTES_FIELD,
MONTHS_FIELD,
SECONDS_FIELD,
TIME_SCHEMA,
CronField,
cron_field_check,
on_time_check,
)
from esphome.components.time import CRON_KEYS, TIME_SCHEMA
import esphome.config_validation as cv
from esphome.const import (
CONF_AT,
CONF_CRON,
CONF_DAYS_OF_MONTH,
CONF_DAYS_OF_WEEK,
CONF_HOUR,
CONF_HOURS,
CONF_MINUTE,
CONF_MINUTES,
CONF_MONTHS,
CONF_ON_TIME,
CONF_SECOND,
CONF_SECONDS,
CONF_THEN,
CONF_TIMEZONE,
@@ -153,48 +138,63 @@ def test_on_time_schedule_rejected(
TIME_SCHEMA({CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{**entry, CONF_THEN: []}]})
def _on_time_lines(action: str, check: str) -> str:
ALL_MINUTES = ", ".join(map(str, range(60)))
ALL_HOURS = ", ".join(map(str, range(24)))
ALL_DOM = ", ".join(map(str, range(1, 32)))
ALL_MONTHS_CPP = ", ".join(map(str, range(1, 13)))
def _cron_lines(
var: str,
seconds: str = "0",
minutes: str = ALL_MINUTES,
hours: str = ALL_HOURS,
days_of_month: str = ALL_DOM,
months: str = ALL_MONTHS_CPP,
days_of_week: str = "1, 2, 3, 4, 5, 6, 7",
) -> str:
return "\n".join(
[
"sntp_time->add_on_time_callback([](const std::remove_cvref_t<ESPTime> & x) -> void {",
f" if (!({check}))",
" return;",
f" esphome__{action}__fn();",
f"new({var}) time::CronTrigger(sntp_time);",
f"{var}->add_seconds({{{seconds}}});",
f"{var}->add_minutes({{{minutes}}});",
f"{var}->add_hours({{{hours}}});",
f"{var}->add_days_of_month({{{days_of_month}}});",
f"{var}->add_months({{{months}}});",
f"{var}->add_days_of_week({{{days_of_week}}});",
]
)
# One entry per on_time automation in config/on_time_fields.yaml, in order.
EXPECTED_ON_TIME_CODE = [
_on_time_lines(
"lambdaaction_id", "x.second == 0 && ((0x84210842108421ULL >> x.minute) & 1)"
_cron_lines(
"time_crontrigger_id", minutes="0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55"
),
_on_time_lines(
"lambdaaction_id_2",
"x.second == 0 && x.minute == 30 && x.hour == 7"
" && ((0x7cU >> x.day_of_week) & 1)",
_cron_lines(
"time_crontrigger_id_2", minutes="30", hours="7", days_of_week="2, 3, 4, 5, 6"
),
_on_time_lines(
"lambdaaction_id_3", "x.second == 0 && x.minute == 30 && x.hour == 7"
_cron_lines("time_crontrigger_id_3", minutes="30", hours="7"),
_cron_lines(
"time_crontrigger_id_4",
minutes="0",
hours="8, 9, 10, 11, 12, 13, 14, 15, 16, 17",
),
_on_time_lines(
"lambdaaction_id_4",
"x.second == 0 && x.minute == 0 && ((0x3ff00U >> x.hour) & 1)",
_cron_lines(
"time_crontrigger_id_5",
minutes="0",
hours="12",
days_of_month="1, 15",
months="1, 7",
),
_on_time_lines(
"lambdaaction_id_5",
"x.second == 0 && x.minute == 0 && x.hour == 12"
" && ((0x8002U >> x.day_of_month) & 1) && ((0x82U >> x.month) & 1)",
),
_on_time_lines(
"lambdaaction_id_6",
"x.second == 0 && x.minute == 0 && x.hour == 0"
" && ((0x20408102U >> x.day_of_month) & 1)",
),
_on_time_lines("lambdaaction_id_7", "((0x1000200040008001ULL >> x.second) & 1)"),
_on_time_lines(
"lambdaaction_id_8", "x.second == 0 && ((0x82U >> x.day_of_week) & 1)"
_cron_lines(
"time_crontrigger_id_6",
minutes="0",
hours="0",
days_of_month="1, 8, 15, 22, 29",
),
_cron_lines("time_crontrigger_id_7", seconds="0, 15, 30, 45, 60"),
_cron_lines("time_crontrigger_id_8", days_of_week="1, 7"),
]
@@ -209,87 +209,3 @@ def test_on_time_generated_code(
found = main_cpp.find(expected, position)
assert found != -1, f"missing or out of order:\n{expected}"
position = found + len(expected)
assert main_cpp.count("sntp_time->start_on_time_checks();") == 1
assert main_cpp.find("sntp_time->start_on_time_checks();", position) != -1
@pytest.mark.parametrize(
("field", "values", "check"),
[
(SECONDS_FIELD, list(range(61)), None),
(DAYS_OF_WEEK_FIELD, list(range(1, 8)), None),
(SECONDS_FIELD, [], "false"),
(MINUTES_FIELD, [30], "x.minute == 30"),
(DAYS_OF_MONTH_FIELD, [31], "x.day_of_month == 31"),
# a 0-60 field takes a 64-bit mask even when every listed value is below 32
(SECONDS_FIELD, [0, 1], "((0x3ULL >> x.second) & 1)"),
(MINUTES_FIELD, [0, 59], "((0x800000000000001ULL >> x.minute) & 1)"),
(HOURS_FIELD, [8, 17], "((0x20100U >> x.hour) & 1)"),
(DAYS_OF_MONTH_FIELD, [1, 31], "((0x80000002U >> x.day_of_month) & 1)"),
(MONTHS_FIELD, [1, 7], "((0x82U >> x.month) & 1)"),
(DAYS_OF_WEEK_FIELD, [2, 3, 4, 5, 6], "((0x7cU >> x.day_of_week) & 1)"),
],
)
def test_cron_field_check(
field: CronField, values: list[int], check: str | None
) -> None:
assert cron_field_check(field, values) == check
def test_empty_field_validates_and_generates(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA(
{CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{CONF_SECONDS: [], CONF_THEN: []}]}
)
assert on_time_check(config[CONF_ON_TIME][0]) == "false"
def test_list_values_are_deduplicated_ints(
set_core_config: SetCoreConfigCallable,
) -> None:
"""``true``/``false`` in a list have always meant 1/0; the check must be valid C++."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA(
{
CONF_TIMEZONE: "UTC0",
CONF_ON_TIME: [{CONF_SECONDS: [True, False, True], CONF_THEN: []}],
}
)
seconds = config[CONF_ON_TIME][0][CONF_SECONDS]
assert seconds == [0, 1]
assert all(type(value) is int for value in seconds)
def test_at_dict_values_are_ints(set_core_config: SetCoreConfigCallable) -> None:
"""The dict form of ``at`` takes the same path, so ``hour: true`` is checked as 1."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA(
{
CONF_TIMEZONE: "UTC0",
CONF_ON_TIME: [
{
CONF_AT: {CONF_HOUR: True, CONF_MINUTE: 0, CONF_SECOND: 0},
CONF_THEN: [],
}
],
}
)
assert on_time_check(config[CONF_ON_TIME][0]) == (
"x.second == 0 && x.minute == 0 && x.hour == 1"
)
def test_every_second_schedule_has_no_check(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A schedule covering every field's full range fires every second, with no check."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA(
{
CONF_TIMEZONE: "UTC0",
CONF_ON_TIME: [{CONF_CRON: "* * * * * *", CONF_THEN: []}],
}
)
assert on_time_check(config[CONF_ON_TIME][0]) is None
+7 -15
View File
@@ -1,5 +1,5 @@
"""The clock's once-a-second on_time check is only compiled when an on_time automation
exists, so the define must follow it; on_time_sync needs no check."""
"""automation.cpp (CronTrigger) is only compiled when an on_time automation
exists, so the define must follow it; on_time_sync needs no class."""
from collections.abc import Callable
from pathlib import Path
@@ -8,29 +8,21 @@ import pytest
from esphome.core import CORE
from ..helpers import get_define_value
@pytest.mark.parametrize(
("fixture", "emits", "slots"),
("fixture", "emits"),
[
("no_triggers.yaml", False, None),
("on_time.yaml", True, "1"),
("on_time_sync.yaml", False, None),
("on_time_fields.yaml", True, "8"),
# the callback list is per clock: its size is the largest count on one clock, not the total
("on_time_two_clocks.yaml", True, "2"),
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", False),
],
)
def test_triggers_define_follows_automations(
fixture: str,
emits: bool,
slots: str | None,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path(fixture))
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_TIME_TRIGGERS" in defines) is emits
assert ("start_on_time_checks()" in main_cpp) is emits
assert get_define_value("TIME_ON_TIME_COUNT") == slots
@@ -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
View File
@@ -6,6 +6,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_define("USE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TRIGGERS")
cg.add_define("TIME_ON_TIME_COUNT", 1)
manifest.to_code = to_code
+57 -12
View File
@@ -3,7 +3,19 @@
#include <gtest/gtest.h>
#include <bitset>
#include <vector>
#include "esphome/components/time/real_time_clock.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/time.h"
// CronTrigger is final; drop that here so the test can reach check_time_() without the scheduler. Everything else
// is included above, so the macro only reaches this one header.
#define final
#include "esphome/components/time/automation.h"
#undef final
namespace esphome::time::testing {
@@ -12,48 +24,81 @@ static constexpr time_t MONDAY_235958 = 1767657598;
class FakeClock : public RealTimeClock {
public:
using RealTimeClock::check_time_;
time_t timestamp_now() override { return this->epoch; }
void update() override {}
time_t epoch{0};
};
class CountAction : public Action<> {
public:
explicit CountAction(int *count) : count_(count) {}
protected:
void play() override { (*this->count_)++; }
int *count_;
};
class TestCronTrigger : public CronTrigger {
public:
using CronTrigger::check_time_;
using CronTrigger::CronTrigger;
};
class OnTimeTest : public ::testing::Test {
protected:
void SetUp() override {
// Other tests leave their zone set; these times are UTC.
this->saved_tz_ = get_global_tz();
set_global_tz(ParsedTimezone{});
this->automation_.add_action(&this->count_);
}
void TearDown() override { set_global_tz(this->saved_tz_); }
// Each stands in for the check code generation writes for an on_time automation.
void match_all_fields_except_seconds_() {
for (uint8_t i = 0; i < 60; i++)
this->trigger_.add_minute(i);
for (uint8_t i = 0; i < 24; i++)
this->trigger_.add_hour(i);
for (uint8_t i = 1; i <= 31; i++)
this->trigger_.add_day_of_month(i);
for (uint8_t i = 1; i <= 12; i++)
this->trigger_.add_month(i);
for (uint8_t i = 1; i <= 7; i++)
this->trigger_.add_day_of_week(i);
}
void match_every_second_() {
this->clock_.add_on_time_callback([this](const ESPTime &) { this->fires_++; });
this->match_all_fields_except_seconds_();
for (uint8_t i = 0; i <= 60; i++)
this->trigger_.add_second(i);
}
void match_second_(uint8_t second) {
this->clock_.add_on_time_callback([this, second](const ESPTime &time) {
if (time.second == second)
this->fires_++;
});
this->match_all_fields_except_seconds_();
this->trigger_.add_second(second);
}
void match_midnight_on_(uint8_t day_of_week) {
this->clock_.add_on_time_callback([this, day_of_week](const ESPTime &time) {
if (time.second == 0 && time.minute == 0 && time.hour == 0 && time.day_of_week == day_of_week)
this->fires_++;
});
this->trigger_.add_second(0);
this->trigger_.add_minute(0);
this->trigger_.add_hour(0);
for (uint8_t i = 1; i <= 31; i++)
this->trigger_.add_day_of_month(i);
for (uint8_t i = 1; i <= 12; i++)
this->trigger_.add_month(i);
this->trigger_.add_day_of_week(day_of_week);
}
// One pass of the 1 s check with the clock at `epoch`; returns how many times on_time fired.
int tick_(time_t epoch) {
this->clock_.epoch = epoch;
this->fires_ = 0;
this->clock_.check_time_();
this->trigger_.check_time_();
return this->fires_;
}
ParsedTimezone saved_tz_{};
FakeClock clock_;
TestCronTrigger trigger_{&this->clock_};
Automation<> automation_{&this->trigger_};
int fires_{0};
CountAction count_{&this->fires_};
};
TEST_F(OnTimeTest, FiresOnlyOnMatchingSecond) {
+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
@@ -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"),
]