Mirror of espurna firmware for wireless switches and more
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1428 lines
30 KiB

webui: remove jquery dependencies and clean-up websocket API Refactor WebUI: - remove jquery dependency from the base custom.js and use vanilla JS - remove jquery + jquery-datatables dependency from the RFM69 module - replace jquery-datatables handlers with pure-css table + some basic cell filtering (may be incomplete, but tbh it is not worth additional 50Kb to the .bin size) - introduce a common way to notify about the app errors, show small text notification at the top of the page instead of relying on user to find out about errors by using the Web Developer Tools - replace <span name=...> with <span data-settings-key=...> - replace <div> templates with <template>, disallowing modification without an explicit DOM clone - run `eslint` on html/custom.js and `html-validate` on html/index.html, and fix issues detected by both tools Streamline settings group handling in custom.js & index.html - drop module-specific button-add-... in favour of button-add-settings-group - only enforce data-settings-max requirement when the property actually exists - re-create label for=... and input id=... when settings group is modified, so checkboxes refer to the correct element - introduce additional data-... properties to generalize settings group additions - introduce Enumerable object to track some common list elements for <select>, allow to re-create <option> list when messages come in different order Minor fixes that also came with this: - fix relay code incorrectly parsing the payload, causing no relay names to be displayed in the SWITCHES panel - fix scheduler code accidentally combining keys b/c of the way C parses string literals on separate lines, without any commas in-between - thermostat should not reference tmpUnit directly in the webui, replace with module-specific thermostatUnit that is handled on the device itself - fix index.html initial setup invalid adminPass ids - fix index.html layout when removing specific schedules
2 years ago
webui: remove jquery dependencies and clean-up websocket API Refactor WebUI: - remove jquery dependency from the base custom.js and use vanilla JS - remove jquery + jquery-datatables dependency from the RFM69 module - replace jquery-datatables handlers with pure-css table + some basic cell filtering (may be incomplete, but tbh it is not worth additional 50Kb to the .bin size) - introduce a common way to notify about the app errors, show small text notification at the top of the page instead of relying on user to find out about errors by using the Web Developer Tools - replace <span name=...> with <span data-settings-key=...> - replace <div> templates with <template>, disallowing modification without an explicit DOM clone - run `eslint` on html/custom.js and `html-validate` on html/index.html, and fix issues detected by both tools Streamline settings group handling in custom.js & index.html - drop module-specific button-add-... in favour of button-add-settings-group - only enforce data-settings-max requirement when the property actually exists - re-create label for=... and input id=... when settings group is modified, so checkboxes refer to the correct element - introduce additional data-... properties to generalize settings group additions - introduce Enumerable object to track some common list elements for <select>, allow to re-create <option> list when messages come in different order Minor fixes that also came with this: - fix relay code incorrectly parsing the payload, causing no relay names to be displayed in the SWITCHES panel - fix scheduler code accidentally combining keys b/c of the way C parses string literals on separate lines, without any commas in-between - thermostat should not reference tmpUnit directly in the webui, replace with module-specific thermostatUnit that is handled on the device itself - fix index.html initial setup invalid adminPass ids - fix index.html layout when removing specific schedules
2 years ago
  1. /*
  2. SCHEDULER MODULE
  3. Copyright (C) 2017 by faina09
  4. Adapted by Xose Pérez <xose dot perez at gmail dot com>
  5. Copyright (C) 2019-2024 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
  6. */
  7. #include "espurna.h"
  8. #if SCHEDULER_SUPPORT
  9. #include "api.h"
  10. #include "curtain_kingart.h"
  11. #include "datetime.h"
  12. #include "mqtt.h"
  13. #include "ntp.h"
  14. #include "ntp_timelib.h"
  15. #include "scheduler.h"
  16. #include "types.h"
  17. #include "ws.h"
  18. #if TERMINAL_SUPPORT == 0
  19. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  20. #include "light.h"
  21. #endif
  22. #if RELAY_SUPPORT
  23. #include "relay.h"
  24. #endif
  25. #endif
  26. #include "libs/EphemeralPrint.h"
  27. #include "libs/PrintString.h"
  28. // -----------------------------------------------------------------------------
  29. #include "scheduler_common.ipp"
  30. #include "scheduler_time.re.ipp"
  31. #if SCHEDULER_SUN_SUPPORT
  32. #include "scheduler_sun.ipp"
  33. #endif
  34. namespace espurna {
  35. namespace scheduler {
  36. // TODO recurrent in addition to calendar?
  37. enum class Type : int {
  38. Unknown = 0,
  39. Disabled,
  40. Calendar,
  41. };
  42. namespace v1 {
  43. enum class Type : int {
  44. None = 0,
  45. Relay,
  46. Channel,
  47. Curtain,
  48. };
  49. } // namespace v1
  50. namespace {
  51. bool initial { true };
  52. #if SCHEDULER_SUN_SUPPORT
  53. namespace sun {
  54. // scheduler itself has minutes precision, while seconds are used in debug and calculations
  55. struct Event {
  56. datetime::Minutes minutes{ -1 };
  57. datetime::Seconds seconds{ -1 };
  58. };
  59. datetime::Seconds event_seconds(const Event& event) {
  60. return std::chrono::duration_cast<datetime::Seconds>(event.minutes) + event.seconds;
  61. }
  62. bool event_valid(const Event& event) {
  63. return (event.minutes > datetime::Minutes::zero())
  64. && (event.seconds > datetime::Seconds::zero());
  65. }
  66. struct EventMatch {
  67. datetime::Date date;
  68. TimeMatch time;
  69. Event last;
  70. };
  71. struct Match {
  72. EventMatch rising;
  73. EventMatch setting;
  74. };
  75. Location location;
  76. Match match;
  77. void setup();
  78. void reset() {
  79. match.rising = EventMatch{};
  80. match.setting = EventMatch{};
  81. }
  82. } // namespace sun
  83. #endif
  84. namespace build {
  85. constexpr size_t max() {
  86. return SCHEDULER_MAX_SCHEDULES;
  87. }
  88. constexpr Type type() {
  89. return Type::Unknown;
  90. }
  91. constexpr bool restore() {
  92. return 1 == SCHEDULER_RESTORE;
  93. }
  94. constexpr int restoreDays() {
  95. return SCHEDULER_RESTORE_DAYS;
  96. }
  97. #if SCHEDULER_SUN_SUPPORT
  98. constexpr double latitude() {
  99. return SCHEDULER_LATITUDE;
  100. }
  101. constexpr double longitude() {
  102. return SCHEDULER_LONGITUDE;
  103. }
  104. constexpr double altitude() {
  105. return SCHEDULER_ALTITUDE;
  106. }
  107. #endif
  108. } // namespace build
  109. namespace settings {
  110. namespace internal {
  111. using espurna::settings::options::Enumeration;
  112. STRING_VIEW_INLINE(Unknown, "unknown");
  113. STRING_VIEW_INLINE(Disabled, "disabled");
  114. STRING_VIEW_INLINE(Calendar, "calendar");
  115. static constexpr std::array<Enumeration<Type>, 3> Types PROGMEM {
  116. {{Type::Unknown, Unknown},
  117. {Type::Disabled, Disabled},
  118. {Type::Calendar, Calendar}}
  119. };
  120. namespace v1 {
  121. STRING_VIEW_INLINE(None, "none");
  122. STRING_VIEW_INLINE(Relay, "relay");
  123. STRING_VIEW_INLINE(Channel, "channel");
  124. STRING_VIEW_INLINE(Curtain, "curtain");
  125. static constexpr std::array<Enumeration<scheduler::v1::Type>, 4> Types PROGMEM {
  126. {{scheduler::v1::Type::None, None},
  127. {scheduler::v1::Type::Relay, Relay},
  128. {scheduler::v1::Type::Channel, Channel},
  129. {scheduler::v1::Type::Curtain, Curtain}}
  130. };
  131. } // namespace v1
  132. } // namespace internal
  133. } // namespace settings
  134. } // namespace
  135. } // namespace scheduler
  136. namespace settings {
  137. namespace internal {
  138. template <>
  139. scheduler::Type convert(const String& value) {
  140. return convert(scheduler::settings::internal::Types, value, scheduler::Type::Unknown);
  141. }
  142. String serialize(scheduler::Type type) {
  143. return serialize(scheduler::settings::internal::Types, type);
  144. }
  145. template <>
  146. scheduler::v1::Type convert(const String& value) {
  147. return convert(scheduler::settings::internal::v1::Types, value, scheduler::v1::Type::None);
  148. }
  149. String serialize(scheduler::v1::Type type) {
  150. return serialize(scheduler::settings::internal::v1::Types, type);
  151. }
  152. } // namespace internal
  153. } // namespace settings
  154. } // namespace espurna
  155. namespace espurna {
  156. namespace scheduler {
  157. namespace {
  158. bool tryParseId(StringView value, size_t& out) {
  159. return ::tryParseId(value, build::max(), out);
  160. }
  161. namespace settings {
  162. STRING_VIEW_INLINE(Prefix, "sch");
  163. namespace keys {
  164. #if SCHEDULER_SUN_SUPPORT
  165. STRING_VIEW_INLINE(Latitude, "schLat");
  166. STRING_VIEW_INLINE(Longitude, "schLong");
  167. STRING_VIEW_INLINE(Altitude, "schAlt");
  168. #endif
  169. STRING_VIEW_INLINE(Days, "schRstrDays");
  170. STRING_VIEW_INLINE(Type, "schType");
  171. STRING_VIEW_INLINE(Restore, "schRestore");
  172. STRING_VIEW_INLINE(Time, "schTime");
  173. STRING_VIEW_INLINE(Action, "schAction");
  174. } // namespace keys
  175. #if SCHEDULER_SUN_SUPPORT
  176. double latitude() {
  177. return getSetting(keys::Latitude, build::latitude());
  178. }
  179. double longitude() {
  180. return getSetting(keys::Longitude, build::longitude());
  181. }
  182. double altitude() {
  183. return getSetting(keys::Altitude, build::altitude());
  184. }
  185. #endif
  186. int restoreDays() {
  187. return getSetting(keys::Days, build::restoreDays());
  188. }
  189. Type type(size_t index) {
  190. return getSetting({keys::Type, index}, build::type());
  191. }
  192. bool restore(size_t index) {
  193. return getSetting({keys::Restore, index}, build::restore());
  194. }
  195. String time(size_t index) {
  196. return getSetting({keys::Time, index});
  197. }
  198. String action(size_t index) {
  199. return getSetting({keys::Action, index});
  200. }
  201. namespace internal {
  202. #define ID_VALUE(NAME, FUNC)\
  203. String NAME (size_t id) {\
  204. return espurna::settings::internal::serialize(FUNC(id));\
  205. }
  206. ID_VALUE(type, settings::type)
  207. ID_VALUE(restore, settings::restore)
  208. #undef ID_VALUE
  209. #define EXACT_VALUE(NAME, FUNC)\
  210. String NAME () {\
  211. return espurna::settings::internal::serialize(FUNC());\
  212. }
  213. EXACT_VALUE(restoreDays, settings::restoreDays);
  214. #if SCHEDULER_SUN_SUPPORT
  215. EXACT_VALUE(latitude, settings::latitude);
  216. EXACT_VALUE(longitude, settings::longitude);
  217. EXACT_VALUE(altitude, settings::altitude);
  218. #endif
  219. #undef EXACT_VALUE
  220. } // namespace internal
  221. static constexpr espurna::settings::query::Setting Settings[] PROGMEM {
  222. {keys::Days, internal::restoreDays},
  223. #if SCHEDULER_SUN_SUPPORT
  224. {keys::Latitude, internal::latitude},
  225. {keys::Longitude, internal::longitude},
  226. {keys::Altitude, internal::altitude},
  227. #endif
  228. };
  229. static constexpr espurna::settings::query::IndexedSetting IndexedSettings[] PROGMEM {
  230. {keys::Type, internal::type},
  231. {keys::Restore, internal::restore},
  232. {keys::Action, settings::action},
  233. {keys::Time, settings::time},
  234. };
  235. struct Parsed {
  236. bool date { false };
  237. bool weekdays { false };
  238. bool time { false };
  239. };
  240. Schedule schedule(size_t index) {
  241. return parse_schedule(settings::time(index));
  242. }
  243. size_t count() {
  244. size_t out { 0 };
  245. for (size_t index = 0; index < build::max(); ++index) {
  246. const auto type = settings::type(index);
  247. if (type == Type::Unknown) {
  248. break;
  249. }
  250. ++out;
  251. }
  252. return out;
  253. }
  254. void gc(size_t total) {
  255. DEBUG_MSG_P(PSTR("[SCH] Registered %zu schedule(s)\n"), total);
  256. for (size_t index = total; index < build::max(); ++index) {
  257. for (auto setting : IndexedSettings) {
  258. delSetting({setting.prefix(), index});
  259. }
  260. }
  261. }
  262. bool checkSamePrefix(StringView key) {
  263. return key.startsWith(settings::Prefix);
  264. }
  265. espurna::settings::query::Result findFrom(StringView key) {
  266. return espurna::settings::query::findFrom(Settings, key);
  267. }
  268. void setup() {
  269. ::settingsRegisterQueryHandler({
  270. .check = checkSamePrefix,
  271. .get = findFrom,
  272. });
  273. }
  274. } // namespace settings
  275. namespace v1 {
  276. using scheduler::v1::Type;
  277. namespace settings {
  278. namespace keys {
  279. STRING_VIEW_INLINE(Enabled, "schEnabled");
  280. STRING_VIEW_INLINE(Switch, "schSwitch");
  281. STRING_VIEW_INLINE(Target, "schTarget");
  282. STRING_VIEW_INLINE(Hour, "schHour");
  283. STRING_VIEW_INLINE(Minute, "schMinute");
  284. STRING_VIEW_INLINE(Weekdays, "schWDs");
  285. STRING_VIEW_INLINE(UTC, "schUTC");
  286. static constexpr std::array<StringView, 5> List {
  287. Enabled,
  288. Target,
  289. Hour,
  290. Minute,
  291. Weekdays,
  292. };
  293. } // namespace keys
  294. STRING_VIEW_INLINE(DefaultWeekdays, "1,2,3,4,5,6,7");
  295. bool enabled(size_t index) {
  296. return getSetting({keys::Enabled, index}, false);
  297. }
  298. Type type(size_t index) {
  299. return getSetting({espurna::scheduler::settings::keys::Type, index}, Type::None);
  300. }
  301. int target(size_t index) {
  302. return getSetting({keys::Target, index}, 0);
  303. }
  304. int action(size_t index) {
  305. using namespace espurna::scheduler::settings::keys;
  306. return getSetting({Action, index}, 0);
  307. }
  308. int hour(size_t index) {
  309. return getSetting({keys::Hour, index}, 0);
  310. }
  311. int minute(size_t index) {
  312. return getSetting({keys::Minute, index}, 0);
  313. }
  314. String weekdays(size_t index) {
  315. return getSetting({keys::Weekdays, index}, DefaultWeekdays);
  316. }
  317. bool utc(size_t index) {
  318. return getSetting({keys::UTC, index}, false);
  319. }
  320. } // namespace settings
  321. String convert_time(const String& weekdays, int hour, int minute, bool utc) {
  322. String out;
  323. // implicit mon..sun already by default
  324. if (weekdays != settings::DefaultWeekdays) {
  325. out += weekdays;
  326. out += ' ';
  327. }
  328. if (hour < 10) {
  329. out += '0';
  330. }
  331. out += String(hour, 10);
  332. out += ':';
  333. if (minute < 10) {
  334. out += '0';
  335. }
  336. out += String(minute, 10);
  337. if (utc) {
  338. out += STRING_VIEW(" UTC");
  339. }
  340. return out;
  341. }
  342. String convert_action(Type type, int target, int action) {
  343. String out;
  344. StringView prefix;
  345. switch (type) {
  346. case Type::None:
  347. break;
  348. case Type::Relay:
  349. {
  350. STRING_VIEW_INLINE(Relay, "relay");
  351. prefix = Relay;
  352. break;
  353. }
  354. case Type::Channel:
  355. {
  356. STRING_VIEW_INLINE(Channel, "channel");
  357. prefix = Channel;
  358. break;
  359. }
  360. case Type::Curtain:
  361. {
  362. STRING_VIEW_INLINE(Curtain, "curtain");
  363. prefix = Curtain;
  364. break;
  365. }
  366. }
  367. if (prefix.length()) {
  368. out += prefix.toString()
  369. + ' ';
  370. out += String(target, 10)
  371. + ' '
  372. + String(action, 10);
  373. }
  374. return out;
  375. }
  376. String convert_type(bool enabled, Type type) {
  377. auto out = scheduler::Type::Unknown;
  378. switch (type) {
  379. case Type::None:
  380. break;
  381. case Type::Relay:
  382. case Type::Channel:
  383. case Type::Curtain:
  384. out = scheduler::Type::Calendar;
  385. break;
  386. }
  387. if (!enabled && (out != scheduler::Type::Unknown)) {
  388. out = scheduler::Type::Disabled;
  389. }
  390. return ::espurna::settings::internal::serialize(out);
  391. }
  392. void migrate() {
  393. for (size_t index = 0; index < build::max(); ++index) {
  394. const auto type = settings::type(index);
  395. setSetting({scheduler::settings::keys::Type, index},
  396. convert_type(settings::enabled(index), type));
  397. setSetting({scheduler::settings::keys::Time, index},
  398. convert_time(settings::weekdays(index),
  399. settings::hour(index),
  400. settings::minute(index),
  401. settings::utc(index)));
  402. setSetting({scheduler::settings::keys::Action, index},
  403. convert_action(type,
  404. settings::target(index),
  405. settings::action(index)));
  406. for (auto& key : settings::keys::List) {
  407. delSetting({key, index});
  408. }
  409. }
  410. }
  411. } // namespace v1
  412. namespace settings {
  413. void migrate(int version) {
  414. if (version < 6) {
  415. moveSettings(
  416. v1::settings::keys::Switch.toString(),
  417. v1::settings::keys::Target.toString());
  418. }
  419. if (version < 15) {
  420. v1::migrate();
  421. }
  422. }
  423. } // namespace settings
  424. #if SCHEDULER_SUN_SUPPORT
  425. namespace sun {
  426. STRING_VIEW_INLINE(Module, "sun");
  427. void setup() {
  428. location.latitude = settings::latitude();
  429. location.longitude = settings::longitude();
  430. location.altitude = settings::altitude();
  431. }
  432. EventMatch* find_event_match(const TimeMatch& m) {
  433. if (want_sunrise(m)) {
  434. return &match.rising;
  435. } else if (want_sunset(m)) {
  436. return &match.setting;
  437. }
  438. return nullptr;
  439. }
  440. EventMatch* find_event_match(const Schedule& schedule) {
  441. return find_event_match(schedule.time);
  442. }
  443. tm time_point_from_seconds(datetime::Seconds seconds) {
  444. tm out{};
  445. time_t timestamp{ seconds.count() };
  446. gmtime_r(&timestamp, &out);
  447. return out;
  448. }
  449. Event make_invalid_event() {
  450. Event out;
  451. out.seconds = datetime::Seconds{ -1 };
  452. out.minutes = datetime::Minutes{ -1 };
  453. return out;
  454. }
  455. Event make_event(datetime::Seconds seconds) {
  456. Event out;
  457. out.seconds = seconds;
  458. out.minutes =
  459. std::chrono::duration_cast<datetime::Minutes>(out.seconds);
  460. out.seconds -= out.minutes;
  461. return out;
  462. }
  463. datetime::Date date_point(const tm& time_point) {
  464. datetime::Date out;
  465. out.year = time_point.tm_year + 1900;
  466. out.month = time_point.tm_mon + 1;
  467. out.day = time_point.tm_mday;
  468. return out;
  469. }
  470. TimeMatch time_match(const tm& time_point) {
  471. TimeMatch out;
  472. out.hour[time_point.tm_hour] = true;
  473. out.minute[time_point.tm_min] = true;
  474. out.flags = FlagUtc;
  475. return out;
  476. }
  477. void update_event_match(EventMatch& match, datetime::Seconds seconds) {
  478. if (seconds <= datetime::Seconds::zero()) {
  479. match.last = make_invalid_event();
  480. return;
  481. }
  482. const auto time_point = time_point_from_seconds(seconds);
  483. match.date = date_point(time_point);
  484. match.time = time_match(time_point);
  485. match.last = make_event(seconds);
  486. }
  487. void update_schedule_from(Schedule& schedule, const EventMatch& match) {
  488. schedule.date.day[match.date.day] = true;
  489. schedule.date.month[match.date.month] = true;
  490. schedule.date.year = match.date.year;
  491. schedule.time = match.time;
  492. }
  493. bool update_schedule(Schedule& schedule) {
  494. // if not sun{rise,set} schedule, keep it as-is
  495. const auto* selected = sun::find_event_match(schedule);
  496. if (nullptr == selected) {
  497. return false;
  498. }
  499. // in case calculation failed, no use here
  500. if (!event_valid((*selected).last)) {
  501. return false;
  502. }
  503. // make sure event can actually trigger with this date spec
  504. if (::espurna::scheduler::match(schedule.date, (*selected).date)) {
  505. update_schedule_from(schedule, *selected);
  506. return true;
  507. }
  508. return false;
  509. }
  510. bool needs_update(datetime::Minutes minutes) {
  511. return ((match.rising.last.minutes < minutes)
  512. || (match.setting.last.minutes < minutes));
  513. }
  514. template <typename T>
  515. void delta_compare(tm& out, datetime::Minutes, T);
  516. void update(datetime::Minutes minutes, const tm& today) {
  517. const auto result = sun::sunrise_sunset(location, today);
  518. update_event_match(match.rising, result.sunrise);
  519. update_event_match(match.setting, result.sunset);
  520. }
  521. template <typename T>
  522. void update(datetime::Minutes minutes, const tm& today, T compare) {
  523. auto result = sun::sunrise_sunset(location, today);
  524. if ((result.sunrise.count() < 0) || (result.sunset.count() < 0)) {
  525. return;
  526. }
  527. if (compare(minutes, result.sunrise) || compare(minutes, result.sunset)) {
  528. tm tmp;
  529. std::memcpy(&tmp, &today, sizeof(tmp));
  530. delta_compare(tmp, minutes, compare);
  531. const auto other = sun::sunrise_sunset(location, tmp);
  532. if ((other.sunrise.count() < 0) || (other.sunset.count() < 0)) {
  533. return;
  534. }
  535. if (compare(minutes, result.sunrise)) {
  536. result.sunrise = other.sunrise;
  537. }
  538. if (compare(minutes, result.sunset)) {
  539. result.sunset = other.sunset;
  540. }
  541. }
  542. update_event_match(match.rising, result.sunrise);
  543. update_event_match(match.setting, result.sunset);
  544. }
  545. template <typename T>
  546. void update(time_t timestamp, const tm& today, T&& compare) {
  547. update(datetime::Seconds{ timestamp }, today, std::forward<T>(compare));
  548. }
  549. String format_match(const EventMatch& match) {
  550. return datetime::format_local(event_seconds(match.last).count());
  551. }
  552. // check() needs current or future events, discard timestamps in the past
  553. // std::greater is type-fixed, make sure minutes vs. seconds actually works
  554. struct CheckCompare {
  555. bool operator()(const datetime::Minutes& lhs, const datetime::Seconds& rhs) {
  556. return lhs > rhs;
  557. }
  558. };
  559. template <>
  560. void delta_compare(tm& out, datetime::Minutes minutes, CheckCompare) {
  561. datetime::delta_utc(
  562. out, datetime::Seconds{ minutes },
  563. datetime::Days{ 1 });
  564. }
  565. void update_after(const datetime::Context& ctx) {
  566. const auto seconds = datetime::Seconds{ ctx.timestamp };
  567. const auto minutes =
  568. std::chrono::duration_cast<datetime::Minutes>(seconds);
  569. if (!needs_update(minutes)) {
  570. return;
  571. }
  572. update(minutes, ctx.utc, CheckCompare{});
  573. if (match.rising.last.minutes.count() > 0) {
  574. DEBUG_MSG_P(PSTR("[SCH] Sunrise at %s\n"),
  575. format_match(match.rising).c_str());
  576. }
  577. if (match.setting.last.minutes.count() > 0) {
  578. DEBUG_MSG_P(PSTR("[SCH] Sunset at %s\n"),
  579. format_match(match.setting).c_str());
  580. }
  581. }
  582. } // namespace sun
  583. #endif
  584. // -----------------------------------------------------------------------------
  585. #if TERMINAL_SUPPORT
  586. namespace terminal {
  587. #if SCHEDULER_SUN_SUPPORT
  588. namespace internal {
  589. String sunrise_sunset(const sun::EventMatch& match) {
  590. if (match.last.minutes > datetime::Minutes::zero()) {
  591. return sun::format_match(match);
  592. }
  593. return STRING_VIEW("value not set").toString();
  594. }
  595. void format_output(::terminal::CommandContext& ctx, const String& prefix, const String& value) {
  596. ctx.output.printf_P(PSTR("%s%s%s\n"),
  597. prefix.c_str(),
  598. value.length()
  599. ? PSTR(" at ")
  600. : " ",
  601. value.c_str());
  602. }
  603. void dump_sunrise_sunset(::terminal::CommandContext& ctx) {
  604. format_output(ctx,
  605. STRING_VIEW("Sunrise").toString(),
  606. sunrise_sunset(sun::match.rising));
  607. format_output(ctx,
  608. STRING_VIEW("Sunset").toString(),
  609. sunrise_sunset(sun::match.setting));
  610. }
  611. } // namespace internal
  612. #endif
  613. PROGMEM_STRING(Dump, "SCHEDULE");
  614. void dump(::terminal::CommandContext&& ctx) {
  615. if (ctx.argv.size() != 2) {
  616. #if SCHEDULER_SUN_SUPPORT
  617. internal::dump_sunrise_sunset(ctx);
  618. #endif
  619. settingsDump(ctx, settings::Settings);
  620. return;
  621. }
  622. size_t id;
  623. if (!tryParseId(ctx.argv[1], id)) {
  624. terminalError(ctx, F("Invalid ID"));
  625. return;
  626. }
  627. settingsDump(ctx, settings::IndexedSettings, id);
  628. terminalOK(ctx);
  629. }
  630. static constexpr ::terminal::Command Commands[] PROGMEM {
  631. {Dump, dump},
  632. };
  633. void setup() {
  634. espurna::terminal::add(Commands);
  635. }
  636. } // namespace terminal
  637. #endif
  638. // -----------------------------------------------------------------------------
  639. #if API_SUPPORT
  640. namespace api {
  641. namespace keys {
  642. STRING_VIEW_INLINE(Type, "type");
  643. STRING_VIEW_INLINE(Restore, "restore");
  644. STRING_VIEW_INLINE(Time, "time");
  645. STRING_VIEW_INLINE(Action, "action");
  646. } // namespace keys
  647. using espurna::settings::internal::serialize;
  648. using espurna::settings::internal::convert;
  649. struct Schedule {
  650. size_t id;
  651. Type type;
  652. int restore;
  653. String time;
  654. String action;
  655. };
  656. void print(JsonObject& root, const Schedule& schedule) {
  657. root[keys::Type] = serialize(schedule.type);
  658. root[keys::Restore] = (1 == schedule.restore);
  659. root[keys::Action] = schedule.action;
  660. root[keys::Time] = schedule.time;
  661. }
  662. template <typename T>
  663. bool set_typed(T& out, JsonObject& root, StringView key) {
  664. auto value = root[key];
  665. if (value.success()) {
  666. out = value.as<T>();
  667. return true;
  668. }
  669. return false;
  670. }
  671. template <>
  672. bool set_typed<Type>(Type& out, JsonObject& root, StringView key) {
  673. auto value = root[key];
  674. if (!value.success()) {
  675. return false;
  676. }
  677. auto type = convert<Type>(value.as<String>());
  678. if (type != Type::Unknown) {
  679. out = type;
  680. return true;
  681. }
  682. return false;
  683. }
  684. template
  685. bool set_typed<String>(String&, JsonObject&, StringView);
  686. template
  687. bool set_typed<bool>(bool&, JsonObject&, StringView);
  688. void update_from(const Schedule& schedule) {
  689. setSetting({keys::Type, schedule.id}, serialize(schedule.type));
  690. setSetting({keys::Time, schedule.id}, schedule.time);
  691. setSetting({keys::Action, schedule.id}, schedule.action);
  692. if (schedule.restore != -1) {
  693. setSetting({keys::Restore, schedule.id}, serialize(1 == schedule.restore));
  694. }
  695. }
  696. bool set(JsonObject& root, const size_t id) {
  697. Schedule out;
  698. out.restore = -1;
  699. // always need type, time and action
  700. if (!set_typed(out.type, root, keys::Type)) {
  701. return false;
  702. }
  703. if (!set_typed(out.time, root, keys::Time)) {
  704. return false;
  705. }
  706. if (!set_typed(out.action, root, keys::Action)) {
  707. return false;
  708. }
  709. // optional restore flag
  710. bool restore;
  711. if (set_typed(restore, root, keys::Restore)) {
  712. out.restore = restore ? 1 : 0;
  713. }
  714. update_from(out);
  715. return true;
  716. }
  717. Schedule make_schedule(size_t id) {
  718. Schedule out;
  719. out.type = settings::type(id);
  720. if (out.type != Type::Unknown) {
  721. out.id = id;
  722. out.restore = settings::restore(id) ? 1 : 0;
  723. out.time = settings::time(id);
  724. out.action = settings::action(id);
  725. }
  726. return out;
  727. }
  728. namespace schedules {
  729. bool get(ApiRequest&, JsonObject& root) {
  730. JsonArray& out = root.createNestedArray("schedules");
  731. for (size_t id = 0; id < build::max(); ++id) {
  732. const auto sch = make_schedule(id);
  733. if (sch.type == Type::Unknown) {
  734. break;
  735. }
  736. auto& root = out.createNestedObject();
  737. print(root, sch);
  738. }
  739. return true;
  740. }
  741. bool set(ApiRequest&, JsonObject& root) {
  742. size_t id = 0;
  743. while (hasSetting({settings::keys::Type, id})) {
  744. ++id;
  745. }
  746. if (id < build::max()) {
  747. return api::set(root, id);
  748. }
  749. return false;
  750. }
  751. } // namespace schedules
  752. namespace schedule {
  753. bool get(ApiRequest& req, JsonObject& root) {
  754. const auto param = req.wildcard(0);
  755. size_t id;
  756. if (tryParseId(param, id)) {
  757. const auto sch = make_schedule(id);
  758. if (sch.type == Type::Unknown) {
  759. return false;
  760. }
  761. print(root, sch);
  762. return true;
  763. }
  764. return false;
  765. }
  766. bool set(ApiRequest& req, JsonObject& root) {
  767. const auto param = req.wildcard(0);
  768. size_t id;
  769. if (tryParseId(param, id)) {
  770. return api::set(root, id);
  771. }
  772. return false;
  773. }
  774. } // namespace schedule
  775. void setup() {
  776. apiRegister(F(MQTT_TOPIC_SCHEDULE), schedules::get, schedules::set);
  777. apiRegister(F(MQTT_TOPIC_SCHEDULE "/+"), schedule::get, schedule::set);
  778. }
  779. } // namespace api
  780. #endif // API_SUPPORT
  781. // -----------------------------------------------------------------------------
  782. #if WEB_SUPPORT
  783. namespace web {
  784. bool onKey(StringView key, const JsonVariant&) {
  785. return key.startsWith(settings::Prefix);
  786. }
  787. void onVisible(JsonObject& root) {
  788. wsPayloadModule(root, settings::Prefix);
  789. #if SCHEDULER_SUN_SUPPORT
  790. wsPayloadModule(root, sun::Module);
  791. #endif
  792. for (const auto& pair : settings::Settings) {
  793. root[pair.key()] = pair.value();
  794. }
  795. }
  796. void onConnected(JsonObject& root){
  797. espurna::web::ws::EnumerableConfig config{ root, STRING_VIEW("schConfig") };
  798. config(STRING_VIEW("schedules"), settings::count(), settings::IndexedSettings);
  799. auto& schedules = config.root();
  800. schedules["max"] = build::max();
  801. }
  802. void setup() {
  803. wsRegister()
  804. .onVisible(onVisible)
  805. .onConnected(onConnected)
  806. .onKeyCheck(onKey);
  807. }
  808. } // namespace web
  809. #endif
  810. // When terminal is disabled, still allow minimum set of actions that we available in v1
  811. #if TERMINAL_SUPPORT == 0
  812. namespace terminal_stub {
  813. #if RELAY_SUPPORT
  814. namespace relay {
  815. void action(SplitStringView split) {
  816. if (!split.next()) {
  817. return;
  818. }
  819. size_t id;
  820. if (!::tryParseId(split.current(), relayCount(), id)) {
  821. return;
  822. }
  823. if (!split.next()) {
  824. return;
  825. }
  826. const auto status = relayParsePayload(split.current());
  827. switch (status) {
  828. case PayloadStatus::Unknown:
  829. break;
  830. case PayloadStatus::Off:
  831. case PayloadStatus::On:
  832. relayStatus(id, (status == PayloadStatus::On));
  833. break;
  834. case PayloadStatus::Toggle:
  835. relayToggle(id);
  836. break;
  837. }
  838. }
  839. } // namespace relay
  840. #endif
  841. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  842. namespace light {
  843. void action(SplitStringView split) {
  844. if (!split.next()) {
  845. return;
  846. }
  847. size_t id;
  848. if (!tryParseId(split.current(), lightChannels(), id)) {
  849. return;
  850. }
  851. if (!split.next()) {
  852. return;
  853. }
  854. const auto convert = ::espurna::settings::internal::convert<long>;
  855. lightChannel(id, convert(split.current().toString()));
  856. lightUpdate();
  857. }
  858. } // namespace light
  859. #endif
  860. #if CURTAIN_SUPPORT
  861. namespace curtain {
  862. void action(SplitStringView split) {
  863. if (!split.next()) {
  864. return;
  865. }
  866. size_t id;
  867. if (!tryParseId(split.current(), curtainCount(), id)) {
  868. return;
  869. }
  870. if (!split.next()) {
  871. return;
  872. }
  873. const auto convert = ::espurna::settings::internal::convert<int>;
  874. curtainUpdate(id, convert(split.current().toString()));
  875. }
  876. } // namespace curtain
  877. #endif
  878. void parse_action(String action) {
  879. auto split = SplitStringView{ action };
  880. if (!split.next()) {
  881. return;
  882. }
  883. auto current = split.current();
  884. #if RELAY_SUPPORT
  885. if (current == STRING_VIEW("relay")) {
  886. relay::action(split);
  887. return;
  888. }
  889. #endif
  890. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  891. if (current == STRING_VIEW("channel")) {
  892. light::action(split);
  893. return;
  894. }
  895. #endif
  896. #if CURTAIN_SUPPORT
  897. if (current == STRING_VIEW("curtain")) {
  898. curtain::action(split);
  899. return;
  900. }
  901. #endif
  902. DEBUG_MSG_P(PSTR("[SCH] Unknown action: %s\n"), action.c_str());
  903. }
  904. } // namespace terminal_stub
  905. using terminal_stub::parse_action;
  906. #else
  907. void parse_action(String action) {
  908. if (!action.endsWith("\r\n") && !action.endsWith("\n")) {
  909. action.concat('\n');
  910. }
  911. static EphemeralPrint output;
  912. PrintString error(64);
  913. if (!espurna::terminal::api_find_and_call(action, output, error)) {
  914. DEBUG_MSG_P(PSTR("[SCH] %s\n"), error.c_str());
  915. }
  916. }
  917. #endif
  918. namespace restore {
  919. [[gnu::used]]
  920. void Context::init() {
  921. #if SCHEDULER_SUN_SUPPORT
  922. const auto seconds = datetime::Seconds{ this->current.timestamp };
  923. const auto minutes =
  924. std::chrono::duration_cast<datetime::Minutes>(seconds);
  925. sun::update(minutes, this->current.utc);
  926. #endif
  927. }
  928. [[gnu::used]]
  929. void Context::init_delta() {
  930. #if SCHEDULER_SUN_SUPPORT
  931. init();
  932. for (auto& pending : this->pending) {
  933. // additional logic in handle_delta. keeps as pending when current value does not pass date match()
  934. pending.schedule.ok =
  935. sun::update_schedule(pending.schedule);
  936. }
  937. #endif
  938. }
  939. [[gnu::used]]
  940. void Context::destroy() {
  941. #if SCHEDULER_SUN_SUPPORT
  942. sun::reset();
  943. #endif
  944. }
  945. // otherwise, there are pending results that need extra days to check
  946. void run_delta(Context& ctx) {
  947. if (!ctx.pending.size()) {
  948. return;
  949. }
  950. const auto days = settings::restoreDays();
  951. for (int day = 0; day < days; ++day) {
  952. if (!ctx.next()) {
  953. break;
  954. }
  955. for (auto it = ctx.pending.begin(); it != ctx.pending.end();) {
  956. if (handle_delta(ctx, *it)) {
  957. it = ctx.pending.erase(it);
  958. } else {
  959. it = std::next(it);
  960. }
  961. }
  962. }
  963. }
  964. // if schedule was due earlier today, make sure this gets checked first
  965. void run_today(Context& ctx) {
  966. for (size_t index = 0; index < build::max(); ++index) {
  967. switch (settings::type(index)) {
  968. case Type::Unknown:
  969. return;
  970. case Type::Disabled:
  971. continue;
  972. case Type::Calendar:
  973. break;
  974. }
  975. if (!settings::restore(index)) {
  976. continue;
  977. }
  978. auto schedule = settings::schedule(index);
  979. if (!schedule.ok) {
  980. continue;
  981. }
  982. #if SCHEDULER_SUN_SUPPORT
  983. if (!sun::update_schedule(schedule)) {
  984. context_pending(ctx, index, schedule);
  985. continue;
  986. }
  987. #else
  988. if (want_sunrise_sunset(schedule.time)) {
  989. continue;
  990. }
  991. #endif
  992. handle_today(ctx, index, schedule);
  993. }
  994. }
  995. void sort(Context& ctx) {
  996. std::sort(
  997. ctx.results.begin(),
  998. ctx.results.end(),
  999. [](const Result& lhs, const Result& rhs) {
  1000. return lhs.offset < rhs.offset;
  1001. });
  1002. }
  1003. void run(const datetime::Context& base) {
  1004. Context ctx{ base };
  1005. run_today(ctx);
  1006. run_delta(ctx);
  1007. sort(ctx);
  1008. for (auto& result : ctx.results) {
  1009. const auto action = settings::action(result.index);
  1010. DEBUG_MSG_P(PSTR("[SCH] Restoring #%zu => %s (%sm)\n"),
  1011. result.index, action.c_str(),
  1012. String(result.offset.count(), 10).c_str());
  1013. parse_action(action);
  1014. }
  1015. }
  1016. } // namespace restore
  1017. void check(const datetime::Context& ctx) {
  1018. for (size_t index = 0; index < build::max(); ++index) {
  1019. switch (settings::type(index)) {
  1020. case Type::Unknown:
  1021. return;
  1022. case Type::Disabled:
  1023. continue;
  1024. case Type::Calendar:
  1025. break;
  1026. }
  1027. auto schedule = settings::schedule(index);
  1028. if (!schedule.ok) {
  1029. continue;
  1030. }
  1031. #if SCHEDULER_SUN_SUPPORT
  1032. if (!sun::update_schedule(schedule)) {
  1033. continue;
  1034. }
  1035. #else
  1036. if (want_sunrise_sunset(schedule.time)) {
  1037. continue;
  1038. }
  1039. #endif
  1040. const auto& time = select_time(ctx, schedule);
  1041. if (!match(schedule.date, time)) {
  1042. continue;
  1043. }
  1044. if (!match(schedule.weekdays, time)) {
  1045. continue;
  1046. }
  1047. if (!match(schedule.time, time)) {
  1048. continue;
  1049. }
  1050. DEBUG_MSG_P(PSTR("[SCH] Action #%zu triggered\n"), index);
  1051. parse_action(settings::action(index));
  1052. }
  1053. }
  1054. void tick(NtpTick tick) {
  1055. if (tick != NtpTick::EveryMinute) {
  1056. return;
  1057. }
  1058. auto ctx = datetime::make_context(now());
  1059. if (initial) {
  1060. initial = false;
  1061. settings::gc(settings::count());
  1062. restore::run(ctx);
  1063. }
  1064. #if SCHEDULER_SUN_SUPPORT
  1065. sun::update_after(ctx);
  1066. #endif
  1067. check(ctx);
  1068. }
  1069. void setup() {
  1070. migrateVersion(scheduler::settings::migrate);
  1071. settings::setup();
  1072. #if SCHEDULER_SUN_SUPPORT
  1073. sun::setup();
  1074. #endif
  1075. #if TERMINAL_SUPPORT
  1076. terminal::setup();
  1077. #endif
  1078. #if WEB_SUPPORT
  1079. web::setup();
  1080. #endif
  1081. #if API_SUPPORT
  1082. api::setup();
  1083. #endif
  1084. ntpOnTick(tick);
  1085. }
  1086. } // namespace
  1087. } // namespace scheduler
  1088. } // namespace espurna
  1089. // -----------------------------------------------------------------------------
  1090. void schSetup() {
  1091. espurna::scheduler::setup();
  1092. }
  1093. #endif // SCHEDULER_SUPPORT