Files
espurna/code/espurna/scheduler.cpp
Maxim Prokhorov ac01afc8f0 sch: stall sunrise & sunset calc when continiously failing
- do not completely fail when either sunrise or sunset missing
  keep what is valid, fall through otherwise
- more event:: helpers for internal comparison
  fix missing to_minutes when comparing update time
2024-09-16 02:15:51 +03:00

2117 lines
47 KiB
C++

/*
SCHEDULER MODULE
Copyright (C) 2017 by faina09
Adapted by Xose Pérez <xose dot perez at gmail dot com>
Copyright (C) 2019-2024 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
*/
#include "espurna.h"
#if SCHEDULER_SUPPORT
#include "api.h"
#include "curtain_kingart.h"
#include "datetime.h"
#include "mqtt.h"
#include "ntp.h"
#include "ntp_timelib.h"
#include "scheduler.h"
#include "types.h"
#include "ws.h"
#if TERMINAL_SUPPORT == 0
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
#include "light.h"
#endif
#if RELAY_SUPPORT
#include "relay.h"
#endif
#endif
#include "libs/EphemeralPrint.h"
#include "libs/PrintString.h"
// -----------------------------------------------------------------------------
#include "scheduler_common.ipp"
#include "scheduler_time.re.ipp"
#if SCHEDULER_SUN_SUPPORT
#include "scheduler_sun.ipp"
#endif
namespace espurna {
namespace scheduler {
enum class Type : int {
Unknown = 0,
Disabled,
Calendar,
Relative,
};
namespace v1 {
enum class Type : int {
None = 0,
Relay,
Channel,
Curtain,
};
} // namespace v1
namespace {
bool initial { true };
constexpr auto EventTtl = datetime::Days{ 1 };
constexpr auto EventsMax = size_t{ 4 };
struct NamedEvent {
String name;
event::time_point time_point;
};
std::forward_list<NamedEvent> named_events;
NamedEvent* find_named(StringView name) {
const auto it = std::find_if(
named_events.begin(),
named_events.end(),
[&](const NamedEvent& entry) {
return entry.name == name;
});
if (it != named_events.end()) {
return &(*it);
}
return nullptr;
}
bool named_event(String name, datetime::Clock::time_point time_point) {
auto it = find_named(name);
if (it) {
it->time_point = time_point;
return true;
}
size_t size { 0 };
auto last = named_events.begin();
while (last != named_events.end()) {
++size;
++last;
}
if (size < EventsMax) {
named_events.push_front(
NamedEvent{
.name = std::move(name),
.time_point = time_point,
});
return true;
}
return false;
}
bool named_event(String name, StringView value) {
datetime::DateHhMmSs datetime;
bool utc { false };
const auto result = parse_simple_iso8601(datetime, utc, value);
if (!result) {
return false;
}
return named_event(
std::move(name),
datetime::make_time_point(datetime, utc));
}
String format_time_point(datetime::Clock::time_point time_point) {
return datetime::format_local_tz(time_point);
}
String format_named_event(const NamedEvent& event) {
return format_time_point(event.time_point);
}
template <typename Container>
void cleanup_entries_impl(const datetime::Context& ctx, Container& container, datetime::Minutes ttl) {
const auto minutes = to_minutes(ctx);
container.remove_if(
[&](const typename Container::value_type& entry) {
return !event::is_valid(entry.time_point)
|| event::difference(minutes, entry.time_point) > ttl;
});
}
void cleanup_named_events(const datetime::Context& ctx) {
cleanup_entries_impl(ctx, named_events, EventTtl);
}
constexpr auto LastTtl = datetime::Days{ 1 };
struct Last {
size_t index;
datetime::Clock::time_point time_point;
};
String format_last_action(const Last& last) {
return format_time_point(last.time_point);
}
std::forward_list<Last> last_actions;
Last* find_last(size_t index) {
auto it = std::find_if(
last_actions.begin(),
last_actions.end(),
[&](const Last& entry) {
return entry.index == index;
});
if (it != last_actions.end()) {
return &(*it);
}
return nullptr;
}
void last_action(size_t index, datetime::Clock::time_point time_point) {
auto* it = find_last(index);
if (it) {
it->time_point = time_point;
return;
}
last_actions.push_front(
Last{
.index = index,
.time_point = time_point,
});
}
void last_action(const datetime::Context& ctx, size_t index) {
last_action(
index, datetime::make_time_point(ctx));
}
datetime::Clock::time_point last_action(size_t index) {
auto it = find_last(index);
if (it) {
return it->time_point;
}
return datetime::Clock::time_point(datetime::Seconds{ -1 });
}
void cleanup_last_actions(const datetime::Context& ctx) {
cleanup_entries_impl(ctx, last_actions, LastTtl);
}
#if SCHEDULER_SUN_SUPPORT
namespace sun {
struct EventMatch : public Event {
datetime::Date date;
TimeMatch time;
};
struct Match {
EventMatch rising;
EventMatch setting;
};
Location location;
Match match;
auto next_update = event::DefaultTimePoint;
void setup();
void reset() {
match.rising = EventMatch{};
match.setting = EventMatch{};
}
} // namespace sun
#endif
namespace build {
constexpr size_t max() {
return SCHEDULER_MAX_SCHEDULES;
}
constexpr Type type() {
return Type::Unknown;
}
constexpr bool restore() {
return 1 == SCHEDULER_RESTORE;
}
constexpr int restoreDays() {
return SCHEDULER_RESTORE_DAYS;
}
#if SCHEDULER_SUN_SUPPORT
constexpr double latitude() {
return SCHEDULER_LATITUDE;
}
constexpr double longitude() {
return SCHEDULER_LONGITUDE;
}
constexpr double altitude() {
return SCHEDULER_ALTITUDE;
}
#endif
} // namespace build
namespace settings {
namespace internal {
using espurna::settings::options::Enumeration;
STRING_VIEW_INLINE(Unknown, "unknown");
STRING_VIEW_INLINE(Disabled, "disabled");
STRING_VIEW_INLINE(Calendar, "calendar");
STRING_VIEW_INLINE(Relative, "relative");
static constexpr std::array<Enumeration<Type>, 4> Types PROGMEM {
{{Type::Unknown, Unknown},
{Type::Disabled, Disabled},
{Type::Calendar, Calendar},
{Type::Relative, Relative}}
};
namespace v1 {
STRING_VIEW_INLINE(None, "none");
STRING_VIEW_INLINE(Relay, "relay");
STRING_VIEW_INLINE(Channel, "channel");
STRING_VIEW_INLINE(Curtain, "curtain");
static constexpr std::array<Enumeration<scheduler::v1::Type>, 4> Types PROGMEM {
{{scheduler::v1::Type::None, None},
{scheduler::v1::Type::Relay, Relay},
{scheduler::v1::Type::Channel, Channel},
{scheduler::v1::Type::Curtain, Curtain}}
};
} // namespace v1
} // namespace internal
} // namespace settings
} // namespace
} // namespace scheduler
namespace settings {
namespace internal {
template <>
scheduler::Type convert(const String& value) {
return convert(scheduler::settings::internal::Types, value, scheduler::Type::Unknown);
}
String serialize(scheduler::Type type) {
return serialize(scheduler::settings::internal::Types, type);
}
template <>
scheduler::v1::Type convert(const String& value) {
return convert(scheduler::settings::internal::v1::Types, value, scheduler::v1::Type::None);
}
String serialize(scheduler::v1::Type type) {
return serialize(scheduler::settings::internal::v1::Types, type);
}
} // namespace internal
} // namespace settings
} // namespace espurna
namespace espurna {
namespace scheduler {
namespace {
bool tryParseId(StringView value, size_t& out) {
return ::tryParseId(value, build::max(), out);
}
namespace settings {
STRING_VIEW_INLINE(Prefix, "sch");
namespace keys {
#if SCHEDULER_SUN_SUPPORT
STRING_VIEW_INLINE(Latitude, "schLat");
STRING_VIEW_INLINE(Longitude, "schLong");
STRING_VIEW_INLINE(Altitude, "schAlt");
#endif
STRING_VIEW_INLINE(Days, "schRstrDays");
STRING_VIEW_INLINE(Type, "schType");
STRING_VIEW_INLINE(Restore, "schRestore");
STRING_VIEW_INLINE(Time, "schTime");
STRING_VIEW_INLINE(Action, "schAction");
} // namespace keys
#if SCHEDULER_SUN_SUPPORT
double latitude() {
return getSetting(keys::Latitude, build::latitude());
}
double longitude() {
return getSetting(keys::Longitude, build::longitude());
}
double altitude() {
return getSetting(keys::Altitude, build::altitude());
}
#endif
int restoreDays() {
return getSetting(keys::Days, build::restoreDays());
}
Type type(size_t index) {
return getSetting({keys::Type, index}, build::type());
}
bool restore(size_t index) {
return getSetting({keys::Restore, index}, build::restore());
}
String time(size_t index) {
return getSetting({keys::Time, index});
}
String action(size_t index) {
return getSetting({keys::Action, index});
}
namespace internal {
#define ID_VALUE(NAME, FUNC)\
String NAME (size_t id) {\
return espurna::settings::internal::serialize(FUNC(id));\
}
ID_VALUE(type, settings::type)
ID_VALUE(restore, settings::restore)
#undef ID_VALUE
#define EXACT_VALUE(NAME, FUNC)\
String NAME () {\
return espurna::settings::internal::serialize(FUNC());\
}
EXACT_VALUE(restoreDays, settings::restoreDays);
#if SCHEDULER_SUN_SUPPORT
EXACT_VALUE(latitude, settings::latitude);
EXACT_VALUE(longitude, settings::longitude);
EXACT_VALUE(altitude, settings::altitude);
#endif
#undef EXACT_VALUE
} // namespace internal
static constexpr espurna::settings::query::Setting Settings[] PROGMEM {
{keys::Days, internal::restoreDays},
#if SCHEDULER_SUN_SUPPORT
{keys::Latitude, internal::latitude},
{keys::Longitude, internal::longitude},
{keys::Altitude, internal::altitude},
#endif
};
static constexpr espurna::settings::query::IndexedSetting IndexedSettings[] PROGMEM {
{keys::Type, internal::type},
{keys::Restore, internal::restore},
{keys::Action, settings::action},
{keys::Time, settings::time},
};
struct Parsed {
bool date { false };
bool weekdays { false };
bool time { false };
};
Schedule schedule(size_t index) {
return parse_schedule(settings::time(index));
}
Relative relative(size_t index) {
return parse_relative(settings::time(index));
}
template <typename T>
void foreach_type(T&& callback) {
for (size_t index = 0; index < build::max(); ++index) {
const auto type = settings::type(index);
if (type == Type::Unknown) {
break;
}
callback(type);
}
}
using Types = std::vector<Type>;
Types types() {
Types out;
foreach_type([&](Type type) {
out.push_back(type);
});
return out;
}
size_t count() {
size_t out { 0 };
foreach_type([&](Type type) {
++out;
});
return out;
}
void gc(size_t total) {
DEBUG_MSG_P(PSTR("[SCH] Registered %zu schedule(s)\n"), total);
for (size_t index = total; index < build::max(); ++index) {
for (auto setting : IndexedSettings) {
delSetting({setting.prefix(), index});
}
}
}
bool checkSamePrefix(StringView key) {
return key.startsWith(settings::Prefix);
}
espurna::settings::query::Result findFrom(StringView key) {
return espurna::settings::query::findFrom(Settings, key);
}
void setup() {
::settingsRegisterQueryHandler({
.check = checkSamePrefix,
.get = findFrom,
});
}
} // namespace settings
namespace v1 {
using scheduler::v1::Type;
namespace settings {
namespace keys {
STRING_VIEW_INLINE(Enabled, "schEnabled");
STRING_VIEW_INLINE(Switch, "schSwitch");
STRING_VIEW_INLINE(Target, "schTarget");
STRING_VIEW_INLINE(Hour, "schHour");
STRING_VIEW_INLINE(Minute, "schMinute");
STRING_VIEW_INLINE(Weekdays, "schWDs");
STRING_VIEW_INLINE(UTC, "schUTC");
static constexpr std::array<StringView, 5> List {
Enabled,
Target,
Hour,
Minute,
Weekdays,
};
} // namespace keys
STRING_VIEW_INLINE(DefaultWeekdays, "1,2,3,4,5,6,7");
bool enabled(size_t index) {
return getSetting({keys::Enabled, index}, false);
}
Type type(size_t index) {
return getSetting({espurna::scheduler::settings::keys::Type, index}, Type::None);
}
int target(size_t index) {
return getSetting({keys::Target, index}, 0);
}
int action(size_t index) {
using namespace espurna::scheduler::settings::keys;
return getSetting({Action, index}, 0);
}
int hour(size_t index) {
return getSetting({keys::Hour, index}, 0);
}
int minute(size_t index) {
return getSetting({keys::Minute, index}, 0);
}
String weekdays(size_t index) {
return getSetting({keys::Weekdays, index}, DefaultWeekdays);
}
bool utc(size_t index) {
return getSetting({keys::UTC, index}, false);
}
} // namespace settings
String convert_time(const String& weekdays, int hour, int minute, bool utc) {
String out;
// implicit mon..sun already by default
if (weekdays != settings::DefaultWeekdays) {
out += weekdays;
out += ' ';
}
if (hour < 10) {
out += '0';
}
out += String(hour, 10);
out += ':';
if (minute < 10) {
out += '0';
}
out += String(minute, 10);
if (utc) {
out += STRING_VIEW(" UTC");
}
return out;
}
String convert_action(Type type, int target, int action) {
String out;
StringView prefix;
switch (type) {
case Type::None:
break;
case Type::Relay:
{
STRING_VIEW_INLINE(Relay, "relay");
prefix = Relay;
break;
}
case Type::Channel:
{
STRING_VIEW_INLINE(Channel, "channel");
prefix = Channel;
break;
}
case Type::Curtain:
{
STRING_VIEW_INLINE(Curtain, "curtain");
prefix = Curtain;
break;
}
}
if (prefix.length()) {
out += prefix.toString()
+ ' ';
out += String(target, 10)
+ ' '
+ String(action, 10);
}
return out;
}
String convert_type(bool enabled, Type type) {
auto out = scheduler::Type::Unknown;
switch (type) {
case Type::None:
break;
case Type::Relay:
case Type::Channel:
case Type::Curtain:
out = scheduler::Type::Calendar;
break;
}
if (!enabled && (out != scheduler::Type::Unknown)) {
out = scheduler::Type::Disabled;
}
return ::espurna::settings::internal::serialize(out);
}
void migrate() {
for (size_t index = 0; index < build::max(); ++index) {
const auto type = settings::type(index);
setSetting({scheduler::settings::keys::Type, index},
convert_type(settings::enabled(index), type));
setSetting({scheduler::settings::keys::Time, index},
convert_time(settings::weekdays(index),
settings::hour(index),
settings::minute(index),
settings::utc(index)));
setSetting({scheduler::settings::keys::Action, index},
convert_action(type,
settings::target(index),
settings::action(index)));
for (auto& key : settings::keys::List) {
delSetting({key, index});
}
}
}
} // namespace v1
namespace settings {
void migrate(int version) {
if (version < 6) {
moveSettings(
v1::settings::keys::Switch.toString(),
v1::settings::keys::Target.toString());
}
if (version < 15) {
v1::migrate();
}
}
} // namespace settings
#if SCHEDULER_SUN_SUPPORT
namespace sun {
STRING_VIEW_INLINE(Module, "sun");
void setup() {
location.latitude = settings::latitude();
location.longitude = settings::longitude();
location.altitude = settings::altitude();
}
EventMatch* find_event_match(const TimeMatch& m) {
if (want_sunrise(m)) {
return &match.rising;
} else if (want_sunset(m)) {
return &match.setting;
}
return nullptr;
}
EventMatch* find_event_match(const Schedule& schedule) {
return find_event_match(schedule.time);
}
tm make_utc_date_time(datetime::Seconds seconds) {
tm out{};
time_t timestamp{ seconds.count() };
gmtime_r(&timestamp, &out);
return out;
}
datetime::Date make_date(const tm& date_time) {
datetime::Date out;
out.year = date_time.tm_year + 1900;
out.month = date_time.tm_mon + 1;
out.day = date_time.tm_mday;
return out;
}
TimeMatch make_time_match(const tm& date_time) {
TimeMatch out;
out.hour[date_time.tm_hour] = true;
out.minute[date_time.tm_min] = true;
out.flags = FlagUtc;
return out;
}
void update_event_match(EventMatch& match, datetime::Clock::time_point time_point) {
if (!event::is_valid(time_point)) {
if (event::is_valid(match.next)) {
match.last = match.next;
}
match.next = event::DefaultTimePoint;
return;
}
const auto duration = time_point.time_since_epoch();
const auto date_time = make_utc_date_time(duration);
match.date = make_date(date_time);
match.time = make_time_match(date_time);
match.last = match.next;
match.next = time_point;
}
void update_schedule_from(Schedule& schedule, const EventMatch& match) {
schedule.date.day[match.date.day] = true;
schedule.date.month[match.date.month] = true;
schedule.date.year = match.date.year;
schedule.time = match.time;
}
bool update_schedule(Schedule& schedule) {
// if not sun{rise,set} schedule, keep it as-is
const auto* selected = sun::find_event_match(schedule);
if (nullptr == selected) {
return true;
}
// in case calculation failed, no use here
if (!event::is_valid((*selected).next)) {
return false;
}
// make sure event can actually trigger with this date spec
if (::espurna::scheduler::match(schedule.date, (*selected).date)) {
update_schedule_from(schedule, *selected);
return true;
}
return false;
}
bool needs_update(datetime::Clock::time_point time_point) {
const auto next = event::is_valid(next_update);
if (next) {
return event::less(next_update, time_point);
}
return event::less(match.rising.next, time_point)
|| event::less(match.setting.next, time_point);
}
template <typename T>
datetime::Clock::time_point delta_compare(tm& out, datetime::Clock::time_point, T);
void update(datetime::Clock::time_point, const tm& today) {
const auto result = sun::sunrise_sunset(location, today);
update_event_match(match.rising, result.sunrise);
update_event_match(match.setting, result.sunset);
}
template <typename T>
void update(datetime::Clock::time_point time_point, const tm& today, T compare) {
auto result = sun::sunrise_sunset(location, today);
const auto reset_sunrise =
!event::is_valid(result.sunrise) || compare(time_point, result.sunrise);
const auto reset_sunset =
!event::is_valid(result.sunset) || compare(time_point, result.sunset);
next_update = event::DefaultTimePoint;
tm tmp;
if (reset_sunrise || reset_sunset) {
std::memcpy(&tmp, &today, sizeof(tmp));
next_update = delta_compare(tmp, time_point, compare);
const auto other = sun::sunrise_sunset(location, tmp);
if (reset_sunrise && event::is_valid(other.sunrise)) {
result.sunrise = other.sunrise;
}
if (reset_sunset && event::is_valid(other.sunset)) {
result.sunset = other.sunset;
}
}
update_event_match(match.rising, result.sunrise);
update_event_match(match.setting, result.sunset);
}
template <typename T>
void update(time_t timestamp, const tm& today, T&& compare) {
update(datetime::make_time_point(timestamp), today, std::forward<T>(compare));
}
String format_match(const EventMatch& match) {
return datetime::format_local_tz(match.next);
}
// check() needs current or future events, discard timestamps in the past
// round to minutes when doing so as well, since std::greater<> would compare seconds
struct CheckCompare {
bool operator()(const event::time_point& lhs, const event::time_point& rhs) {
return event::greater(lhs, rhs);
}
};
template <>
datetime::Clock::time_point delta_compare(tm& out, datetime::Clock::time_point time_point, CheckCompare) {
return datetime::make_time_point(
datetime::delta_utc(
out, time_point.time_since_epoch(),
datetime::Days{ 1 }));
}
void update_after(const datetime::Context& ctx) {
const auto time_point = event::make_time_point(ctx);
if (!needs_update(time_point)) {
return;
}
update(time_point, ctx.utc, CheckCompare{});
if (event::is_valid(match.rising.next)) {
DEBUG_MSG_P(PSTR("[SCH] Sunrise at %s\n"),
format_match(match.rising).c_str());
}
if (event::is_valid(match.setting.next)) {
DEBUG_MSG_P(PSTR("[SCH] Sunset at %s\n"),
format_match(match.setting).c_str());
}
}
} // namespace sun
#endif
// -----------------------------------------------------------------------------
#if TERMINAL_SUPPORT
namespace terminal {
#if SCHEDULER_SUN_SUPPORT
namespace internal {
String sunrise_sunset(const sun::EventMatch& match) {
if (match.next.time_since_epoch() > datetime::Clock::duration::zero()) {
return sun::format_match(match);
}
return STRING_VIEW("value not set").toString();
}
void format_output(::terminal::CommandContext& ctx, const String& prefix, const String& value) {
ctx.output.printf_P(PSTR("- %s%s%s\n"),
prefix.c_str(),
value.length()
? PSTR(" at ")
: " ",
value.c_str());
}
void dump_sunrise_sunset(::terminal::CommandContext& ctx) {
format_output(ctx,
STRING_VIEW("Sunrise").toString(),
sunrise_sunset(sun::match.rising));
format_output(ctx,
STRING_VIEW("Sunset").toString(),
sunrise_sunset(sun::match.setting));
}
} // namespace internal
#endif
// SCHEDULE [<ID>]
PROGMEM_STRING(Dump, "SCHEDULE");
void dump(::terminal::CommandContext&& ctx) {
if (ctx.argv.size() != 2) {
settingsDump(ctx, settings::Settings);
return;
}
size_t id;
if (!tryParseId(ctx.argv[1], id)) {
terminalError(ctx, F("Invalid ID"));
return;
}
const auto last = find_last(id);
if (last) {
ctx.output.printf_P(PSTR("last action: %s\n"),
format_time_point((*last).time_point).c_str());
}
settingsDump(ctx, settings::IndexedSettings, id);
terminalOK(ctx);
}
PROGMEM_STRING(Event, "EVENT");
// EVENT [<NAME>] [<DATETIME>]
void event(::terminal::CommandContext&& ctx) {
String name;
if (ctx.argv.size() == 2) {
name = std::move(ctx.argv[1]);
}
if (ctx.argv.size() != 3) {
bool once { true };
for (auto& entry : named_events) {
if (name.length() && entry.name != name) {
continue;
}
if (once) {
ctx.output.print(PSTR("Named events:\n"));
once = false;
}
ctx.output.printf_P(PSTR("- \"%s\" at %s\n"),
entry.name.c_str(),
format_named_event(entry).c_str());
if (name.length()) {
terminalOK(ctx);
return;
}
}
if (name.length()) {
terminalError(ctx, STRING_VIEW("Invalid name"));
return;
}
if (!last_actions.empty()) {
ctx.output.print(PSTR("Calendar events:\n"));
for (auto& entry : last_actions) {
ctx.output.printf_P(PSTR("- cal#%zu at %s\n"),
entry.index,
format_last_action(entry).c_str());
}
}
#if SCHEDULER_SUN_SUPPORT
ctx.output.print(PSTR("Sun events:\n"));
internal::dump_sunrise_sunset(ctx);
#endif
terminalOK(ctx);
return;
}
if (named_event(std::move(ctx.argv[1]), ctx.argv[2])) {
terminalOK(ctx);
return;
}
terminalError(ctx, STRING_VIEW("Cannot set event"));
}
static constexpr ::terminal::Command Commands[] PROGMEM {
{Dump, dump},
{Event, event},
};
void setup() {
espurna::terminal::add(Commands);
}
} // namespace terminal
#endif
// -----------------------------------------------------------------------------
#if API_SUPPORT
namespace api {
namespace keys {
STRING_VIEW_INLINE(Type, "type");
STRING_VIEW_INLINE(Restore, "restore");
STRING_VIEW_INLINE(Time, "time");
STRING_VIEW_INLINE(Action, "action");
} // namespace keys
using espurna::settings::internal::serialize;
using espurna::settings::internal::convert;
struct Schedule {
size_t id;
Type type;
int restore;
String time;
String action;
};
void print(JsonObject& root, const Schedule& schedule) {
root[keys::Type] = serialize(schedule.type);
root[keys::Restore] = (1 == schedule.restore);
root[keys::Action] = schedule.action;
root[keys::Time] = schedule.time;
}
template <typename T>
bool set_typed(T& out, JsonObject& root, StringView key) {
auto value = root[key];
if (value.success()) {
out = value.as<T>();
return true;
}
return false;
}
template <>
bool set_typed<Type>(Type& out, JsonObject& root, StringView key) {
auto value = root[key];
if (!value.success()) {
return false;
}
auto type = convert<Type>(value.as<String>());
if (type != Type::Unknown) {
out = type;
return true;
}
return false;
}
template
bool set_typed<String>(String&, JsonObject&, StringView);
template
bool set_typed<bool>(bool&, JsonObject&, StringView);
void update_from(const Schedule& schedule) {
setSetting({keys::Type, schedule.id}, serialize(schedule.type));
setSetting({keys::Time, schedule.id}, schedule.time);
setSetting({keys::Action, schedule.id}, schedule.action);
if (schedule.restore != -1) {
setSetting({keys::Restore, schedule.id}, serialize(1 == schedule.restore));
}
}
bool set(JsonObject& root, const size_t id) {
Schedule out;
out.restore = -1;
// always need type, time and action
if (!set_typed(out.type, root, keys::Type)) {
return false;
}
if (!set_typed(out.time, root, keys::Time)) {
return false;
}
if (!set_typed(out.action, root, keys::Action)) {
return false;
}
// optional restore flag
bool restore;
if (set_typed(restore, root, keys::Restore)) {
out.restore = restore ? 1 : 0;
}
update_from(out);
return true;
}
Schedule make_schedule(size_t id) {
Schedule out;
out.type = settings::type(id);
if (out.type != Type::Unknown) {
out.id = id;
out.restore = settings::restore(id) ? 1 : 0;
out.time = settings::time(id);
out.action = settings::action(id);
}
return out;
}
namespace schedules {
bool get(ApiRequest&, JsonObject& root) {
JsonArray& out = root.createNestedArray("schedules");
for (size_t id = 0; id < build::max(); ++id) {
const auto sch = make_schedule(id);
if (sch.type == Type::Unknown) {
break;
}
auto& root = out.createNestedObject();
print(root, sch);
}
return true;
}
bool set(ApiRequest&, JsonObject& root) {
size_t id = 0;
while (hasSetting({settings::keys::Type, id})) {
++id;
}
if (id < build::max()) {
return api::set(root, id);
}
return false;
}
} // namespace schedules
namespace schedule {
bool get(ApiRequest& req, JsonObject& root) {
const auto param = req.wildcard(0);
size_t id;
if (tryParseId(param, id)) {
const auto sch = make_schedule(id);
if (sch.type == Type::Unknown) {
return false;
}
print(root, sch);
return true;
}
return false;
}
bool set(ApiRequest& req, JsonObject& root) {
const auto param = req.wildcard(0);
size_t id;
if (tryParseId(param, id)) {
return api::set(root, id);
}
return false;
}
} // namespace schedules
constexpr char Events[] = "events";
constexpr char Name[] = "name";
constexpr char Datetime[] = "datetime";
namespace events {
bool get(ApiRequest& req, JsonObject& root) {
const auto param = req.wildcard(0);
auto& events = root.createNestedArray(Events);
for (auto& event : named_events) {
auto& entry = events.createNestedObject();
entry[Name] = event.name.c_str();
entry[Datetime] = format_named_event(event);
}
return true;
}
} // namespace events
namespace event {
bool get(ApiRequest& req, JsonObject& root) {
const auto param = req.wildcard(0);
const auto it = find_named(param);
if (it) {
root[Datetime] = format_named_event(*it);
return true;
}
return false;
}
bool set(ApiRequest& req, JsonObject& root) {
const auto datetime = root[Datetime];
if (!datetime.success() || !datetime.is<String>()) {
return false;
}
return named_event(
req.wildcard(0), datetime.as<String>());
}
} // namespace event
void setup() {
apiRegister(F(MQTT_TOPIC_SCHEDULE), schedules::get, schedules::set);
apiRegister(F(MQTT_TOPIC_SCHEDULE "/+"), schedule::get, schedule::set);
apiRegister(F(MQTT_TOPIC_NAMED_EVENT), events::get, nullptr);
apiRegister(F(MQTT_TOPIC_NAMED_EVENT "/+"), event::get, event::set);
}
} // namespace api
#endif // API_SUPPORT
// -----------------------------------------------------------------------------
#if MQTT_SUPPORT
namespace mqtt {
void callback(unsigned int type, StringView topic, StringView payload) {
if (type == MQTT_CONNECT_EVENT) {
mqttSubscribe(MQTT_TOPIC_NAMED_EVENT "/+");
return;
}
STRING_VIEW_INLINE(Topic, MQTT_TOPIC_NAMED_EVENT);
if (type == MQTT_MESSAGE_EVENT) {
const auto t = mqttMagnitude(topic);
if (!t.startsWith(Topic)) {
return;
}
const auto name = t.slice(Topic.length() + 1);
if (!name.length()) {
return;
}
named_event(name.toString(), payload);
return;
}
}
void setup() {
::mqttRegister(callback);
}
} // namespace mqtt
#endif
// -----------------------------------------------------------------------------
#if WEB_SUPPORT
namespace web {
bool onKey(StringView key, const JsonVariant&) {
return key.startsWith(settings::Prefix);
}
void onVisible(JsonObject& root) {
wsPayloadModule(root, settings::Prefix);
#if SCHEDULER_SUN_SUPPORT
wsPayloadModule(root, sun::Module);
#endif
for (const auto& pair : settings::Settings) {
root[pair.key()] = pair.value();
}
}
void onConnected(JsonObject& root){
espurna::web::ws::EnumerableConfig config{ root, STRING_VIEW("schConfig") };
config(STRING_VIEW("schedules"), settings::count(), settings::IndexedSettings);
auto& schedules = config.root();
schedules["max"] = build::max();
}
void setup() {
wsRegister()
.onVisible(onVisible)
.onConnected(onConnected)
.onKeyCheck(onKey);
}
} // namespace web
#endif
// When terminal is disabled, still allow minimum set of actions that we available in v1
#if TERMINAL_SUPPORT == 0
namespace terminal_stub {
#if RELAY_SUPPORT
namespace relay {
void action(SplitStringView split) {
if (!split.next()) {
return;
}
size_t id;
if (!::tryParseId(split.current(), relayCount(), id)) {
return;
}
if (!split.next()) {
return;
}
const auto status = relayParsePayload(split.current());
switch (status) {
case PayloadStatus::Unknown:
break;
case PayloadStatus::Off:
case PayloadStatus::On:
relayStatus(id, (status == PayloadStatus::On));
break;
case PayloadStatus::Toggle:
relayToggle(id);
break;
}
}
} // namespace relay
#endif
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
namespace light {
void action(SplitStringView split) {
if (!split.next()) {
return;
}
size_t id;
if (!tryParseId(split.current(), lightChannels(), id)) {
return;
}
if (!split.next()) {
return;
}
const auto convert = ::espurna::settings::internal::convert<long>;
lightChannel(id, convert(split.current().toString()));
lightUpdate();
}
} // namespace light
#endif
#if CURTAIN_SUPPORT
namespace curtain {
void action(SplitStringView split) {
if (!split.next()) {
return;
}
size_t id;
if (!tryParseId(split.current(), curtainCount(), id)) {
return;
}
if (!split.next()) {
return;
}
const auto convert = ::espurna::settings::internal::convert<int>;
curtainUpdate(id, convert(split.current().toString()));
}
} // namespace curtain
#endif
void parse_action(String action) {
auto split = SplitStringView{ action };
if (!split.next()) {
return;
}
auto current = split.current();
#if RELAY_SUPPORT
if (current == STRING_VIEW("relay")) {
relay::action(split);
return;
}
#endif
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
if (current == STRING_VIEW("channel")) {
light::action(split);
return;
}
#endif
#if CURTAIN_SUPPORT
if (current == STRING_VIEW("curtain")) {
curtain::action(split);
return;
}
#endif
DEBUG_MSG_P(PSTR("[SCH] Unknown action: %s\n"), action.c_str());
}
} // namespace terminal_stub
using terminal_stub::parse_action;
#else
void parse_action(String action) {
if (!action.endsWith("\r\n") && !action.endsWith("\n")) {
action.concat('\n');
}
static EphemeralPrint output;
PrintString error(64);
if (!espurna::terminal::api_find_and_call(action, output, error)) {
DEBUG_MSG_P(PSTR("[SCH] %s\n"), error.c_str());
return;
}
}
#endif
Schedule load_schedule(size_t index) {
auto out = settings::schedule(index);
if (!out.ok) {
return out;
}
#if SCHEDULER_SUN_SUPPORT
if (want_sunrise_sunset(out.time) && !sun::update_schedule(out)) {
out.ok = false;
}
#else
if (want_sunrise_sunset(out.time)) {
out.ok = false;
}
#endif
return out;
}
bool match_schedule(const Schedule& schedule, const tm& time_point) {
if (!match(schedule.date, time_point)) {
return false;
}
if (!match(schedule.weekdays, time_point)) {
return false;
}
return match(schedule.time, time_point);
}
bool check_calendar(const datetime::Context& ctx, size_t index) {
const auto schedule = load_schedule(index);
return schedule.ok
&& match_schedule(schedule, select_time(ctx, schedule));
}
namespace restore {
[[gnu::used]]
void Context::init() {
#if SCHEDULER_SUN_SUPPORT
const auto seconds = datetime::Seconds{ this->current.timestamp };
const auto minutes =
std::chrono::duration_cast<datetime::Minutes>(seconds);
const auto time_point =
datetime::Clock::time_point(minutes);
sun::update(time_point, this->current.utc);
#endif
}
[[gnu::used]]
void Context::init_delta() {
#if SCHEDULER_SUN_SUPPORT
init();
for (auto& pending : this->pending) {
// additional logic in handle_delta. keeps as pending when current value does not pass date match()
pending.schedule.ok =
sun::update_schedule(pending.schedule);
}
#endif
}
[[gnu::used]]
void Context::destroy() {
#if SCHEDULER_SUN_SUPPORT
sun::reset();
#endif
}
// otherwise, there are pending results that need extra days to check
void run_delta(Context& ctx) {
if (!ctx.pending.size()) {
return;
}
const auto days = settings::restoreDays();
for (int day = 0; day < days; ++day) {
if (!ctx.next()) {
break;
}
for (auto it = ctx.pending.begin(); it != ctx.pending.end();) {
if (handle_pending(ctx, *it)) {
it = ctx.pending.erase(it);
} else {
it = std::next(it);
}
}
}
}
// if schedule was due earlier today, make sure this gets checked first
void run_today(Context& ctx) {
for (size_t index = 0; index < build::max(); ++index) {
switch (settings::type(index)) {
case Type::Unknown:
return;
case Type::Disabled:
case Type::Relative:
continue;
case Type::Calendar:
break;
}
if (!settings::restore(index)) {
continue;
}
auto schedule = settings::schedule(index);
if (!schedule.ok) {
continue;
}
#if SCHEDULER_SUN_SUPPORT
if (!sun::update_schedule(schedule)) {
ctx.push_pending(index, schedule);
continue;
}
#else
if (want_sunrise_sunset(schedule.time)) {
continue;
}
#endif
handle_today(ctx, index, schedule);
}
}
void run(const datetime::Context& base) {
Context ctx{ base };
run_today(ctx);
run_delta(ctx);
ctx.sort();
for (auto& result : ctx.results) {
const auto action = settings::action(result.index);
DEBUG_MSG_P(PSTR("[SCH] Restoring #%zu => %s (%sm)\n"),
result.index, action.c_str(),
String(result.offset.count(), 10).c_str());
parse_action(action);
}
}
} // namespace restore
namespace relative {
struct Source {
virtual ~Source();
virtual const event::time_point& time_point() const = 0;
virtual bool before(const datetime::Context&) = 0;
virtual bool after(const datetime::Context&) = 0;
};
Source::~Source() = default;
struct Calendar : public Source {
Calendar(size_t index, std::shared_ptr<expect::Context> expect) :
_expect(expect),
_index(index)
{}
const event::time_point& time_point() const override {
return _time_point;
}
bool before(const datetime::Context&) override;
bool after(const datetime::Context&) override;
private:
void _reset_expected(const datetime::Context& ctx, datetime::Minutes offset) {
_time_point = datetime::make_time_point(to_minutes(ctx) + offset);
}
void _reset_expected(const datetime::Context& ctx, const expect::Context& expect) {
_reset_expected(ctx, expect.results.back().offset);
}
std::shared_ptr<expect::Context> _expect;
size_t _index;
event::time_point _time_point{ event::DefaultTimePoint };
};
bool Calendar::before(const datetime::Context& ctx) {
if (event::is_valid(_time_point)) {
return true;
}
const auto it = std::find_if(
_expect->results.begin(),
_expect->results.end(),
[&](const Offset& offset) {
return offset.index == _index;
});
if (it != _expect->results.end()) {
_reset_expected(ctx, (*it).offset);
return true;
}
const auto schedule = load_schedule(_index);
if (!schedule.ok) {
return false;
}
if ((_expect->days == _expect->days.zero()) && handle_today(*_expect, _index, schedule)) {
_reset_expected(ctx, *_expect);
return true;
}
const auto pending = std::find_if(
_expect->pending.begin(),
_expect->pending.end(),
[&](const Pending& pending) {
return pending.index == _index;
});
if (pending == _expect->pending.end()) {
return false;
}
if (handle_pending(*_expect, *pending)) {
_reset_expected(ctx, *_expect);
return true;
}
// assuming this only happen once, after +1day shift
_expect->pending.erase(pending);
return false;
}
bool Calendar::after(const datetime::Context& ctx) {
_time_point = last_action(_index);
return event::is_valid(_time_point);
}
struct Named : public Source {
explicit Named(String&& name) :
_name(std::move(name))
{}
const datetime::Clock::time_point& time_point() const override {
return _time_point;
}
bool before(const datetime::Context&) override;
bool after(const datetime::Context&) override;
private:
bool _reset_named(StringView name) {
const auto it = find_named(name);
if (it) {
_time_point = it->time_point;
}
return event::is_valid(_time_point);
}
String _name;
datetime::Clock::time_point _time_point{ event::DefaultTimePoint };
};
bool Named::before(const datetime::Context&) {
return event::is_valid(_time_point) || _reset_named(_name);
}
bool Named::after(const datetime::Context&) {
return event::is_valid(_time_point) || _reset_named(_name);
}
#if SCHEDULER_SUN_SUPPORT
struct Sun : public Source {
explicit Sun(const Event* event) :
_event(event)
{}
bool before(const datetime::Context&) override {
return _reset_time_point(_event->next);
}
bool after(const datetime::Context&) override {
return _reset_time_point(_event->last);
}
const datetime::Clock::time_point& time_point() const override {
return *_time_point;
}
private:
bool _reset_time_point(const datetime::Clock::time_point& time_point) {
_time_point = event::is_valid(time_point)
? &time_point
: &event::DefaultTimePoint;
return event::is_valid(*_time_point);
}
const Event* _event;
const event::time_point* _time_point{ &event::DefaultTimePoint };
};
struct Sunset : public Sun {
Sunset() :
Sun(&sun::match.setting)
{}
};
struct Sunrise : public Sun {
Sunrise() :
Sun(&sun::match.rising)
{}
};
#endif
struct EventOffset : public Offset {
std::unique_ptr<Source> source;
relative::Order order;
};
using EventOffsets = std::vector<EventOffset>;
void process_valid_event_offsets(const datetime::Context& ctx, EventOffsets& pending, relative::Order order) {
std::vector<size_t> matched;
auto it = pending.begin();
while (it != pending.end()) {
if ((*it).order != order) {
goto next;
}
// expect required event time point ('next' or 'last') to exist for the requested 'order'
{
const auto& time_point = (*it).source->time_point();
if (!event::is_valid(time_point)) {
goto next;
}
const auto diff = event::difference(ctx, time_point);
if (diff == (*it).offset) {
matched.push_back((*it).index);
}
}
// always fall-through and erase
it = pending.erase(it);
continue;
// only move where when explicitly told to
next:
it = std::next(it);
continue;
}
for (const auto& match : matched) {
parse_action(settings::action(match));
}
}
struct Prepared {
Span<scheduler::Type> types;
EventOffsets event_offsets;
std::shared_ptr<expect::Context> expect;
explicit operator bool() const {
return event_offsets.size() > 0;
}
bool next() {
return expect.get() != nullptr
&& expect.use_count() > 1
&& expect->pending.size()
&& expect->next();
}
};
Prepared prepare_event_offsets(const datetime::Context& ctx, Span<scheduler::Type> types) {
Prepared out{
.types = types,
.event_offsets = {},
.expect = {},
};
for (size_t index = 0; index < types.size(); ++index) {
if (scheduler::Type::Relative != types[index]) {
continue;
}
auto relative = settings::relative(index);
if (Type::None == relative.type) {
continue;
}
if (Order::None == relative.order) {
continue;
}
EventOffset tmp;
tmp.index = index;
tmp.order = relative.order;
tmp.offset = relative.offset;
if (Order::Before == relative.order) {
tmp.offset = -tmp.offset;
}
switch (relative.type) {
case Type::None:
continue;
case Type::Calendar:
if (!out.expect) {
out.expect = std::make_unique<expect::Context>(ctx);
}
tmp.source =
std::make_unique<Calendar>(relative.data, out.expect);
break;
case Type::Named:
tmp.source =
std::make_unique<Named>(std::move(relative.name));
break;
case Type::Sunrise:
#if SCHEDULER_SUN_SUPPORT
tmp.source = std::make_unique<Sunrise>();
break;
#else
continue;
#endif
case Type::Sunset:
#if SCHEDULER_SUN_SUPPORT
tmp.source = std::make_unique<Sunrise>();
break;
#else
continue;
#endif
}
if (tmp.source) {
out.event_offsets.push_back(std::move(tmp));
}
}
return out;
}
void handle_ordered(const datetime::Context& ctx, Prepared& prepared, Order order) {
auto it = prepared.event_offsets.begin();
while (it != prepared.event_offsets.end()) {
if ((*it).order != order) {
goto next;
}
switch (order) {
case Order::None:
goto next;
case Order::Before:
if (!(*it).source->before(ctx)) {
goto erase;
}
goto next;
case Order::After:
if (!(*it).source->after(ctx)) {
goto erase;
}
goto next;
}
erase:
it = prepared.event_offsets.erase(it);
continue;
next:
it = std::next(it);
continue;
}
}
void handle_before(const datetime::Context& ctx, Prepared& prepared) {
handle_ordered(ctx, prepared, Order::Before);
if (prepared.next()) {
handle_ordered(ctx, prepared, Order::Before);
}
}
void handle_after(const datetime::Context& ctx, Prepared& prepared) {
handle_ordered(ctx, prepared, Order::After);
}
} // namespace relative
void handle_calendar(const datetime::Context& ctx, Span<Type> types) {
for (size_t index = 0; index < types.size(); ++index) {
bool ok = false;
switch (types[index]) {
case Type::Unknown:
return;
case Type::Disabled:
case Type::Relative:
continue;
case Type::Calendar:
ok = check_calendar(ctx, index);
break;
}
if (ok) {
last_action(ctx, index);
parse_action(settings::action(index));
}
}
}
void tick(NtpTick tick) {
auto ctx = datetime::make_context(now());
if (tick == NtpTick::EveryHour) {
cleanup_last_actions(ctx);
cleanup_named_events(ctx);
return;
}
if (initial) {
initial = false;
settings::gc(settings::count());
restore::run(ctx);
}
#if SCHEDULER_SUN_SUPPORT
sun::update_after(ctx);
#endif
const auto types = settings::types();
auto prepared =
relative::prepare_event_offsets(ctx, make_span(types));
if (prepared) {
relative::handle_before(ctx, prepared);
relative::process_valid_event_offsets(
ctx, prepared.event_offsets, relative::Order::Before);
}
handle_calendar(ctx, prepared.types);
if (prepared) {
relative::handle_after(ctx, prepared);
relative::process_valid_event_offsets(
ctx, prepared.event_offsets, relative::Order::After);
}
}
void setup() {
migrateVersion(scheduler::settings::migrate);
settings::setup();
#if SCHEDULER_SUN_SUPPORT
sun::setup();
#endif
#if TERMINAL_SUPPORT
terminal::setup();
#endif
#if WEB_SUPPORT
web::setup();
#endif
#if API_SUPPORT
api::setup();
#endif
#if MQTT_SUPPORT
mqtt::setup();
#endif
ntpOnTick(tick);
}
} // namespace
} // namespace scheduler
} // namespace espurna
// -----------------------------------------------------------------------------
void schSetup() {
espurna::scheduler::setup();
}
#endif // SCHEDULER_SUPPORT