mirror of
https://github.com/randybb/esphome-configs.git
synced 2026-10-14 16:35:12 +02:00
161 lines
5.4 KiB
C++
161 lines
5.4 KiB
C++
#pragma once
|
|
// Upcoming calendar events for mcu-m5paper: parsed from the calendar.get_events
|
|
// response and kept in flash (NVS), so they survive the power-off between RTC wake-ups.
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
#include "esphome/components/json/json_util.h"
|
|
#include "esphome/core/preferences.h"
|
|
#include "esphome/core/time.h"
|
|
|
|
namespace m5paper_calendar {
|
|
|
|
static constexpr uint8_t MAX_EVENTS = 32;
|
|
static constexpr size_t SUMMARY_LEN = 44;
|
|
static constexpr uint16_t ALL_DAY = 0xFFFF;
|
|
static constexpr uint32_t PREF_HASH = 0x6D35C41A;
|
|
|
|
enum Kind : uint8_t { BIRTHDAY = 0, WASTE };
|
|
|
|
struct Event {
|
|
uint16_t day; // local date, days since 1970-01-01
|
|
uint16_t minute; // local start time in minutes after midnight, ALL_DAY if none
|
|
uint8_t kind;
|
|
char summary[SUMMARY_LEN + 1];
|
|
};
|
|
|
|
struct Events {
|
|
uint32_t fetched_at{0}; // UTC epoch of the download
|
|
uint8_t count{0};
|
|
Event items[MAX_EVENTS]{};
|
|
|
|
bool valid() const { return this->fetched_at != 0; }
|
|
};
|
|
|
|
// the events in RAM, loaded from flash at boot
|
|
inline Events &events() {
|
|
static Events ev;
|
|
return ev;
|
|
}
|
|
|
|
// days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm)
|
|
inline int32_t days_from_civil(int y, int m, int d) {
|
|
y -= m <= 2;
|
|
const int era = (y >= 0 ? y : y - 399) / 400;
|
|
const int yoe = y - era * 400;
|
|
const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
|
const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
return era * 146097 + doe - 719468;
|
|
}
|
|
|
|
inline int32_t local_day(const esphome::ESPTime &t) { return days_from_civil(t.year, t.month, t.day_of_month); }
|
|
|
|
// 0 = Monday
|
|
inline int weekday(int32_t day) { return (day + 3) % 7; }
|
|
|
|
// "2026-10-01" (all-day) or "2026-10-01T10:00:00+02:00" -> local day and minute; false on error
|
|
inline bool parse_start(const char *s, uint16_t &day, uint16_t &minute) {
|
|
int y, mo, d, h, mi, sec = 0, oh = 0, om = 0;
|
|
char sign = '+';
|
|
int n = sscanf(s, "%d-%d-%dT%d:%d:%d%c%d:%d", &y, &mo, &d, &h, &mi, &sec, &sign, &oh, &om);
|
|
if (n < 3)
|
|
return false;
|
|
if (n < 5) {
|
|
day = days_from_civil(y, mo, d);
|
|
minute = ALL_DAY;
|
|
return true;
|
|
}
|
|
int64_t ts = int64_t(days_from_civil(y, mo, d)) * 86400 + h * 3600 + mi * 60 + sec;
|
|
if (n >= 8 && (sign == '+' || sign == '-')) {
|
|
int64_t offset = oh * 3600 + om * 60;
|
|
ts += sign == '+' ? -offset : offset;
|
|
}
|
|
if (ts <= 0)
|
|
return false;
|
|
const auto t = esphome::ESPTime::from_epoch_local(ts);
|
|
day = local_day(t);
|
|
minute = t.hour * 60 + t.minute;
|
|
return true;
|
|
}
|
|
|
|
// copy at most SUMMARY_LEN bytes without cutting a UTF-8 sequence
|
|
inline void copy_summary(char *dst, const char *src) {
|
|
size_t n = strnlen(src, SUMMARY_LEN + 1);
|
|
if (n > SUMMARY_LEN) {
|
|
n = SUMMARY_LEN;
|
|
while (n > 0 && (uint8_t(src[n]) & 0xC0) == 0x80)
|
|
n--;
|
|
}
|
|
memcpy(dst, src, n);
|
|
dst[n] = '\0';
|
|
}
|
|
|
|
// Parse the calendar.get_events response; Home Assistant wraps the action response as
|
|
// {"response": {"calendar.x": {"events": [...]}, ...}}; every calendar other than the waste one holds birthdays
|
|
inline bool parse(ArduinoJson::JsonObjectConst response, const char *waste, uint32_t now, Events &ev) {
|
|
ArduinoJson::JsonObjectConst root = response["response"].is<ArduinoJson::JsonObjectConst>()
|
|
? response["response"].as<ArduinoJson::JsonObjectConst>()
|
|
: response;
|
|
if (root.size() == 0)
|
|
return false;
|
|
|
|
Events out;
|
|
for (ArduinoJson::JsonPairConst cal : root) {
|
|
const uint8_t kind = strcmp(cal.key().c_str(), waste) == 0 ? WASTE : BIRTHDAY;
|
|
for (ArduinoJson::JsonObjectConst item : cal.value()["events"].as<ArduinoJson::JsonArrayConst>()) {
|
|
Event e;
|
|
if (!parse_start(item["start"] | "", e.day, e.minute))
|
|
continue;
|
|
e.kind = kind;
|
|
copy_summary(e.summary, item["summary"] | "");
|
|
// the same birthday in the contacts and the manual calendar
|
|
if (std::any_of(out.items, out.items + out.count,
|
|
[&e](const Event &o) { return o.day == e.day && strcmp(o.summary, e.summary) == 0; }))
|
|
continue;
|
|
if (out.count < MAX_EVENTS) {
|
|
out.items[out.count++] = e;
|
|
continue;
|
|
}
|
|
// full: keep the nearest events
|
|
Event *last = std::max_element(out.items, out.items + out.count,
|
|
[](const Event &a, const Event &b) { return a.day < b.day; });
|
|
if (e.day < last->day)
|
|
*last = e;
|
|
}
|
|
}
|
|
std::sort(out.items, out.items + out.count, [](const Event &a, const Event &b) {
|
|
// all-day (0xFFFF wraps to 0) first
|
|
return a.day != b.day ? a.day < b.day : uint16_t(a.minute + 1) < uint16_t(b.minute + 1);
|
|
});
|
|
out.fetched_at = now;
|
|
ev = out;
|
|
return true;
|
|
}
|
|
|
|
// battery cycle: refreshed once a day, after midnight
|
|
inline bool stale(const esphome::ESPTime &now) {
|
|
const auto &ev = events();
|
|
return !ev.valid() || !now.is_valid() || now.timestamp - ev.fetched_at >= 20 * 3600;
|
|
}
|
|
|
|
inline esphome::ESPPreferenceObject &pref() {
|
|
static esphome::ESPPreferenceObject p = esphome::global_preferences->make_preference<Events>(PREF_HASH, true);
|
|
return p;
|
|
}
|
|
|
|
inline bool load(Events &ev) {
|
|
if (!pref().load(&ev)) {
|
|
ev = Events{};
|
|
return false;
|
|
}
|
|
ev.count = std::min(ev.count, MAX_EVENTS);
|
|
return true;
|
|
}
|
|
|
|
// written to flash right away: the board may power off before the next preferences flush
|
|
inline bool save(const Events &ev) { return pref().save(&ev) && esphome::global_preferences->sync(); }
|
|
|
|
} // namespace m5paper_calendar
|