diff --git a/esphome/components/template/time/__init__.py b/esphome/components/template/time/__init__.py new file mode 100644 index 0000000000..0332198e78 --- /dev/null +++ b/esphome/components/template/time/__init__.py @@ -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_)) diff --git a/esphome/components/template/time/template_real_time_clock.cpp b/esphome/components/template/time/template_real_time_clock.cpp new file mode 100644 index 0000000000..4b523def25 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.cpp @@ -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::max()) { + ESP_LOGW(TAG, "timestamp value out of range: %lld", static_cast(value)); + return 0; + } + return static_cast(value); + } + return 0; +} + +} // namespace esphome::template_ diff --git a/esphome/components/template/time/template_real_time_clock.h b/esphome/components/template/time/template_real_time_clock.h new file mode 100644 index 0000000000..ec381115d6 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.h @@ -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 void set_template(F &&f) { this->f_.set(std::forward(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 f_; +}; + +} // namespace esphome::template_ diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 90994d90f4..f98adb67b5 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -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 void add_on_time_sync_callback(F &&callback) { this->time_sync_callback_.add(std::forward(callback)); diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index d06f3ce131..a93fc0f5b6 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -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: diff --git a/tests/integration/fixtures/template_time.yaml b/tests/integration/fixtures/template_time.yaml new file mode 100644 index 0000000000..d55e8fe656 --- /dev/null +++ b/tests/integration/fixtures/template_time.yaml @@ -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"); diff --git a/tests/integration/test_template_time.py b/tests/integration/test_template_time.py new file mode 100644 index 0000000000..c0a03fe235 --- /dev/null +++ b/tests/integration/test_template_time.py @@ -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")