[template][time] Add template time platform (#14838)

Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
Pat Satyshur
2026-09-27 09:48:08 +00:00
committed by GitHub
co-authored by clydebarrow
parent b8af6cb005
commit ea4754e798
7 changed files with 174 additions and 1 deletions
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA
from esphome.types import ConfigType
from .. import template_ns
TemplateRealTimeClock = template_ns.class_("TemplateRealTimeClock", time_.RealTimeClock)
CONFIG_SCHEMA = time_.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(TemplateRealTimeClock),
cv.Required(CONF_LAMBDA): cv.returning_lambda,
cv.Optional(time_.CONF_ON_TIME_SYNC): cv.invalid(
"on_time_sync is not supported for template time, use on the source clock instead."
),
}
).extend(cv.polling_component_schema("never"))
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await time_.register_time(var, config)
template_ = await cg.process_lambda(
config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.int64)
)
cg.add(var.set_template(template_))
@@ -0,0 +1,20 @@
#include "template_real_time_clock.h"
namespace esphome::template_ {
static const char *const TAG = "template.time";
time_t TemplateRealTimeClock::timestamp_now() {
auto val = this->f_.call();
if (val.has_value()) {
auto value = *val;
if (value < 0 || value > std::numeric_limits<time_t>::max()) {
ESP_LOGW(TAG, "timestamp value out of range: %lld", static_cast<long long>(value));
return 0;
}
return static_cast<time_t>(value);
}
return 0;
}
} // namespace esphome::template_
@@ -0,0 +1,22 @@
#pragma once
#include "esphome/components/time/real_time_clock.h"
#include "esphome/core/template_lambda.h"
namespace esphome::template_ {
class TemplateRealTimeClock : public time::RealTimeClock {
public:
template<typename F> void set_template(F &&f) { this->f_.set(std::forward<F>(f)); }
/// The time is computed on demand and never written to the system clock, so there is nothing to poll.
void update() override {}
/// Returns 0 (an invalid time) if the lambda gives no value.
time_t timestamp_now() override;
protected:
TemplateLambda<int64_t> f_;
};
} // namespace esphome::template_
+1 -1
View File
@@ -29,7 +29,7 @@ class RealTimeClock : public PollingComponent {
ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); }
/// Get the current time as the UTC epoch since January 1st 1970.
time_t timestamp_now() { return ::time(nullptr); }
virtual time_t timestamp_now() { return ::time(nullptr); }
template<typename F> void add_on_time_sync_callback(F &&callback) {
this->time_sync_callback_.add(std::forward<F>(callback));
+4
View File
@@ -3,6 +3,10 @@
time:
- platform: sntp # Required for datetime
id: sntp_time
- platform: template
id: template_offset_time
lambda: |-
return id(sntp_time).timestamp_now() + 3600;
wifi: # Required for sntp time
ap:
@@ -0,0 +1,47 @@
esphome:
name: template-time-test
host:
api:
logger:
time:
- platform: template
id: fixed_time
lambda: |-
return 1700000000;
# Derived from another time source, as when offsetting a real clock.
- platform: template
id: offset_time
lambda: |-
return id(fixed_time).timestamp_now() + 3600;
# A lambda that gives no value reports an invalid time.
- platform: template
id: empty_time
lambda: |-
return {};
text_sensor:
# Timestamps are exposed as text (rather than a 32-bit float sensor state,
# which cannot represent a UNIX epoch exactly) so the exact value can be checked.
- platform: template
name: "Fixed Timestamp"
id: fixed_timestamp
update_interval: 100ms
lambda: |-
return std::to_string(id(fixed_time).now().timestamp);
- platform: template
name: "Offset Timestamp"
id: offset_timestamp
update_interval: 100ms
lambda: |-
return std::to_string(id(offset_time).now().timestamp);
- platform: template
name: "Empty Time Valid"
id: empty_time_valid
update_interval: 100ms
lambda: |-
return std::string(id(empty_time).now().is_valid() ? "valid" : "invalid");
+49
View File
@@ -0,0 +1,49 @@
"""Integration test for the template time platform."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, TextSensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
# Timestamps are exposed as text sensors (rather than 32-bit float sensor states,
# which cannot represent a UNIX epoch exactly) so the exact value can be checked.
EXPECTED_STATES = {
"fixed_timestamp": "1700000000",
"offset_timestamp": "1700003600",
"empty_time_valid": "invalid",
}
@pytest.mark.asyncio
async def test_template_time(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Verify the template time platform evaluates its lambda on demand."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES))
events = {name: asyncio.Event() for name in EXPECTED_STATES}
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
name = key_to_name.get(state.key)
if name is not None and state.state == EXPECTED_STATES[name]:
events[name].set()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
for name, event in events.items():
try:
await asyncio.wait_for(event.wait(), timeout=3.0)
except TimeoutError:
pytest.fail(f"Timeout waiting for {name} to report its expected value")