Skip to content

Instantly share code, notes, and snippets.

@ginjo
Last active September 12, 2024 22:23
Show Gist options
  • Save ginjo/1515fc8b0cb5cc859005f81f5f73eb5a to your computer and use it in GitHub Desktop.
Save ginjo/1515fc8b0cb5cc859005f81f5f73eb5a to your computer and use it in GitHub Desktop.
C++ class to add dynamic editable cron schedules to esphome
#pragma once
#include <croncpp.h>
#include <iostream>
#include <string>
#include <regex>
#include <vector>
#include <map>
#include <algorithm>
#include <Preferences.h>
/********************************************************************************
Croncpp Scheduler for ESPHome
ABOUT
This Schedule class provides a cron interface for scheduling anything in ESPhome.
The class was originally created to schedule ESPHome Sprinkler controllers,
which is reflected in these instructions and examples.
* Automate trigger times entirely within ESPhome, no home-assistant required.
* Live editable cron expressions, no re-flash or reboot required.
* Multiple cron expressions for each Schedule instance.
* Multiple schedule instances to cover unrelated processes.
* Remembers missed trigger times after power failure or reboot.
This file has been posted to gist.github.com, and the latest version can be found there.
https://gist.github.com/ginjo/1515fc8b0cb5cc859005f81f5f73eb5a
REQUIREMENTS
This class has two dependencies.
Croncpp is a c++ library for handling cron expressions.
https://github.com/mariusbancila/croncpp
https://www.codeproject.com/Articles/1260511/cronpp-A-Cplusplus-Library-for-CRON-Expressions
Preferences are part of the arduino-esp32 library,
and they are provided as part of the esphome build environment.
They are used to store persistent scheduling data on the ESP32 device.
https://docs.espressif.com/projects/arduino-esp32/en/latest/tutorials/preferences.html
https://docs.espressif.com/projects/arduino-esp32/en/latest/api/preferences.html
List the Preferences and Croncpp libraries in the 'esphome: libraries' section
of your configuration file.
esphome:
libraries:
- Croncpp=https://github.com/mariusbancila/croncpp.git
- Preferences
Croncpp's stated requirements are c++11/14/17, but I was only able to compile using gnu++17 dialect.
ESPhome, by default, compiles using the c++11. To use the gnu++17 dialect, include the following
build flags in the 'platformio_options' section of your configuration file.
platformio_options:
build_unflags:
- "-std=gnu++11"
build_flags:
- "-std=gnu++17"
SETUP
Place this schedules.h file in the same directory as your esphome configuration yaml file.
Add the schedules.h file to the 'esphome: includes' section of your yaml file.
esphome:
inculdes:
- schedules.h
Add the code to create and start-up the scheduler to the 'esphome: on_boot' section of your yaml.
esphome:
on_boot:
then:
- lambda: |-
// Creates a new schedule with a name and a lambda.
new Schedule(
"unique-name-15-characters-max",
[]() {
whatever-you-want-to-do-here;
return true; // The scheduler will bump the next trigger time.
return false; // The scheduler will NOT bump the next trigger time.
}
);
// Starts all defined schedule instances (only run once).
Schedule::StartCronLooper();
Create an esphome text component (not text-sensor) to edit the cron schedules.
text:
- id: crontab_input
name: Cron Schedule
mode: text
platform: template
lambda: return {Schedule::Schedules("<schedule-name>")->getCrontab()};
update_interval: 15min
set_action:
- lambda: |-
Schedule::Schedules("<schedule-name>")->setCrontab(x);
id(crontab_input).update();
(optional) Create an esphome text_sensor to display next-run-time from the scheduler.
text_sensor:
- id: cron_next_run_display
name: Next Run
platform: template
lambda: |-
return Schedule::Schedules("<schedule-name>")->cronNextString("---"); // Optionally pass default display string.
update_interval: 1s
filters:
# Prevents publishing updates if no change in value.
- lambda: |-
static std::string last;
if (x == last)
return {};
last = x;
return {x};
USAGE
Cron Expressions
Once your esp is up and running, enter one or more cron expressions in the text field you created.
This library supports multiple cron expressions separated by <space><bar><space>, or literally " | ".
All cron expressions entered will be used to determine the next-run time.
Example entry in crontab field:
0 0 0,5 * * mon,wed,fri | 0 30 2 * * tue,thu
This translates to "every mon, wed, fri at midnight and 5am, AND every tue, thu at 2:30am".
For supported cron expression features and syntax, see the Croncpp documentation.
https://github.com/mariusbancila/croncpp .
Disable Schedules
When this element is turned ON, schedules are disabled. No other functionality of ESPhome is affected.
While schedules are disabled, no next-run time is calculated.
When this element is turned OFF, schedules are activated and a new next-run time is calculated.
TODO: This needs more info and examples.
Multiple Schedule Instances
If your yaml file contains multiple component instances, you can create a Schedule instance for each.
Each schedule instance is independent of the others.
Each instance's "Cron Schedule", "Next Run", and "Disable Schedules" fields
ONLY apply to the Schedule instance they are associated with.
Missed Runs
If a valid next-run was calculated at the time of a power-off or reboot event,
that run will be started at the next power-on, if the following are ALL true:
* Next-run is in the past.
* Disable-schedules is not set to ON.
* Ignore Missed is not enabled.
********************************************************************************/
class Schedule {
private:
// Access to the Preferences handler.
Preferences prefs;
// Maybe see here for polymorphic members vars:
// https://stackoverflow.com/questions/17035951/member-variable-polymorphism-argument-by-reference
//esphome::time::RealTimeClock* esptime;
// Basic data points.
const char * schedule_name;
std::string crontab;
std::time_t cronnext;
bool bypass;
bool ignore_missed;
// Lamba for call to target action.
bool(*target_action_fptr)();
// Vars for integrating with Esphome Interval componenet programatically.
// Used by StartCronLooper() method.
static interval::IntervalTrigger* croncpp_interval_trigger;
static Automation<>* croncpp_automation;
static LambdaAction<>* croncpp_lambda_action;
public:
// Custom constructor method to create Schedule object.
// Create schedules relatively early in the boot process,
// just before you call StartCronLooper().
// TODO: Change class name to be more specific, or consider using namespace?
//
Schedule( // schedule-name, component-id, time-component-id
const char * _name,
//esphome::time::RealTimeClock* _esptime,
bool(*_target_action_fptr)()
) :
schedule_name(_name),
//esptime(_esptime),
crontab(std::string("")),
cronnext(0),
bypass(false),
ignore_missed(false),
target_action_fptr(_target_action_fptr)
{
ESP_LOGD("schedules", "Initializing Schedule object '%s'", schedule_name);
AddToSchedules(this);
loadPrefs();
} // end custom Schedule(...) constructor.
// Globally accessible wrapper for access to all_schedules static var.
static std::vector<Schedule*>& Schedules() {
static std::vector<Schedule*> all_schedules;
return all_schedules;
}
// Gets indexed member of Schedules.
// TODO: Store schedules in a map, instead of vector, and allow access by id or by name.
static Schedule* Schedules(int index) {
if (!Schedules().empty()) {
return Schedules()[index];
}
else {
return nullptr;
}
}
// Gets member of Schedules by schedule_name (as if it was a map).
static Schedule* Schedules(const char* name) {
if (!Schedules().empty()) {
for (auto& x : Schedules()) {
if (x->schedule_name == name) {
return x;
}
}
}
return nullptr;
}
// StartCronLooper() Sets up a recuring interval in esphome, just
// like the built-in Interval component does.
//
// Call StartCronLooper() as soon as you finish creating Schedule objects in yaml.
// It should be realatively early in the boot process.
//
// TODO: Does this actually need the declared static vars (see above under private).
// TODO: Make sure this can't mess up already running schedules.
//
static void StartCronLooper(const int startup_delay = 10000, const int interval = 5000) {
ESP_LOGD("schedules", "StartCronLooper() called");
interval::IntervalTrigger* croncpp_interval_trigger = new interval::IntervalTrigger();
croncpp_interval_trigger->set_component_source("interval");
App.register_component(croncpp_interval_trigger);
Automation<>* croncpp_automation = new Automation<>(croncpp_interval_trigger);
LambdaAction<>* croncpp_lambda_action = new LambdaAction<>([=]() -> void {
// Seems to work with either of these.
//Schedule::CronLooper();
CronLooper();
});
croncpp_automation->add_actions({croncpp_lambda_action});
// TODO: Should these be moved up so they're before the above add_actions method?
// It's not clear which step, actually starts the interval looper, or if
// the looper doesn't actually start until later in the boot process.
croncpp_interval_trigger->set_startup_delay(startup_delay);
croncpp_interval_trigger->set_update_interval(interval);
ESP_LOGD("schedules", "StartCronLooper() finished");
}
// Gets human-readable time of cronnext.
std::string cronNextString(const char* _default="") {
if (cronnext == 0) {
//std::string str("");
std::string str(_default);
return str;
}
else {
return timeToString(cronnext);
}
}
// Experimental - return multiple sequencial cronNextCalc results, as a map of {time_t, cron-next-string}.
std::map<std::time_t, std::string> cronNextMap(int count = 1, std::string _crontab = "", std::time_t ref_time = 0) {
if (_crontab == "") { _crontab = crontab; }
if (ref_time == 0) { ref_time = timeNow(); }
std::map<std::time_t, std::string> out {};
std::time_t this_time_t = ref_time;
std::string this_time_s;
if (_crontab == "" || ref_time == 0) { return out; }
for (int i=count; i > 0; i--) {
this_time_t = cronNextCalc(_crontab, this_time_t);
this_time_s = timeToString(this_time_t);
out.insert({this_time_t, this_time_s});
// ESP_LOGD("schedules", "i: %i, this_time_t: %i, this_time_s: %s", i, this_time_t, this_time_s.c_str());
}
return out;
}
// Is cronnext time older than now?
bool cronNextExpired() {
if (crontab == "" || cronnext == 0 || bypass) { return false;}
return (std::difftime(cronnext, timeNow()) < 0);
}
// Getter for cronnext.
std::time_t getCronNext() {
return cronnext;
}
// Sets cron_next from crontab.
// TODO: Allow a user-entered value to be passed.
void setCronNext() {
if (timeIsValid()) {
if (crontab == "" || bypass) {
cronnext = 0;
}
else {
cronnext = cronNextCalc();
}
ESP_LOGD("schedules", "Setting cronnext for '%s' %s", schedule_name, timeToString(cronnext).c_str());
}
}
// Getter for crontab
std::string getCrontab() {
return crontab;
}
// Sets crontab with given string.
std::string setCrontab(std::string str) {
ESP_LOGD("schedules", "Setting crontab for '%s' %s", schedule_name, str.c_str());
crontab = str;
setCronNext();
return crontab;
}
// Gets bypass setting.
bool getBypass() {
return bypass;
}
// Sets bypass.
bool setBypass(bool val) {
bypass = val;
setCronNext();
return val;
}
// Gets ignore_missed setting.
bool getIgnoreMissed() {
return ignore_missed;
}
// Sets bypass.
bool setIgnoreMissed(bool val) {
ignore_missed = val;
setCronNext();
return val;
}
// Builds human-readable string from time_t.
// See here for printing time_t data:
// https://stackoverflow.com/questions/18422384/how-to-print-time-t-in-a-specific-format
std::string timeToString(std::time_t timet) {
if (timeIsValid()) {
struct tm * timetm;
// Converts time_t to tm (a fancy time object), cuz that's what strftime wants.
timetm = localtime(&timet);
char str[24];
strftime(str, sizeof(str), "%Y-%m-%d %H:%M:%S", timetm);
//ESP_LOGD("schedules", "From inside timeToString() function: %s", str);
std::string char_to_string(str);
return char_to_string;
}
else {
return "";
}
}
private:
// Adds a schedule object to a globally accessible vector array 'all_schedules'.
static void AddToSchedules(Schedule* schedule) {
ESP_LOGD("schedules", "Adding Schedule '%s' to Schedules vector", schedule->schedule_name);
Schedules().push_back(schedule);
}
// Loads persistent data from esp32 nvs.
void loadPrefs() {
prefs.begin(schedule_name, true); // open read-only
ESP_LOGD("schedules", "Opening Preferences '%s' for reading", schedule_name);
size_t number_free_entries = prefs.freeEntries();
ESP_LOGD("schedules", "There are %u free entries available in the namespace table '%s'", number_free_entries, schedule_name);
ESP_LOGD("schedules", "Loading crontab from prefs '%s'", schedule_name);
crontab = std::string(prefs.getString("crontab", String("0 0 0 * * *")).c_str());
ESP_LOGD("schedules", "Loading ignore_missed from prefs '%s'", schedule_name);
ignore_missed = prefs.getBool("ignore_missed", false);
ESP_LOGD("schedules", "Loading bypass from prefs '%s'", schedule_name);
bypass = prefs.getBool("bypass", false);
// This has to load after all the others, since we may need to call setCronNext(),
// which depends on the others being loaded.
if (!ignore_missed) {
ESP_LOGD("schedules", "Loading cronnext from prefs '%s'", schedule_name);
cronnext = (std::time_t) prefs.getDouble("cronnext", 0);
} else {
// timeNow() might not be valid yet, but we'll try here anyway.
// Otherwise, we have a hook in the cron loop that will pick this up.
setCronNext();
}
prefs.end(); // close
ESP_LOGD("schedules", "Schedule '%s' loaded crontab: %s", schedule_name, crontab.c_str());
ESP_LOGD("schedules", "Schedule '%s' loaded ignore_missed: %i", schedule_name, ignore_missed);
if (!ignore_missed) {
ESP_LOGD("schedules", "Schedule '%s' loaded cronnext: %d (%s)", schedule_name, cronnext, timeToString(cronnext).c_str());
}
ESP_LOGD("schedules", "Schedule '%s' loaded bypass: %i", schedule_name, bypass);
}
// Saves persistent data to esp32 nvs.
void savePrefs() {
prefs.begin(schedule_name, true); // open read-only
bool crontab_changed = (crontab != std::string(prefs.getString("crontab", String("0 0 0 * * *")).c_str()));
bool ignore_missed_changed = (ignore_missed != prefs.getBool("ignore_missed", false));
bool cronnext_changed = (cronnext != (std::time_t) prefs.getDouble("cronnext", 0) && !ignore_missed);
bool bypass_changed = (bypass != prefs.getBool("bypass", false));
prefs.end(); // close
// If any changes, then open prefs for writing.
if (crontab_changed || ignore_missed_changed || cronnext_changed || bypass_changed) {
prefs.begin(schedule_name, false); // open as read/write
ESP_LOGD("schedules", "Opening Preferences '%s' for writing", schedule_name);
//size_t number_free_entries = prefs.freeEntries();
//ESP_LOGD("schedules", "There are %u free entries available in the namespace table '%s'", number_free_entries, schedule_name);
if (crontab_changed) {
ESP_LOGD("schedules", "Saving crontab to prefs '%s'", schedule_name);
prefs.putString("crontab", String(crontab.c_str()));
}
if (ignore_missed_changed) {
ESP_LOGD("schedules", "Saving ignore_missed to prefs '%s'", schedule_name);
prefs.putBool("ignore_missed", ignore_missed);
}
if (cronnext_changed) {
ESP_LOGD("schedules", "Saving cronnext to prefs '%s'", schedule_name);
prefs.putDouble("cronnext", cronnext);
}
if (bypass_changed) {
ESP_LOGD("schedules", "Saving bypass to prefs '%s'", schedule_name);
prefs.putBool("bypass", bypass);
}
prefs.end();
} // if any changes
}
// Loops all schedules' cronLoop() method.
static void CronLooper() {
//ESP_LOGD("schedules", "CronLooper() called");
for (auto s : Schedules()) {
s->cronLoop();
}
}
// Compares cronnext with current time and calls lambda.
void cronLoop() {
if (timeIsValid() && cronNextExpired()) {
bool result = target_action_fptr();
if (result) {
setCronNext();
}
}
// We try to setCronNext() at pref loading, but timeNow() might not be valid then,
// so we try to clean it up here. We don't want to run setCronNext(), unless
// absolutely necessary, since we might eventually allow user-input for a one-off.
else if (timeIsValid() && !bypass && ignore_missed && cronnext == 0) {
setCronNext();
}
savePrefs();
}
// Gets next time_t, given cron expression(s) string in crontab.
std::time_t cronNextCalc(std::string _crontab = "", std::time_t ref_time = 0) {
if (_crontab == ""){ _crontab = crontab; }
if (ref_time == 0) { ref_time = timeNow(); }
// Returns 0 if no crontab or ref_time.
if (_crontab == "" || ref_time == 0) { return 0; }
// Requests sorted vector of nexts given crontab parsing string regex.
std::string regex_str = " *\\| *";
auto nexts = vectorOfNext(splitString(_crontab, regex_str), ref_time);
// Logs next-run for each crontab.
// for (auto& item: nexts)
// {
// ESP_LOGD("schedules", "Sorted cron-next: %s", timeToString(item).c_str());
// }
// Returns first (soonest) time_t from vector-of-nexts.
return nexts[0];
}
// Returns current time as time_t.
std::time_t timeNow() {
return std::time(NULL);
}
// Is current esphome time valid (synced & legit)?
bool timeIsValid() {
//ESP_LOGD("schedules", "About to calculate within timeIsValid()", "");
//return id(esptime).now().is_valid();
return (timeNow() > 0);
}
// Function to split std::string on regex.
std::vector<std::string> splitString(const std::string str,
const std::string regex_str) {
std::regex regexz(regex_str);
return {std::sregex_token_iterator(str.begin(), str.end(), regexz, -1),
std::sregex_token_iterator()};
}
// Returns sorted vector of next time_t values for given vector-of-crontab-strings.
std::vector<std::time_t> vectorOfNext(std::vector<std::string> crontabs, std::time_t ref_time = 0) {
if (ref_time == 0) { ref_time = timeNow(); }
std::time_t _ref_time = ref_time;
std::vector<std::time_t> start_times;
// Adds seconds to ref_time, just to make sure it's ahead of input ref_time.
// To do that, we have to convert to tm and back to time_t.
// But it looks like it's not needed!
struct tm * timetm = localtime(&_ref_time);
//timetm->tm_sec += 5;
_ref_time = std::mktime(timetm);
for (auto& item: crontabs)
{
auto cron_obj = cron::make_cron(item);
std::time_t next = cron::cron_next(cron_obj, _ref_time);
start_times.push_back(next);
}
// Sorts (in-place) vector of start_times values from soonest to furthest.
std::sort(start_times.begin(), start_times.end(), [_ref_time](std::time_t& a, std::time_t& b)
{
double diff_a = std::difftime(a, _ref_time);
double diff_b = std::difftime(b, _ref_time);
return diff_a<diff_b;
}
);
return start_times;
}
}; // Schedule class
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment