mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 17:45:57 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b94440ef46 | ||
|
|
5fe744a30e | ||
|
|
3a5a7ba6d3 | ||
|
|
bc4cbe6c51 | ||
|
|
7123d83a30 | ||
|
|
84fbf1a933 | ||
|
|
29e98feabe | ||
|
|
7d972192fc | ||
|
|
dc238ba271 | ||
|
|
1681f7790d | ||
|
|
359e439b18 | ||
|
|
19ee9db1cf | ||
|
|
57294a4499 | ||
|
|
4ce89e21cc | ||
|
|
aeb099d8a1 | ||
|
|
e3563c3799 | ||
|
|
c0f0c61c54 | ||
|
|
aebf8cf78f | ||
|
|
9d6a805343 | ||
|
|
9497ea0345 | ||
|
|
13457001c3 | ||
|
|
06bdedd3ff | ||
|
|
38b86769b3 | ||
|
|
ce4baa22c0 | ||
|
|
db8c32fe00 | ||
|
|
783cb8e449 | ||
|
|
23b82c7c22 | ||
|
|
330ac831fc | ||
|
|
98ba538a29 | ||
|
|
69621497b1 | ||
|
|
93b7ca3bdd | ||
|
|
a708742c5e |
No files matched your search
@@ -1,7 +1,9 @@
|
||||
from collections.abc import Callable
|
||||
import errno
|
||||
import functools
|
||||
from importlib import resources
|
||||
import logging
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import tzlocal
|
||||
|
||||
@@ -25,7 +27,6 @@ from esphome.const import (
|
||||
CONF_SECOND,
|
||||
CONF_SECONDS,
|
||||
CONF_TIMEZONE,
|
||||
CONF_TRIGGER_ID,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
PLATFORM_BK72XX,
|
||||
@@ -38,6 +39,7 @@ 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__)
|
||||
|
||||
@@ -47,7 +49,7 @@ DOMAIN = "time"
|
||||
|
||||
time_ns = cg.esphome_ns.namespace("time")
|
||||
RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent)
|
||||
CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component)
|
||||
_request_on_time_slot = cg.slot_counter("TIME_ON_TIME_COUNT")
|
||||
|
||||
# C++ types for pre-parsed timezone struct generation
|
||||
DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True)
|
||||
@@ -221,7 +223,8 @@ 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})."
|
||||
)
|
||||
return sorted(value)
|
||||
# YAML true/false pass as 1/0
|
||||
return sorted({int(v) for v in value})
|
||||
value = cv.string(value)
|
||||
values = set()
|
||||
for part in value.split(","):
|
||||
@@ -231,12 +234,28 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
|
||||
return validator
|
||||
|
||||
|
||||
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",
|
||||
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",
|
||||
1,
|
||||
12,
|
||||
{
|
||||
@@ -254,20 +273,29 @@ validate_cron_months = cron_expression_validator(
|
||||
"DEC": 12,
|
||||
},
|
||||
)
|
||||
validate_cron_days_of_week = cron_expression_validator(
|
||||
"days of week",
|
||||
DAYS_OF_WEEK_FIELD = CronField(
|
||||
CONF_DAYS_OF_WEEK,
|
||||
"day_of_week",
|
||||
1,
|
||||
7,
|
||||
{"SUN": 1, "MON": 2, "TUE": 3, "WED": 4, "THU": 5, "FRI": 6, "SAT": 7},
|
||||
)
|
||||
CRON_KEYS = [
|
||||
CONF_SECONDS,
|
||||
CONF_MINUTES,
|
||||
CONF_HOURS,
|
||||
CONF_DAYS_OF_MONTH,
|
||||
CONF_MONTHS,
|
||||
CONF_DAYS_OF_WEEK,
|
||||
]
|
||||
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()
|
||||
|
||||
|
||||
def validate_cron_raw(value):
|
||||
@@ -291,9 +319,9 @@ def validate_cron_raw(value):
|
||||
def validate_time_at(value):
|
||||
value = cv.time_of_day(value)
|
||||
return {
|
||||
CONF_HOURS: [value[CONF_HOUR]],
|
||||
CONF_MINUTES: [value[CONF_MINUTE]],
|
||||
CONF_SECONDS: [value[CONF_SECOND]],
|
||||
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_DAYS_OF_MONTH: validate_cron_days_of_month("*"),
|
||||
CONF_MONTHS: validate_cron_months("*"),
|
||||
CONF_DAYS_OF_WEEK: validate_cron_days_of_week("*"),
|
||||
@@ -322,6 +350,33 @@ 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)
|
||||
|
||||
@@ -359,7 +414,6 @@ 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,
|
||||
@@ -438,23 +492,18 @@ async def setup_time_core_(time_var, config):
|
||||
cg.add_define("USE_TIME_TRIGGERS")
|
||||
|
||||
for conf in on_time:
|
||||
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)
|
||||
_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())
|
||||
|
||||
for conf in config.get(CONF_ON_TIME_SYNC, []):
|
||||
await automation.build_callback_automation(
|
||||
@@ -492,11 +541,9 @@ automation.register_apply_condition(
|
||||
|
||||
|
||||
# posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a
|
||||
# timezone is configured or detected; automation.cpp holds the on_time
|
||||
# trigger and is #ifdef'd on USE_TIME_TRIGGERS.
|
||||
# timezone is configured or detected.
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"posix_tz.cpp": "USE_TIME_TIMEZONE",
|
||||
"automation.cpp": "USE_TIME_TRIGGERS",
|
||||
}
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
#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
|
||||
@@ -1,48 +0,0 @@
|
||||
#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
|
||||
@@ -1,5 +1,8 @@
|
||||
#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)
|
||||
@@ -23,19 +26,7 @@ ESPHOME_LOG_TAG(TAG, "time");
|
||||
|
||||
RealTimeClock::RealTimeClock() = default;
|
||||
|
||||
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
|
||||
}
|
||||
ESPTime __attribute__((noinline)) RealTimeClock::now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
|
||||
|
||||
void RealTimeClock::dump_config() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
@@ -108,4 +99,41 @@ 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,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <bitset>
|
||||
#include <cstdlib>
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -35,6 +34,14 @@ 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:
|
||||
@@ -42,6 +49,12 @@ 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.
|
||||
|
||||
@@ -369,6 +369,7 @@
|
||||
#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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
@@ -6,17 +6,32 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.time import CRON_KEYS, TIME_SCHEMA
|
||||
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,
|
||||
)
|
||||
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,
|
||||
@@ -138,63 +153,48 @@ def test_on_time_schedule_rejected(
|
||||
TIME_SCHEMA({CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{**entry, CONF_THEN: []}]})
|
||||
|
||||
|
||||
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:
|
||||
def _on_time_lines(action: str, check: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
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}}});",
|
||||
"sntp_time->add_on_time_callback([](const std::remove_cvref_t<ESPTime> & x) -> void {",
|
||||
f" if (!({check}))",
|
||||
" return;",
|
||||
f" esphome__{action}__fn();",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# One entry per on_time automation in config/on_time_fields.yaml, in order.
|
||||
EXPECTED_ON_TIME_CODE = [
|
||||
_cron_lines(
|
||||
"time_crontrigger_id", minutes="0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55"
|
||||
_on_time_lines(
|
||||
"lambdaaction_id", "x.second == 0 && ((0x84210842108421ULL >> x.minute) & 1)"
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_2", minutes="30", hours="7", days_of_week="2, 3, 4, 5, 6"
|
||||
_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_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_3", "x.second == 0 && x.minute == 30 && x.hour == 7"
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_5",
|
||||
minutes="0",
|
||||
hours="12",
|
||||
days_of_month="1, 15",
|
||||
months="1, 7",
|
||||
_on_time_lines(
|
||||
"lambdaaction_id_4",
|
||||
"x.second == 0 && x.minute == 0 && ((0x3ff00U >> x.hour) & 1)",
|
||||
),
|
||||
_cron_lines(
|
||||
"time_crontrigger_id_6",
|
||||
minutes="0",
|
||||
hours="0",
|
||||
days_of_month="1, 8, 15, 22, 29",
|
||||
_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_7", seconds="0, 15, 30, 45, 60"),
|
||||
_cron_lines("time_crontrigger_id_8", days_of_week="1, 7"),
|
||||
]
|
||||
|
||||
|
||||
@@ -209,3 +209,87 @@ 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
|
||||
@@ -1,5 +1,5 @@
|
||||
"""automation.cpp (CronTrigger) is only compiled when an on_time automation
|
||||
exists, so the define must follow it; on_time_sync needs no class."""
|
||||
"""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."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
@@ -8,21 +8,29 @@ import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..helpers import get_define_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "emits"),
|
||||
("fixture", "emits", "slots"),
|
||||
[
|
||||
("no_triggers.yaml", False),
|
||||
("on_time.yaml", True),
|
||||
("on_time_sync.yaml", False),
|
||||
("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"),
|
||||
],
|
||||
)
|
||||
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:
|
||||
generate_main(component_config_path(fixture))
|
||||
main_cpp = 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
|
||||
@@ -6,5 +6,6 @@ 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
|
||||
@@ -3,19 +3,7 @@
|
||||
|
||||
#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 {
|
||||
|
||||
@@ -24,81 +12,48 @@ 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_); }
|
||||
|
||||
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);
|
||||
}
|
||||
// Each stands in for the check code generation writes for an on_time automation.
|
||||
void match_every_second_() {
|
||||
this->match_all_fields_except_seconds_();
|
||||
for (uint8_t i = 0; i <= 60; i++)
|
||||
this->trigger_.add_second(i);
|
||||
this->clock_.add_on_time_callback([this](const ESPTime &) { this->fires_++; });
|
||||
}
|
||||
void match_second_(uint8_t second) {
|
||||
this->match_all_fields_except_seconds_();
|
||||
this->trigger_.add_second(second);
|
||||
this->clock_.add_on_time_callback([this, second](const ESPTime &time) {
|
||||
if (time.second == second)
|
||||
this->fires_++;
|
||||
});
|
||||
}
|
||||
void match_midnight_on_(uint8_t day_of_week) {
|
||||
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);
|
||||
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_++;
|
||||
});
|
||||
}
|
||||
// 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->trigger_.check_time_();
|
||||
this->clock_.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) {
|
||||
|
||||
Reference in new issue
Block a user