Files
OpenEPaperLink/ESP32_AP-Flasher/src/contentmanager.cpp
Skip Hansen bfff2ef0b9 Added support for ts_option to provide finer control of time stamps.
1. Fixed time stamp overlap on weather forecast content on  2.9" tags.
2. Fixed time stamp overlap on AP info screen on 160x80 TFT displays.
3. Changed black on white to white on black on TFT displays.
2025-11-06 15:38:21 -05:00

3188 lines
129 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "contentmanager.h"
// possibility to turn off, to save space if needed
#ifndef SAVE_SPACE
#define CONTENT_QR
#define CONTENT_RSS
#define CONTENT_BIGCAL
#define CONTENT_NFCLUT
#define CONTENT_DAYAHEAD
#define CONTENT_TIMESTAMP
#define CONTENT_BUIENRADAR
#define CONTENT_CAL
#define CONTENT_TIME_RAWDATA
#endif
#define CONTENT_TAGCFG
#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <MD5Builder.h>
#include <locale.h>
#ifdef CONTENT_RSS
#include <rssClass.h>
#endif
#include <TJpg_Decoder.h>
#include <time.h>
#include <map>
#include "commstructs.h"
#include "makeimage.h"
#include "newproto.h"
#include "storage.h"
#ifdef CONTENT_QR
#include "QRCodeGenerator.h"
#endif
#include "language.h"
#include "settings.h"
#include "system.h"
#include "tag_db.h"
#include "truetype.h"
#include "util.h"
#include "web.h"
// https://csvjson.com/json_beautifier
bool needRedraw(uint8_t contentMode, uint8_t wakeupReason) {
// contentmode 26, timestamp
if ((wakeupReason == WAKEUP_REASON_BUTTON1 || wakeupReason == WAKEUP_REASON_BUTTON2 || wakeupReason == WAKEUP_REASON_BUTTON3) && contentMode == 26) return true;
return false;
}
void contentRunner() {
if (config.runStatus == RUNSTATUS_STOP) return;
time_t now;
time(&now);
uint8_t wifimac[8];
WiFi.macAddress(wifimac);
memset(&wifimac[6], 0, 2);
for (tagRecord *taginfo : tagDB) {
const bool isAp = memcmp(taginfo->mac, wifimac, 8) == 0;
if (taginfo->RSSI &&
(now >= taginfo->nextupdate || needRedraw(taginfo->contentMode, taginfo->wakeupReason)) &&
config.runStatus == RUNSTATUS_RUN &&
(taginfo->expectedNextCheckin < now + 300 || isAp || (wsClientCount() && config.stopsleep == 1)) &&
Storage.freeSpace() > 31000 && !util::isSleeping(config.sleepTime1, config.sleepTime2)) {
drawNew(taginfo->mac, taginfo);
taginfo->wakeupReason = 0;
}
if (taginfo->expectedNextCheckin > now - 10 && taginfo->expectedNextCheckin < now + 30 && taginfo->pendingIdle == 0 && taginfo->pendingCount == 0 && !isAp) {
int32_t minutesUntilNextUpdate = (taginfo->nextupdate - now) / 60;
if (minutesUntilNextUpdate > config.maxsleep) {
minutesUntilNextUpdate = config.maxsleep;
}
if (util::isSleeping(config.sleepTime1, config.sleepTime2)) {
struct tm timeinfo;
getLocalTime(&timeinfo);
struct tm nextSleepTimeinfo = timeinfo;
nextSleepTimeinfo.tm_hour = config.sleepTime2;
nextSleepTimeinfo.tm_min = 0;
nextSleepTimeinfo.tm_sec = 0;
time_t nextWakeTime = mktime(&nextSleepTimeinfo);
if (nextWakeTime < now) nextWakeTime += 24 * 3600;
minutesUntilNextUpdate = (nextWakeTime - now) / 60 - 2;
}
if (minutesUntilNextUpdate > 1 && (wsClientCount() == 0 || config.stopsleep == 0)) {
taginfo->pendingIdle = minutesUntilNextUpdate * 60;
taginfo->expectedNextCheckin = now + taginfo->pendingIdle;
if (taginfo->isExternal == false) {
prepareIdleReq(taginfo->mac, minutesUntilNextUpdate);
}
}
}
vTaskDelay(1 / portTICK_PERIOD_MS); // add a small delay to allow other threads to run
}
}
void checkVars() {
JsonDocument cfgobj;
for (tagRecord *tag : tagDB) {
if (tag->contentMode == 19) {
deserializeJson(cfgobj, tag->modeConfigJson);
const String jsonfile = cfgobj["filename"].as<String>();
if (!util::isEmptyOrNull(jsonfile)) {
File file = contentFS->open(jsonfile, "r");
if (file) {
const size_t fileSize = file.size();
std::unique_ptr<char[]> fileContent(new char[fileSize + 1]);
file.readBytes(fileContent.get(), fileSize);
file.close();
fileContent[fileSize] = '\0';
const char *contentPtr = fileContent.get();
for (const auto &entry : varDB) {
if (entry.second.changed && strstr(contentPtr, entry.first.c_str()) != nullptr) {
Serial.println("updating " + jsonfile + " because of var " + entry.first.c_str());
tag->nextupdate = 0;
}
}
}
file.close();
}
}
if (tag->contentMode == 21) {
if (varDB["ap_tagcount"].changed || varDB["ap_ip"].changed || varDB["ap_ch"].changed) {
tag->nextupdate = 0;
}
}
}
for (const auto &entry : varDB) {
if (entry.second.changed) varDB[entry.first].changed = false;
}
}
/// @brief Draw a counter
/// @param mac Destination mac
/// @param taginfo Tag information
/// @param cfgobj Tag config as json object
/// @param filename Filename
/// @param imageParams Image parameters
/// @param nextupdate Next counter update
/// @param nextCheckin Next tag checkin
void drawCounter(const uint8_t mac[8], tagRecord *&taginfo, JsonObject &cfgobj, String &filename, imgParam &imageParams, const uint32_t nextupdate, const uint16_t nextCheckin) {
int32_t counter = cfgobj["counter"].as<int32_t>();
bool buttonPressed = false;
if (buttonPressed) { // fixme: button pressed
counter = 0;
}
drawNumber(filename, counter, (int32_t)cfgobj["thresholdred"], taginfo, imageParams);
taginfo->nextupdate = nextupdate;
updateTagImage(filename, mac, (buttonPressed ? 0 : nextCheckin), taginfo, imageParams);
cfgobj["counter"] = counter + 1;
}
void drawNew(const uint8_t mac[8], tagRecord *&taginfo) {
time_t now;
time(&now);
const HwType hwdata = getHwType(taginfo->hwType);
if (hwdata.bpp == 0) {
taginfo->nextupdate = now + 300;
Serial.println("No definition found for tag type " + String(taginfo->hwType));
return;
}
uint8_t wifimac[8];
WiFi.macAddress(wifimac);
memset(&wifimac[6], 0, 2);
const bool isAp = memcmp(mac, wifimac, 8) == 0;
if ((taginfo->wakeupReason == WAKEUP_REASON_FIRSTBOOT || taginfo->wakeupReason == WAKEUP_REASON_WDT_RESET) && taginfo->contentMode == 0) {
if (isAp) {
taginfo->contentMode = 21;
taginfo->nextupdate = 0;
} else if (contentFS->exists("/tag_defaults.json")) {
JsonDocument doc;
fs::File tagDefaults = contentFS->open("/tag_defaults.json", "r");
DeserializationError err = deserializeJson(doc, tagDefaults);
if (!err) {
if (doc["contentMode"].is<uint8_t>()) {
taginfo->contentMode = doc["contentMode"];
}
if (doc["modecfgjson"].is<String>()) {
taginfo->modeConfigJson = doc["modecfgjson"].as<String>();
}
}
tagDefaults.close();
}
}
char hexmac[17];
mac2hex(mac, hexmac);
String filename = "/temp/" + String(hexmac) + ".raw";
#ifdef HAS_TFT
if (isAp) {
filename = "direct";
}
#endif
JsonDocument doc;
deserializeJson(doc, taginfo->modeConfigJson);
JsonObject cfgobj = doc.as<JsonObject>();
char buffer[64];
wsLog("Updating " + String(hexmac));
taginfo->nextupdate = now + 60;
imgParam imageParams;
imageParams.hwdata = hwdata;
imageParams.width = hwdata.width;
imageParams.height = hwdata.height;
imageParams.bpp = hwdata.bpp;
imageParams.rotatebuffer = hwdata.rotatebuffer;
imageParams.shortlut = hwdata.shortlut;
imageParams.highlightColor = getColor(String(hwdata.highlightColor));
imageParams.hasRed = false;
imageParams.dataType = DATATYPE_IMG_RAW_1BPP;
imageParams.dither = 2;
imageParams.invert = taginfo->invert;
imageParams.symbols = 0;
imageParams.rotate = taginfo->rotate;
if (hwdata.zlib != 0 && taginfo->tagSoftwareVersion >= hwdata.zlib) {
imageParams.zlib = 1;
} else {
imageParams.zlib = 0;
}
#ifdef SAVE_SPACE
imageParams.g5 = 0;
#else
if (hwdata.g5 != 0 && taginfo->tagSoftwareVersion >= hwdata.g5) {
imageParams.g5 = 1;
} else {
imageParams.g5 = 0;
}
#endif
imageParams.lut = EPD_LUT_NO_REPEATS;
if (taginfo->lut == 2) imageParams.lut = EPD_LUT_FAST_NO_REDS;
if (taginfo->lut == 3) imageParams.lut = EPD_LUT_FAST;
time_t last_midnight = now - now % (24 * 60 * 60) + 3 * 3600; // somewhere in the middle of the night
if (imageParams.shortlut == SHORTLUT_DISABLED || taginfo->lastfullupdate < last_midnight || taginfo->lut == 1) {
imageParams.lut = EPD_LUT_DEFAULT;
taginfo->lastfullupdate = now;
}
int32_t interval = cfgobj["interval"].as<int>() * 60;
if (interval == -1440 * 60) {
interval = util::getMidnightTime() - now;
} else if (interval < 0) {
interval = -interval;
unsigned int secondsUntilNext = (interval - (now % interval)) % interval;
interval = secondsUntilNext;
} else if (interval < 180)
interval = 60 * 60;
imageParams.ts_option = config.showtimestamp;
if(imageParams.ts_option) {
JsonDocument loc;
getTemplate(loc, taginfo->contentMode, taginfo->hwType);
if(loc["ts_option"].is<int>()) {
// Overide ts_option if present in template
imageParams.ts_option = loc["ts_option"];
}
else {
const JsonArray jsonArray = loc.as<JsonArray>();
for (const JsonVariant &elem : jsonArray) {
if(elem["ts_option"].is<int>()) {
// Overide ts_option if present in template
imageParams.ts_option = elem["ts_option"];
break;
}
}
}
}
switch (taginfo->contentMode) {
case 0: // Not configured
case 22: // Static image
case 23: // Static image (advanced)
case 24: // External image
case 25: // Home Assistant
{
String configFilename = cfgobj["filename"].as<String>();
if (!util::isEmptyOrNull(configFilename)) {
if (!configFilename.startsWith("/")) {
configFilename = "/" + configFilename;
}
if (!contentFS->exists(configFilename)) {
taginfo->nextupdate = 3216153600;
wsErr("Not found: " + configFilename);
break;
}
imageParams.dither = cfgobj["dither"];
imageParams.preload = cfgobj["preload"] && cfgobj["preload"] == "1";
imageParams.preloadlut = cfgobj["preload_lut"];
imageParams.preloadtype = cfgobj["preload_type"];
jpg2buffer(configFilename, filename, imageParams);
if (imageParams.hasRed && imageParams.lut == EPD_LUT_NO_REPEATS && imageParams.shortlut == SHORTLUT_ONLY_BLACK) {
imageParams.lut = EPD_LUT_DEFAULT;
}
if (imageParams.bpp == 3) {
imageParams.dataType = DATATYPE_IMG_RAW_3BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_3BPP");
} else if (imageParams.bpp == 4) {
imageParams.dataType = DATATYPE_IMG_RAW_4BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_4BPP");
} else if (imageParams.zlib) {
imageParams.dataType = DATATYPE_IMG_ZLIB;
Serial.println("datatype: DATATYPE_IMG_ZLIB");
} else if (imageParams.g5) {
imageParams.dataType = DATATYPE_IMG_G5;
Serial.println("datatype: DATATYPE_IMG_G5");
} else if (imageParams.hasRed) {
imageParams.dataType = DATATYPE_IMG_RAW_2BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_2BPP");
} else {
Serial.println("datatype: DATATYPE_IMG_RAW_1BPP");
}
struct imageDataTypeArgStruct arg = {0};
// load parameters in case we do need to preload an image
if (imageParams.preload) {
arg.preloadImage = 1;
arg.specialType = imageParams.preloadtype;
arg.lut = imageParams.preloadlut;
} else {
arg.lut = imageParams.lut & 0x03;
}
if (prepareDataAvail(filename, imageParams.dataType, *((uint8_t *)&arg), mac, cfgobj["timetolive"].as<int>())) {
if (cfgobj["delete"].as<String>() == "1") {
contentFS->remove("/" + configFilename);
}
} else {
wsErr("Error accessing " + filename);
}
} else {
// configfilename is empty. Probably the tag needs to redisplay the image after a reboot.
Serial.println("Resend static image");
// fixme: doesn't work yet
// prepareDataAvail(mac);
}
taginfo->nextupdate = 3216153600;
} break;
case 1: // Today
drawDate(filename, cfgobj, taginfo, imageParams);
taginfo->nextupdate = util::getMidnightTime();
updateTagImage(filename, mac, (taginfo->nextupdate - now) / 60 - 10, taginfo, imageParams);
break;
case 2: // CountDays
drawCounter(mac, taginfo, cfgobj, filename, imageParams, util::getMidnightTime(), 15);
break;
case 3: // CountHours
drawCounter(mac, taginfo, cfgobj, filename, imageParams, now + 3600, 5);
break;
case 4: // Weather
// https://open-meteo.com/
// https://geocoding-api.open-meteo.com/v1/search?name=eindhoven
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current_weather=true
// https://github.com/erikflowers/weather-icons
drawWeather(filename, cfgobj, taginfo, imageParams);
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
break;
case 8: // Forecast
drawForecast(filename, cfgobj, taginfo, imageParams);
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
break;
case 5: // Firmware
filename = cfgobj["filename"].as<String>();
if (!util::isEmptyOrNull(filename) && !cfgobj["#fetched"].as<bool>()) {
File file = contentFS->open(filename, "r");
if (file) {
if (file.find("<html")) {
file.close();
wsErr("User error flashing tag firmware: this is a html-file!");
cfgobj["#fetched"] = true;
} else {
file.close();
if (prepareDataAvail(filename, DATATYPE_FW_UPDATE, 0, mac, cfgobj["timetolive"].as<int>())) {
cfgobj["#fetched"] = true;
}
}
}
taginfo->nextupdate = 3216153600;
} else {
taginfo->nextupdate = now + 300;
}
break;
case 7: // ImageUrl
{
const int httpcode = getImgURL(filename, cfgobj["url"], (time_t)cfgobj["#fetched"], imageParams, String(hexmac));
if (httpcode == 200) {
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
cfgobj["#fetched"] = now;
} else if (httpcode == 304) {
taginfo->nextupdate = now + interval;
} else {
taginfo->nextupdate = now + 300;
}
break;
}
#ifdef CONTENT_RSS
case 9: // RSSFeed
if (getRssFeed(filename, cfgobj["url"], cfgobj["title"], taginfo, imageParams)) {
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
} else {
taginfo->nextupdate = now + 300;
}
break;
#endif
#ifdef CONTENT_QR
case 10: // QRcode:
drawQR(filename, cfgobj["qr-content"], cfgobj["title"], taginfo, imageParams);
taginfo->nextupdate = now + 12 * 3600;
updateTagImage(filename, mac, 0, taginfo, imageParams);
break;
#endif
#ifdef CONTENT_CAL
case 11: // Calendar:
if (getCalFeed(filename, cfgobj, taginfo, imageParams)) {
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
} else {
taginfo->nextupdate = now + 300;
}
break;
#endif
case 12: // RemoteAP
taginfo->nextupdate = 3216153600;
break;
case 13: // SegStatic
sprintf(buffer, "%-4.4s%-2.2s%-4.4s", cfgobj["line1"].as<const char *>(), cfgobj["line2"].as<const char *>(), cfgobj["line3"].as<const char *>());
taginfo->nextupdate = 3216153600;
sendAPSegmentedData(mac, (String)buffer, 0x0000, false, (taginfo->isExternal == false));
break;
#ifdef CONTENT_NFCLUT
case 14: // NFC URL
taginfo->nextupdate = 3216153600;
prepareNFCReq(mac, cfgobj["url"].as<const char *>());
break;
#endif
#ifdef CONTENT_BUIENRADAR
case 16: // buienradar
{
const uint8_t refresh = drawBuienradar(filename, cfgobj, taginfo, imageParams);
taginfo->nextupdate = now + refresh * 60;
updateTagImage(filename, mac, refresh, taginfo, imageParams);
break;
}
#endif
case 17: // tag command
{
sendTagCommand(mac, cfgobj["cmd"].as<int>(), (taginfo->isExternal == false));
taginfo->nextupdate = 3216153600;
break;
}
#ifdef CONTENT_TAGCFG
case 18: // tag config
{
prepareConfigFile(mac, cfgobj);
taginfo->nextupdate = 3216153600;
break;
}
#endif
case 19: // json template
{
const String configFilename = cfgobj["filename"].as<String>();
if (!util::isEmptyOrNull(configFilename)) {
String configUrl = cfgobj["url"].as<String>();
if (!util::isEmptyOrNull(configUrl)) {
JsonDocument json;
Serial.println("Get json url + file");
int index = configUrl.indexOf("{mac}");
if (index != -1) {
char macStr[17];
mac2hex(mac, macStr);
configUrl.replace("{mac}", macStr);
}
if (util::httpGetJson(configUrl, json, 1000)) {
taginfo->nextupdate = now + interval;
if (getJsonTemplateFileExtractVariables(filename, configFilename, json, taginfo, imageParams)) {
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
} else {
wsErr("error opening file " + configFilename);
}
} else {
taginfo->nextupdate = now + 600;
}
} else {
const bool result = getJsonTemplateFile(filename, configFilename, taginfo, imageParams);
if (result) {
updateTagImage(filename, mac, interval, taginfo, imageParams);
} else {
wsErr("error opening file " + configFilename);
}
taginfo->nextupdate = 3216153600;
}
} else {
const int httpcode = getJsonTemplateUrl(filename, cfgobj["url"], (time_t)cfgobj["#fetched"], String(hexmac), taginfo, imageParams);
if (httpcode == 200) {
taginfo->nextupdate = now + interval;
updateTagImage(filename, mac, interval / 60, taginfo, imageParams);
cfgobj["#fetched"] = now;
} else if (httpcode == 304) {
taginfo->nextupdate = now + interval;
} else {
taginfo->nextupdate = now + 600;
}
}
break;
}
case 20: // display a copy
break;
case 21: // ap info
drawAPinfo(filename, cfgobj, taginfo, imageParams);
updateTagImage(filename, mac, 0, taginfo, imageParams);
taginfo->nextupdate = 3216153600;
break;
#ifdef CONTENT_TIMESTAMP
case 26: // timestamp
taginfo->nextupdate = 3216153600;
drawTimestamp(filename, cfgobj, taginfo, imageParams);
updateTagImage(filename, mac, 0, taginfo, imageParams);
break;
#endif
#ifdef CONTENT_DAYAHEAD
case 27: // Day Ahead:
if (getDayAheadFeed(filename, cfgobj, taginfo, imageParams)) {
// Get user-configured update frequency in minutes, default to 15 minutes (matching 15-min data intervals)
int updateFreqMinutes = 15;
if (cfgobj["updatefreq"].is<String>()) {
String freqStr = cfgobj["updatefreq"].as<String>();
freqStr.trim();
if (freqStr.length() > 0) {
updateFreqMinutes = freqStr.toInt();
if (updateFreqMinutes < 1) {
wsErr("Invalid update frequency, defaulting to 15 minutes");
updateFreqMinutes = 15;
}
}
}
int updateInterval = updateFreqMinutes * 60; // Convert to seconds
// Schedule next update (align to interval boundary for consistent timing)
taginfo->nextupdate = now + (updateInterval - now % updateInterval);
updateTagImage(filename, mac, 0, taginfo, imageParams);
} else {
taginfo->nextupdate = now + 300; // Retry in 5 minutes on failure
}
break;
#endif
case 28: // tag command
{
uint64_t newmac;
sscanf(cfgobj["mac"].as<String>().c_str(), "%llx", &newmac);
sendTagMac(mac, newmac, (taginfo->isExternal == false));
taginfo->nextupdate = 3216153600;
break;
}
#ifdef CONTENT_TIME_RAWDATA
case 29: // Time and raw data like strings etc. in the future
taginfo->nextupdate = now + 1800;
prepareTIME_RAW(mac, now);
break;
#endif
}
taginfo->modeConfigJson = doc.as<String>();
}
bool updateTagImage(String &filename, const uint8_t *dst, uint16_t nextCheckin, tagRecord *&taginfo, imgParam &imageParams) {
if (taginfo->hwType == SOLUM_SEG_UK) {
sendAPSegmentedData(dst, (String)imageParams.segments, imageParams.symbols, (imageParams.invert == 1), (taginfo->isExternal == false));
} else {
if (imageParams.hasRed && imageParams.lut == EPD_LUT_NO_REPEATS && imageParams.shortlut == SHORTLUT_ONLY_BLACK) {
imageParams.lut = EPD_LUT_DEFAULT;
}
if (imageParams.bpp == 3) {
imageParams.dataType = DATATYPE_IMG_RAW_3BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_3BPP");
} else if (imageParams.bpp == 4) {
imageParams.dataType = DATATYPE_IMG_RAW_4BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_4BPP");
} else if (imageParams.zlib) {
imageParams.dataType = DATATYPE_IMG_ZLIB;
Serial.println("datatype: DATATYPE_IMG_ZLIB");
} else if (imageParams.g5) {
imageParams.dataType = DATATYPE_IMG_G5;
Serial.println("datatype: DATATYPE_IMG_G5");
} else if (imageParams.hasRed) {
imageParams.dataType = DATATYPE_IMG_RAW_2BPP;
Serial.println("datatype: DATATYPE_IMG_RAW_2BPP");
}
if (nextCheckin > 0x7fff) nextCheckin = 0;
prepareDataAvail(filename, imageParams.dataType, imageParams.lut, dst, nextCheckin);
}
return true;
}
uint8_t processFontPath(String &font) {
if (font == "") return 3;
if (font == "glasstown_nbp_tf") return 1;
if (font == "7x14_tf") return 1;
if (font == "t0_14b_tf") return 1;
if (font.indexOf('/') == -1) font = "/fonts/" + font;
if (!font.startsWith("/")) font = "/" + font;
if (font.endsWith(".vlw")) font = font.substring(0, font.length() - 4);
if (font.endsWith(".ttf")) return 2;
return 3;
}
void replaceVariables(String &format) {
size_t startIndex = 0;
size_t openBraceIndex, closeBraceIndex;
time_t now;
time(&now);
struct tm timedef;
localtime_r(&now, &timedef);
char timeBuffer[80];
strftime(timeBuffer, sizeof(timeBuffer), "%H:%M:%S", &timedef);
setVarDB("ap_time", timeBuffer, false);
while ((openBraceIndex = format.indexOf('{', startIndex)) != -1 &&
(closeBraceIndex = format.indexOf('}', openBraceIndex + 1)) != -1) {
const std::string variableName = format.substring(openBraceIndex + 1, closeBraceIndex).c_str();
const std::string varKey = "{" + variableName + "}";
const auto var = varDB.find(variableName);
if (var != varDB.end()) {
format.replace(varKey.c_str(), var->second.value);
} else {
format.replace(varKey.c_str(), "-");
}
startIndex = closeBraceIndex + 1;
}
}
void drawString(TFT_eSprite &spr, String content, int16_t posx, int16_t posy, String font, byte align, uint16_t color, uint16_t size, uint16_t bgcolor) {
// drawString(spr,"test",100,10,"bahnschrift30",TC_DATUM,TFT_RED);
// backwards compitibility
replaceVariables(content);
if (font.startsWith("fonts/calibrib")) {
String numericValueStr = font.substring(14);
int calibriSize = numericValueStr.toInt();
if (calibriSize != 30 && calibriSize != 16) {
font = "Signika-SB.ttf";
size = calibriSize;
}
}
if (font == "glasstown_nbp_tf") {
font = "tahoma9.vlw";
posy -= 8;
}
if (font == "7x14_tf") {
font = "REFSAN12.vlw";
posy -= 10;
}
if (font == "t0_14b_tf") {
font = "calibrib16.vlw";
posy -= 11;
}
switch (processFontPath(font)) {
case 2: {
// truetype
time_t t = millis();
truetypeClass truetype = truetypeClass();
void *framebuffer = spr.getPointer();
truetype.setFramebuffer(spr.width(), spr.height(), spr.getColorDepth(), static_cast<uint8_t *>(framebuffer));
File fontFile = contentFS->open(font, "r");
if (!truetype.setTtfFile(fontFile)) {
Serial.println("read ttf failed");
return;
}
truetype.setCharacterSize(size);
truetype.setCharacterSpacing(0);
if (align == TC_DATUM) {
posx -= truetype.getStringWidth(content) / 2;
}
if (align == TR_DATUM) {
posx -= truetype.getStringWidth(content);
}
truetype.setTextBoundary(posx, spr.width(), spr.height());
if (spr.getColorDepth() == 8) {
truetype.setTextColor(spr.color16to8(color), spr.color16to8(color));
} else {
truetype.setTextColor(color, color);
}
truetype.textDraw(posx, posy, content);
truetype.end();
} break;
case 3: {
// vlw bitmap font
spr.setTextDatum(align);
if (font != "") spr.loadFont(font.substring(1), *contentFS);
spr.setTextColor(color, bgcolor);
spr.setTextWrap(false, false);
spr.drawString(content, posx, posy);
if (font != "") spr.unloadFont();
}
}
}
void drawTextBox(TFT_eSprite &spr, String &content, int16_t &posx, int16_t &posy, int16_t boxwidth, int16_t boxheight, String font, uint16_t color, uint16_t bgcolor, float lineheight, byte align) {
replaceVariables(content);
switch (processFontPath(font)) {
case 2: {
// truetype
Serial.println("truetype font not implemented for drawTextBox");
} break;
case 3: {
// vlw bitmap font
// spr.drawRect(posx, posy, boxwidth, boxheight, TFT_BLACK);
spr.setTextDatum(align);
if (font != "") spr.loadFont(font.substring(1), *contentFS);
spr.setTextWrap(false, false);
spr.setTextColor(color, bgcolor);
int length = content.length();
int startPos = 0;
int startPosY = posy;
while (startPos < length && posy + spr.gFont.yAdvance <= startPosY + boxheight) {
int endPos = startPos;
bool hasspace = false;
while (endPos < length && spr.textWidth(content.substring(startPos, endPos + 1).c_str()) <= boxwidth && content.charAt(endPos) != '\n') {
if (content.charAt(endPos) == ' ' || content.charAt(endPos) == '-') hasspace = true;
endPos++;
}
while (endPos < length && endPos > startPos && hasspace == true && content.charAt(endPos - 1) != ' ' && content.charAt(endPos - 1) != '-' && content.charAt(endPos) != '\n') {
endPos--;
}
spr.drawString(content.substring(startPos, endPos), posx, posy);
posy += spr.gFont.yAdvance * lineheight;
if (content.charAt(endPos) == '\n') endPos++;
startPos = endPos;
while (startPos < length && content.charAt(startPos) == ' ') {
startPos++;
}
}
if (font != "") spr.unloadFont();
}
}
}
void initSprite(TFT_eSprite &spr, int w, int h, imgParam &imageParams) {
spr.setColorDepth(16);
spr.createSprite(w, h);
if (spr.getPointer() == nullptr) {
wsErr("low on memory. Fallback to 8bpp");
util::printLargestFreeBlock();
spr.setColorDepth(8);
spr.createSprite(w, h);
}
if (spr.getPointer() == nullptr) {
wsErr("low on memory. Fallback to 1bpp");
util::printLargestFreeBlock();
spr.setColorDepth(1);
spr.setBitmapColor(TFT_WHITE, TFT_BLACK);
imageParams.bufferbpp = 1;
spr.createSprite(w, h);
}
if (spr.getPointer() == nullptr) {
wsErr("Failed to create sprite");
}
spr.setRotation(3);
spr.fillSprite(TFT_WHITE);
}
String utf8FromCodepoint(uint16_t cp) {
char buf[4] = {0};
if (cp < 0x80) {
buf[0] = cp;
} else if (cp < 0x800) {
buf[0] = 0xC0 | (cp >> 6);
buf[1] = 0x80 | (cp & 0x3F);
} else {
buf[0] = 0xE0 | ((cp >> 12) & 0x0F);
buf[1] = 0x80 | ((cp >> 6) & 0x3F);
buf[2] = 0x80 | (cp & 0x3F);
}
return String(buf);
}
String formatUtcToLocal(const String &s) {
int h, m, ss = 0;
if (sscanf(s.c_str(), "%d:%d:%d", &h, &m, &ss) < 2 || h > 23 || m > 59 || ss > 59)
return "-";
time_t n = time(nullptr);
struct tm tm = *localtime(&n);
tm.tm_hour = h;
tm.tm_min = m;
tm.tm_sec = ss;
time_t utc = mktime(&tm) - _timezone + (tm.tm_isdst ? 3600 : 0);
localtime_r(&utc, &tm);
char buf[6];
snprintf(buf, 6, "%02d:%02d", tm.tm_hour, tm.tm_min);
return buf;
}
void drawDate(String &filename, JsonObject &cfgobj, tagRecord *&taginfo, imgParam &imageParams) {
time_t now;
time(&now);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
const int month_number = timeinfo.tm_mon;
const int year_number = timeinfo.tm_year + 1900;
if (taginfo->hwType == SOLUM_SEG_UK) {
sprintf(imageParams.segments, "%2d%2d%-2.2s%04d", timeinfo.tm_mday, month_number + 1, languageDays[timeinfo.tm_wday], year_number);
imageParams.symbols = 0x04;
return;
}
JsonDocument loc;
getTemplate(loc, 1, taginfo->hwType);
TFT_eSprite spr = TFT_eSprite(&tft);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
auto date = loc["date"];
auto weekday = loc["weekday"];
const auto &month = loc["month"];
const auto &day = loc["day"];
if (cfgobj["location"]) {
const String lat = cfgobj["#lat"];
const String lon = cfgobj["#lon"];
JsonDocument doc;
const bool success = util::httpGetJson("https://api.farmsense.net/v1/daylengths/?d=" + String(now) + "&lat=" + lat + "&lon=" + lon + "&tz=UTC", doc, 5000);
if (success && loc["sunrise"].is<JsonArray>()) {
String sunrise = formatUtcToLocal(doc[0]["Sunrise"]);
String sunset = formatUtcToLocal(doc[0]["Sunset"]);
const auto &sunriseicon = loc["sunrise"];
const auto &sunseticon = loc["sunset"];
drawString(spr, String("\uF046 "), sunriseicon[0], sunriseicon[1].as<int>(), "/fonts/weathericons.ttf", TR_DATUM, TFT_BLACK, sunriseicon[3]);
drawString(spr, String("\uF047 "), sunseticon[0], sunseticon[1].as<int>(), "/fonts/weathericons.ttf", TR_DATUM, TFT_BLACK, sunseticon[3]);
drawString(spr, sunrise, sunriseicon[0], sunriseicon[1], sunriseicon[2], TL_DATUM, TFT_BLACK, sunriseicon[3]);
drawString(spr, sunset, sunseticon[0], sunseticon[1], sunseticon[2], TL_DATUM, TFT_BLACK, sunseticon[3]);
const bool success = util::httpGetJson("https://api.farmsense.net/v1/moonphases/?d=" + String(now), doc, 5000);
if (success && loc["moonicon"].is<JsonArray>()) {
uint8_t moonage = doc[0]["Index"].as<int>();
const auto &moonicon = loc["moonicon"];
uint16_t moonIconId = 0xf095 + moonage;
String moonIcon = utf8FromCodepoint(moonIconId);
drawString(spr, moonIcon, moonicon[0], moonicon[1], "/fonts/weathericons.ttf", TC_DATUM, TFT_BLACK, moonicon[2]);
}
date = loc["altdate"];
weekday = loc["altweekday"];
}
}
if (date.is<JsonArray>()) {
drawString(spr, languageDays[timeinfo.tm_wday], weekday[0], weekday[1], weekday[2], TC_DATUM, imageParams.highlightColor, weekday[3]);
drawString(spr, String(timeinfo.tm_mday) + " " + languageMonth[timeinfo.tm_mon], date[0], date[1], date[2], TC_DATUM, TFT_BLACK, date[3]);
} else {
drawString(spr, languageDays[timeinfo.tm_wday], weekday[0], weekday[1], weekday[2], TC_DATUM, TFT_BLACK, weekday[3]);
drawString(spr, String(languageMonth[timeinfo.tm_mon]), month[0], month[1], month[2], TC_DATUM, TFT_BLACK, month[3]);
drawString(spr, String(timeinfo.tm_mday), day[0], day[1], day[2], TC_DATUM, imageParams.highlightColor, day[3]);
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
void drawNumber(String &filename, int32_t count, int32_t thresholdred, tagRecord *&taginfo, imgParam &imageParams) {
int32_t countTemp = count;
count = abs(count);
if (taginfo->hwType == SOLUM_SEG_UK) {
imageParams.symbols = 0x00;
if (count > 19999) {
sprintf(imageParams.segments, "over flow");
return;
} else if (count > 9999) {
imageParams.symbols = 0x02;
sprintf(imageParams.segments, "%04d", count - 10000);
} else {
sprintf(imageParams.segments, "%4d", count);
}
if (taginfo->contentMode == 3) {
strcat(imageParams.segments, " hour");
} else {
strcat(imageParams.segments, " days");
}
return;
}
TFT_eSprite spr = TFT_eSprite(&tft);
JsonDocument loc;
getTemplate(loc, 2, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
uint16_t color = TFT_BLACK;
if (countTemp > thresholdred) {
color = imageParams.highlightColor;
}
String font = loc["fonts"][0].as<String>();
uint8_t size = loc["fonts"][1].as<uint16_t>();
if (count > 9) size = loc["fonts"][2].as<uint16_t>();
if (count > 99) size = loc["fonts"][3].as<uint16_t>();
if (count > 999) size = loc["fonts"][4].as<uint16_t>();
if (count > 9999) size = loc["fonts"][5].as<uint16_t>();
if (count > 99999) size = loc["fonts"][6].as<uint16_t>();
drawString(spr, String(count), loc["xy"][0].as<uint16_t>(), loc["xy"][1].as<uint16_t>() - size / 1.8, font, TC_DATUM, color, size);
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
/// @brief Get a weather icon
/// @param id Icon identifier/index
/// @param isNight Use night icons (true) or not (false)
/// @return String reference to icon
const String getWeatherIcon(const uint8_t id, const bool isNight = false) {
const String weatherIcons[] = {"\uf00d", "\uf00c", "\uf002", "\uf013", "\uf013", "\uf014", "", "", "\uf014", "", "",
"\uf01a", "", "\uf01a", "", "\uf01a", "\uf017", "\uf017", "", "", "",
"\uf019", "", "\uf019", "", "\uf019", "\uf015", "\uf015", "", "", "",
"\uf01b", "", "\uf01b", "", "\uf01b", "", "\uf076", "", "", "\uf01a",
"\uf01a", "\uf01a", "", "", "\uf064", "\uf064", "", "", "", "",
"", "", "", "", "\uf01e", "\uf01d", "", "", "\uf01e"};
if (isNight && id <= 2) {
const String nightIcons[] = {"\uf02e", "\uf083", "\uf086"};
return nightIcons[id];
}
return weatherIcons[id];
}
void drawWeatherContent(JsonDocument &doc, JsonDocument &loc, TFT_eSprite &spr, JsonObject &cfgobj, imgParam &imageParams, bool isForecast = false) {
const auto &currentWeather = doc["current_weather"];
const double temperature = currentWeather["temperature"].as<double>();
float windspeed = currentWeather["windspeed"].as<float>();
int windval = 0;
const int winddirection = currentWeather["winddirection"].as<int>();
const bool isNight = currentWeather["is_day"].as<int>() == 0;
uint8_t weathercode = currentWeather["weathercode"].as<int>();
if (weathercode > 40) weathercode -= 40;
const uint8_t beaufort = windSpeedToBeaufort(windspeed);
if (cfgobj["units"] != "1") {
windval = beaufort;
} else {
windval = int(windspeed);
}
if (!isForecast) {
const auto &location = loc["location"];
drawString(spr, cfgobj["location"], location[0], location[1], location[2]);
}
const auto &wind = isForecast ? loc["currentwind"] : loc["wind"];
drawString(spr, String(windval), wind[0], wind[1], wind[2], TR_DATUM, (beaufort > 4 ? imageParams.highlightColor : TFT_BLACK));
char tmpOutput[5];
dtostrf(temperature, 2, 1, tmpOutput);
const auto &temp = loc["temp"];
String temperatureStr = String(tmpOutput);
if (temp[3] && temp[3] == 1) {
temperatureStr += (cfgobj["units"] == "1") ? "°" : "°";
}
drawString(spr, temperatureStr, temp[0], temp[1], temp[2], TL_DATUM, (temperature < 0 ? imageParams.highlightColor : TFT_BLACK));
const int iconcolor = (weathercode == 55 || weathercode == 65 || weathercode == 75 || weathercode == 82 || weathercode == 86 || weathercode == 95 || weathercode == 96 || weathercode == 99)
? imageParams.highlightColor
: TFT_BLACK;
const auto &icon = isForecast ? loc["currenticon"] : loc["icon"];
drawString(spr, getWeatherIcon(weathercode, isNight), icon[0], icon[1], "/fonts/weathericons.ttf", icon[3], iconcolor, icon[2]);
const auto &dir = loc["dir"];
drawString(spr, windDirectionIcon(winddirection), dir[0], dir[1], "/fonts/weathericons.ttf", TC_DATUM, TFT_BLACK, dir[2]);
if (weathercode > 10) {
const auto &umbrella = loc["umbrella"];
drawString(spr, "\uf084", umbrella[0], umbrella[1], "/fonts/weathericons.ttf", TC_DATUM, imageParams.highlightColor, umbrella[2]);
}
}
void drawWeather(String &filename, JsonObject &cfgobj, const tagRecord *taginfo, imgParam &imageParams) {
wsLog("get weather");
getLocation(cfgobj);
const String lat = cfgobj["#lat"];
const String lon = cfgobj["#lon"];
const String tz = cfgobj["#tz"];
String units = "";
if (cfgobj["units"] == "1") {
units += "&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch";
}
JsonDocument doc;
const bool success = util::httpGetJson("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "&current_weather=true&windspeed_unit=ms&timezone=" + tz + units, doc, 5000);
if (!success) {
return;
}
JsonDocument loc;
getTemplate(loc, 4, taginfo->hwType);
TFT_eSprite spr = TFT_eSprite(&tft);
tft.setTextWrap(false, false);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
drawWeatherContent(doc, loc, spr, cfgobj, imageParams);
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
void drawForecast(String &filename, JsonObject &cfgobj, const tagRecord *taginfo, imgParam &imageParams) {
wsLog("get weather");
getLocation(cfgobj);
String lat = cfgobj["#lat"];
String lon = cfgobj["#lon"];
String tz = cfgobj["#tz"];
String units = "";
if (cfgobj["units"] == "1") {
units += "&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch";
}
JsonDocument doc;
const bool success = util::httpGetJson("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "&daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,winddirection_10m_dominant&current_weather=true&windspeed_unit=ms&timeformat=unixtime&timezone=" + tz + units, doc, 5000);
if (!success) {
return;
}
TFT_eSprite spr = TFT_eSprite(&tft);
tft.setTextWrap(false, false);
JsonDocument loc;
getTemplate(loc, 8, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
if (loc["temp"]) drawWeatherContent(doc, loc, spr, cfgobj, imageParams, true);
const auto &location = loc["location"];
drawString(spr, cfgobj["location"], location[0], location[1], location[2], TL_DATUM, TFT_BLACK);
const auto &daily = doc["daily"];
const auto &column = loc["column"];
const int column1 = column[1].as<int>();
const auto &day = loc["day"];
const unsigned long utc_offset = doc["utc_offset_seconds"];
for (uint8_t dag = 0; dag < column[0]; dag++) {
const time_t weatherday = (daily["time"][dag].as<time_t>() + utc_offset);
const struct tm *datum = localtime(&weatherday);
drawString(spr, String(languageDaysShort[datum->tm_wday]), dag * column1 + day[0].as<int>(), day[1], day[2], TC_DATUM, TFT_BLACK);
uint8_t weathercode = daily["weathercode"][dag].as<int>();
if (weathercode > 40) weathercode -= 40;
const int iconcolor = (weathercode == 55 || weathercode == 65 || weathercode == 75 || weathercode == 82 || weathercode == 86 || weathercode == 95 || weathercode == 96 || weathercode == 99)
? imageParams.highlightColor
: TFT_BLACK;
drawString(spr, getWeatherIcon(weathercode), loc["icon"][0].as<int>() + dag * column1, loc["icon"][1], "/fonts/weathericons.ttf", TC_DATUM, iconcolor, loc["icon"][2]);
drawString(spr, windDirectionIcon(daily["winddirection_10m_dominant"][dag]), loc["wind"][0].as<int>() + dag * column1, loc["wind"][1], "/fonts/weathericons.ttf", TC_DATUM, TFT_BLACK, loc["icon"][2]);
const int8_t tmin = round(daily["temperature_2m_min"][dag].as<double>());
const int8_t tmax = round(daily["temperature_2m_max"][dag].as<double>());
uint8_t wind;
const int8_t beaufort = windSpeedToBeaufort(daily["windspeed_10m_max"][dag].as<double>());
if (cfgobj["units"] == "1") {
wind = daily["windspeed_10m_max"][dag].as<int>();
} else {
wind = beaufort;
}
if (loc["rain"]) {
if (cfgobj["units"] == "0") {
const int8_t rain = round(daily["precipitation_sum"][dag].as<double>());
if (rain > 0) {
drawString(spr, String(rain) + "mm", dag * column1 + loc["rain"][0].as<int>(), loc["rain"][1], day[2], TC_DATUM, (rain > 10 ? imageParams.highlightColor : TFT_BLACK));
}
} else {
double fRain = daily["precipitation_sum"][dag].as<double>();
fRain = round(fRain * 100.0) / 100.0;
if (fRain > 0.0) {
// inch, display if > .01 inches
drawString(spr, String(fRain) + "in", dag * column1 + loc["rain"][0].as<int>(), loc["rain"][1], day[2], TC_DATUM, (fRain > 0.5 ? imageParams.highlightColor : TFT_BLACK));
}
}
}
drawString(spr, String(tmin) + " ", dag * column1 + day[0].as<int>(), day[4], day[2], TR_DATUM, (tmin < 0 ? imageParams.highlightColor : TFT_BLACK));
drawString(spr, String(" ") + String(tmax), dag * column1 + day[0].as<int>(), day[4], day[2], TL_DATUM, (tmax < 0 ? imageParams.highlightColor : TFT_BLACK));
drawString(spr, " " + String(wind), dag * column1 + column1 / 2, day[3], day[2], TL_DATUM, (beaufort > 5 ? imageParams.highlightColor : TFT_BLACK));
if (dag > 0) {
for (int i = loc["line"][0]; i < loc["line"][1]; i += 3) {
spr.drawPixel(dag * column1, i, TFT_BLACK);
}
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
int getImgURL(String &filename, String URL, time_t fetched, imgParam &imageParams, String MAC) {
// https://images.klari.net/kat-bw29.jpg
HTTPClient http;
logLine("http getImgURL " + URL);
http.begin(URL);
http.addHeader("If-Modified-Since", formatHttpDate(fetched));
http.addHeader("X-ESL-MAC", MAC);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(5000); // timeout in ms
const int httpCode = http.GET();
if (httpCode == 200) {
xSemaphoreTake(fsMutex, portMAX_DELAY);
File f = contentFS->open("/temp/temp.jpg", "w");
if (f) {
http.writeToStream(&f);
f.close();
xSemaphoreGive(fsMutex);
jpg2buffer("/temp/temp.jpg", filename, imageParams);
} else {
xSemaphoreGive(fsMutex);
}
} else {
if (httpCode != 304) {
wsErr("http " + URL + " " + String(httpCode));
}
}
http.end();
return httpCode;
}
#ifdef CONTENT_RSS
rssClass reader;
#endif
void replaceHTMLentities(String &text) {
text.replace("&gt;", ">");
text.replace("&lt;", "<");
text.replace("&quot;", "\"");
text.replace("&apos;", "'");
text.replace("&amp;", "&");
}
void removeHTML(String &text) {
int len = text.length();
bool insideTag = false;
int j = 0;
for (int i = 0; i < len; ++i) {
char c = text[i];
if (c == '<') {
insideTag = true;
} else if (c == '>') {
insideTag = false;
} else if (!insideTag) {
text[j++] = c;
}
}
text.remove(j);
}
void stampTime(TFT_eSprite &spr) {
time_t now;
time(&now);
char timeStr[24];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S ", localtime(&now));
drawString(spr, timeStr, spr.width() - 1, 12, "glasstown_nbp_tf", TR_DATUM, TFT_BLACK);
}
#ifdef CONTENT_RSS
bool getRssFeed(String &filename, String URL, String title, tagRecord *&taginfo, imgParam &imageParams) {
// https://github.com/garretlab/shoddyxml2
// http://feeds.feedburner.com/tweakers/nieuws
// https://www.nu.nl/rss/Algemeen
wsLog("get rss feed");
const char *url = URL.c_str();
const int rssTitleSize = 255;
const int rssDescSize = 1000;
TFT_eSprite spr = TFT_eSprite(&tft);
JsonDocument loc;
getTemplate(loc, 9, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
// stampTime(spr);
if (util::isEmptyOrNull(title)) title = "RSS feed";
drawString(spr, title, loc["title"][0], loc["title"][1], loc["title"][2], TL_DATUM, TFT_BLACK, loc["title"][3]);
int16_t posx = loc["line"][0];
int16_t posy = loc["line"][1];
int n = reader.getArticles(url, rssTitleSize, rssDescSize, loc["items"]);
float lineheight = loc["desc"][3].as<float>();
for (int i = 0; i < n; i++) {
// if (reader.titleData[i] != NULL && *reader.titleData[i] != NULL) {
if (reader.titleData[i] != NULL) {
String title = String(reader.titleData[i]);
replaceHTMLentities(title);
removeHTML(title);
if (!util::isEmptyOrNull(title)) {
drawTextBox(spr, title, posx, posy, imageParams.width - 2 * posx, 100, loc["line"][2], TFT_BLACK);
}
}
// if (reader.descData[i] != NULL && *reader.descData[i] != NULL) {
if (reader.descData[i] != NULL && loc["desc"][2] != "") {
String desc = String(reader.descData[i]);
replaceHTMLentities(desc);
removeHTML(desc);
if (!util::isEmptyOrNull(desc)) {
posy += loc["desc"][0].as<int>();
drawTextBox(spr, desc, posx, posy, imageParams.width - 2 * posx, 100, loc["desc"][2], TFT_BLACK, TFT_WHITE, lineheight);
posy += loc["desc"][1].as<int>();
}
} else {
posy += loc["desc"][1].as<int>();
}
}
reader.clearItemData();
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
return true;
}
#endif
char *epoch_to_display(time_t utc) {
static char display[6];
struct tm local_tm;
localtime_r(&utc, &local_tm);
time_t now;
time(&now);
struct tm now_tm;
localtime_r(&now, &now_tm);
if (local_tm.tm_year < now_tm.tm_year ||
(local_tm.tm_year == now_tm.tm_year && local_tm.tm_mon < now_tm.tm_mon) ||
(local_tm.tm_year == now_tm.tm_year && local_tm.tm_mon == now_tm.tm_mon && local_tm.tm_mday < now_tm.tm_mday) ||
(local_tm.tm_hour == 0 && local_tm.tm_min == 0) ||
difftime(utc, now) >= 86400) {
strftime(display, sizeof(display), languageDateFormat[1].c_str(), &local_tm);
} else {
strftime(display, sizeof(display), "%H:%M", &local_tm);
}
return display;
}
#ifdef CONTENT_CAL
bool getCalFeed(String &filename, JsonObject &cfgobj, tagRecord *&taginfo, imgParam &imageParams) {
// google apps scripts method to retrieve calendar
// see https://github.com/OpenEPaperLink/OpenEPaperLink/wiki/Google-Apps-Scripts for description
wsLog("get calendar");
JsonDocument loc;
getTemplate(loc, 11, taginfo->hwType);
String URL = cfgobj["apps_script_url"].as<String>() + "?days=" + loc["days"].as<String>();
time_t now;
time(&now);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
char dateString[40];
strftime(dateString, sizeof(dateString), languageDateFormat[0].c_str(), &timeinfo);
HTTPClient http;
// logLine("http getCalFeed " + URL);
http.begin(URL);
http.setTimeout(10000);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
int httpCode = http.GET();
if (httpCode != 200) {
wsErr("getCalFeed http error " + String(httpCode));
return false;
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString());
if (error) {
wsErr(error.c_str());
}
http.end();
TFT_eSprite spr = TFT_eSprite(&tft);
if (loc["rotate"] == 1) {
int temp = imageParams.height;
imageParams.height = imageParams.width;
imageParams.width = temp;
imageParams.rotatebuffer = 1 - (imageParams.rotatebuffer % 2);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
} else {
initSprite(spr, imageParams.width, imageParams.height, imageParams);
}
switch (loc["mode"].as<int>()) {
case 0: {
// appointment list
String title = cfgobj["title"];
if (util::isEmptyOrNull(title)) title = "Calendar";
drawString(spr, title, loc["title"][0], loc["title"][1], loc["title"][2], TL_DATUM, TFT_BLACK);
drawString(spr, dateString, loc["date"][0], loc["date"][1], loc["title"][2], TR_DATUM, TFT_BLACK);
int n = doc.size();
if (n > loc["items"]) n = loc["items"];
for (int i = 0; i < n; i++) {
const JsonObject &obj = doc[i];
const String eventtitle = obj["title"];
const time_t starttime = obj["start"];
const time_t endtime = obj["end"];
if (starttime <= now && endtime > now) {
spr.fillRect(loc["red"][0], loc["red"][1].as<int>() + i * loc["line"][2].as<int>(), loc["red"][2], loc["red"][3], imageParams.highlightColor);
drawString(spr, epoch_to_display(obj["start"]), loc["line"][0], loc["line"][1].as<int>() + i * loc["line"][2].as<int>(), loc["line"][3], TL_DATUM, TFT_WHITE, 0, imageParams.highlightColor);
drawString(spr, eventtitle, loc["line"][4], loc["line"][1].as<int>() + i * loc["line"][2].as<int>(), loc["line"][3], TL_DATUM, TFT_WHITE, 0, imageParams.highlightColor);
} else {
drawString(spr, epoch_to_display(obj["start"]), loc["line"][0], loc["line"][1].as<int>() + i * loc["line"][2].as<int>(), loc["line"][3], TL_DATUM, TFT_BLACK, 0, TFT_WHITE);
drawString(spr, eventtitle, loc["line"][4], loc["line"][1].as<int>() + i * loc["line"][2].as<int>(), loc["line"][3], TL_DATUM, TFT_BLACK, 0, TFT_WHITE);
}
}
break;
}
case 1: {
#ifdef CONTENT_BIGCAL
// week view
// gridparam:
// 0: 10; offset top
// 1: 20; offset cal block
// 2: 30; offset left
// 3: calibrib16.vlw; headers font
// 4: tahoma9.vlw; appointments font
// 5: 14; line height
timeinfo.tm_hour = 0;
timeinfo.tm_min = 0;
timeinfo.tm_sec = 0;
time_t midnightEpoch = mktime(&timeinfo);
int calWidth = imageParams.width - 1;
int calHeight = imageParams.height;
int calDays = loc["days"];
int colLeft = loc["gridparam"][2].as<int>();
int colWidth = (calWidth - colLeft) / calDays;
int calTop = loc["gridparam"][0].as<int>();
int calBottom = calHeight;
int calYOffset = loc["gridparam"][1].as<int>();
int lineHeight = loc["gridparam"][5].as<int>();
uint16_t backgroundLight = getColor("lightgray");
uint16_t backgroundDark = getColor("darkgray");
if (imageParams.hwdata.bpp >= 3) {
backgroundLight = getColor("#BEFAFF");
backgroundDark = getColor("#79FAFF");
}
// drawString(spr, String(timeinfo.tm_mday), calWidth / 2, -calHeight/5, "Signika-SB.ttf", TC_DATUM, imageParams.highlightColor, calHeight * 1.2);
for (int i = 0; i < calDays; i++) {
struct tm dayTimeinfo = *localtime(&midnightEpoch);
dayTimeinfo.tm_mday += i;
time_t dayEpoch = mktime(&dayTimeinfo);
struct tm *dayInfo = localtime(&dayEpoch);
int colStart = colLeft + i * colWidth;
spr.drawLine(colStart, calTop, colStart, calBottom, TFT_BLACK);
drawString(spr, String(languageDaysShort[dayInfo->tm_wday]) + " " + String(dayInfo->tm_mday), colStart + colWidth / 2, calTop, loc["gridparam"][3], TC_DATUM, TFT_BLACK);
if (dayInfo->tm_wday == 0 || dayInfo->tm_wday == 6) {
spr.fillRect(colStart + 1, calTop + calYOffset, colWidth - 1, calHeight - 1, backgroundDark);
} else {
spr.fillRect(colStart + 1, calTop + calYOffset, colWidth - 1, calHeight - 1, backgroundLight);
}
}
int colStart = colLeft + calDays * colWidth;
spr.drawLine(colStart, calTop, colStart, calBottom, TFT_BLACK);
spr.drawLine(0, calTop + calYOffset, calWidth, calTop + calYOffset, TFT_BLACK);
int minHour = 9;
int maxHour = 17;
int n = doc.size();
int maxBlock = 0;
int *block = new int[n];
for (int i = 0; i < n; i++) {
const JsonObject &obj = doc[i];
String eventtitle = obj["title"];
const time_t startdatetime = obj["start"];
const time_t enddatetime = obj["end"];
const bool isallday = obj["isallday"];
const int calendarId = obj["calendar"];
if (!isallday) {
localtime_r(&startdatetime, &timeinfo);
if (timeinfo.tm_hour < minHour) minHour = timeinfo.tm_hour;
localtime_r(&enddatetime, &timeinfo);
if (timeinfo.tm_hour > maxHour) maxHour = timeinfo.tm_hour;
if (timeinfo.tm_min > 1 && timeinfo.tm_hour + 1 > maxHour) maxHour = timeinfo.tm_hour + 1;
} else {
int fulldaystart = constrain((startdatetime - midnightEpoch) / (24 * 3600), 0, calDays);
int fulldayend = constrain((enddatetime - midnightEpoch) / (24 * 3600), 0, calDays);
if (fulldaystart < calDays) {
int line = 1;
bool overlap = false;
do {
overlap = false;
for (int j = 0; j < i; j++) {
const JsonObject &obj2 = doc[j];
const bool isallday2 = obj2["isallday"];
const time_t startdatetime2 = obj2["start"];
const time_t enddatetime2 = obj2["end"];
if (startdatetime < enddatetime2 && enddatetime > startdatetime2 &&
line == block[j] && isallday2) {
overlap == true;
line++;
}
}
} while (overlap == true);
int16_t eventX = colLeft + fulldaystart * colWidth + 3;
int16_t eventY = calTop + calYOffset + (line - 1) * lineHeight + 3;
uint16_t background = TFT_WHITE;
uint16_t border = TFT_BLACK;
uint16_t textcolor = TFT_BLACK;
if (loc["colors1"].is<JsonArray>() && loc["colors1"].size() > calendarId) {
background = getColor(loc["colors1"][calendarId]);
border = getColor(loc["colors2"][calendarId]);
if (loc["colors3"][calendarId]) textcolor = getColor(loc["colors3"][calendarId]);
}
spr.fillRect(eventX - 1, eventY - 2, colWidth * (fulldayend - fulldaystart) - 3, lineHeight - 1, background);
spr.drawRect(eventX - 2, eventY - 3, colWidth * (fulldayend - fulldaystart) - 1, lineHeight + 1, border);
drawTextBox(spr, eventtitle, eventX, eventY, colWidth * (fulldayend - fulldaystart) - 3, 15, loc["gridparam"][4], textcolor);
block[i] = line;
if (line > maxBlock) maxBlock = line;
}
}
const int starttime = timeinfo.tm_hour * 60 + timeinfo.tm_min;
int hours = timeinfo.tm_hour;
int minutes = timeinfo.tm_min;
}
calYOffset += maxBlock * lineHeight;
spr.drawLine(0, calTop + calYOffset, calWidth, calTop + calYOffset, TFT_BLACK);
int hourHeight = (calHeight - calYOffset - calTop) / (maxHour - minHour);
for (int i = 0; i < (maxHour - minHour); i++) {
spr.drawLine(0, calTop + calYOffset + i * hourHeight, colLeft + 10, calTop + calYOffset + i * hourHeight, TFT_BLACK);
for (int j = 1; j <= calDays; j++) {
spr.drawLine(colLeft + j * colWidth - 5, calTop + calYOffset + i * hourHeight, colLeft + j * colWidth + 5, calTop + calYOffset + i * hourHeight, TFT_BLACK);
}
drawString(spr, String(minHour + i), colLeft - 2, calTop + calYOffset + i * hourHeight + 2, loc["gridparam"][3], TR_DATUM, TFT_BLACK);
}
spr.drawLine(0, calTop + calYOffset + (maxHour - minHour) * hourHeight, calWidth, calTop + calYOffset + (maxHour - minHour) * hourHeight, TFT_BLACK);
for (int i = 0; i < n; i++) {
const JsonObject &obj = doc[i];
String eventtitle = obj["title"];
const time_t startdatetime = obj["start"];
const time_t enddatetime = obj["end"];
const bool isallday = obj["isallday"];
const int calendarId = obj["calendar"];
if (!isallday) {
int fulldaystart = constrain((startdatetime - midnightEpoch) / (24 * 3600), 0, calDays);
int fulldayend = constrain((enddatetime - midnightEpoch) / (24 * 3600), 0, calDays);
for (int day = fulldaystart; day <= fulldayend; day++) {
localtime_r(&startdatetime, &timeinfo);
int starttime = timeinfo.tm_hour * 60 + timeinfo.tm_min - minHour * 60;
int duration = (enddatetime - startdatetime) / 60;
if (day > fulldaystart) {
starttime = 0;
localtime_r(&enddatetime, &timeinfo);
duration = timeinfo.tm_hour * 60 + timeinfo.tm_min - minHour * 60;
}
char formattedTime[6];
strftime(formattedTime, sizeof(formattedTime), "%H:%M", &timeinfo);
String formattedTimeString = String(formattedTime);
int indent = 1;
bool overlap = false;
do {
overlap = false;
for (int j = 0; j < i; j++) {
const JsonObject &obj2 = doc[j];
const bool isallday2 = obj2["isallday"];
const time_t startdatetime2 = obj2["start"];
const time_t enddatetime2 = obj2["end"];
if (startdatetime < enddatetime2 && enddatetime > startdatetime2 &&
indent == block[j] && isallday2 == false) {
overlap == true;
indent++;
}
}
} while (overlap == true);
block[i] = indent;
int16_t eventX = colLeft + day * colWidth + (indent - 1) * 5;
int16_t eventY = calTop + calYOffset + (starttime * hourHeight / 60);
uint16_t background = TFT_WHITE;
uint16_t border = TFT_BLACK;
uint16_t textcolor = TFT_BLACK;
if (loc["colors1"].is<JsonArray>() && loc["colors1"].size() > calendarId) {
background = getColor(loc["colors1"][calendarId]);
border = getColor(loc["colors2"][calendarId]);
if (loc["colors3"][calendarId]) textcolor = getColor(loc["colors3"][calendarId]);
}
spr.fillRect(eventX + 2, eventY + 1, colWidth - 3, (duration * hourHeight / 60) - 1, background);
spr.drawRect(eventX + 1, eventY, colWidth - 1, (duration * hourHeight / 60) + 1, border);
eventX += 2;
eventY += 2;
if (day == fulldaystart) {
eventtitle = formattedTimeString + " " + String(eventtitle);
drawTextBox(spr, formattedTimeString, eventX, eventY, colWidth - 1, (duration * hourHeight / 60) - 1, loc["gridparam"][4], textcolor, TFT_WHITE, 1);
eventX++;
eventY = calTop + calYOffset + (starttime * hourHeight / 60) + 2;
} else {
eventtitle = obj["title"].as<String>();
}
drawTextBox(spr, eventtitle, eventX, eventY, colWidth - 1, (duration * hourHeight / 60) - 1, loc["gridparam"][4], textcolor, TFT_WHITE, 1);
}
}
}
delete[] block;
#endif
break;
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
return true;
}
#endif
#ifdef CONTENT_DAYAHEAD
uint16_t getPercentileColor(const double *prices, int numPrices, double price, HwType hwdata) {
const char *colorsDefault[] = {"black", "darkgray", "pink", "red"};
const double boundariesDefault[] = {40.0, 80.0, 90.0};
const char *colors3bpp[] = {"blue", "green", "yellow", "orange", "red"};
const double boundaries3bpp[] = {20.0, 50.0, 70.0, 90.0};
const char **colors;
const double *boundaries;
int numColors, numBoundaries;
if (hwdata.bpp == 3 || hwdata.bpp == 4) {
colors = colors3bpp;
boundaries = boundaries3bpp;
numColors = sizeof(colors3bpp) / sizeof(colors3bpp[0]);
numBoundaries = sizeof(boundaries3bpp) / sizeof(boundaries3bpp[0]);
} else {
colors = colorsDefault;
boundaries = boundariesDefault;
numColors = sizeof(colorsDefault) / sizeof(colorsDefault[0]);
numBoundaries = sizeof(boundariesDefault) / sizeof(boundariesDefault[0]);
if (hwdata.highlightColor == 3) {
colors[2] = "brown";
colors[3] = "yellow";
}
}
int colorIndex = numColors - 1;
for (int i = 0; i < numBoundaries; i++) {
if (price < prices[int(numPrices * boundaries[i] / 100.0)]) {
colorIndex = i;
break;
}
}
return getColor(String(colors[constrain(colorIndex, 0, numColors - 1)]));
}
struct YAxisScale {
double min;
double max;
double step;
};
double roundToNearest(double value, double factor) {
return round(value / factor) * factor;
}
int mapDouble(double value, double fromMin, double fromMax, int toMin, int toMax) {
return static_cast<int>(round((value - fromMin) / (fromMax - fromMin) * (toMax - toMin) + toMin));
}
YAxisScale calculateYAxisScale(double priceMin, double priceMax, int divisions) {
double range = priceMax - priceMin;
double stepSize = range / divisions;
double orderOfMagnitude = pow(10, floor(log10(stepSize)));
double significantDigit = stepSize / orderOfMagnitude;
double roundedSignificantDigit;
double thresholds[] = {1.0, 2.0, 5.0, 10.0};
for (double threshold : thresholds) {
if (significantDigit <= threshold) {
roundedSignificantDigit = threshold;
break;
}
}
double roundedStepSize = roundedSignificantDigit * orderOfMagnitude;
double newRange = roundedStepSize * divisions;
double minY = floor(priceMin / roundedStepSize) * roundedStepSize;
double maxY = minY + newRange;
return {minY, maxY, roundedStepSize};
}
// Helper function: calculate data range and align to interval boundaries
// Returns: DataRange struct
struct DataRange {
int startIndex;
int endIndex;
int numAveragedSamples;
int availableSamples;
};
DataRange calculateDataRangeAndAlignment(const JsonDocument& doc, int originalDataSize, time_t now,
int avgFactor, int targetIntervalMinutes, int barAreaWidth) {
DataRange result;
// Step 1: Find data range - start at now-1h, trim far future if needed
time_t targetStart = now - 3600; // 1 hour ago
// Find first data point at/before now-1h
result.startIndex = 0;
for (int i = originalDataSize - 1; i >= 0; i--) {
time_t dataTime = doc[i]["time"];
if (dataTime <= targetStart) {
result.startIndex = i;
break;
}
}
// Calculate maximum bars that can fit on display
int maxBars = barAreaWidth; // 1px per bar minimum
// Calculate how many samples we'll have after averaging
result.availableSamples = originalDataSize - result.startIndex;
result.numAveragedSamples = result.availableSamples / avgFactor;
// Step 2: Trim from far future if needed (prioritize now and near future)
result.endIndex = originalDataSize;
if (result.numAveragedSamples > maxBars) {
// Too many samples - limit the range
int maxSamplesNeeded = maxBars * avgFactor;
result.endIndex = result.startIndex + maxSamplesNeeded;
if (result.endIndex > originalDataSize) result.endIndex = originalDataSize;
result.availableSamples = result.endIndex - result.startIndex;
result.numAveragedSamples = result.availableSamples / avgFactor;
}
// Step 3: Align to interval boundary if averaging
if (avgFactor > 1) {
// Find first data point that aligns with target interval
for (int i = result.startIndex; i < result.endIndex; i++) {
time_t dataTime = doc[i]["time"];
struct tm dt;
localtime_r(&dataTime, &dt);
// Check if this timestamp aligns with target interval
if (targetIntervalMinutes == 30) {
if (dt.tm_min == 0 || dt.tm_min == 30) {
result.startIndex = i;
break;
}
} else if (targetIntervalMinutes == 60) {
if (dt.tm_min == 0) {
result.startIndex = i;
break;
}
}
}
// Recalculate after alignment
result.availableSamples = result.endIndex - result.startIndex;
result.numAveragedSamples = result.availableSamples / avgFactor;
}
return result;
}
// Helper function: Perform historic data backfill to optimize bar width
// Returns: BackfillResult struct
struct BackfillResult {
int startIndex;
int numAveragedSamples;
int availableSamples;
int addedHistoricCount;
double barwidthBefore;
double barwidthAfter;
};
BackfillResult performHistoricBackfill(int startIndex, int endIndex, int numAveragedSamples,
int avgFactor, int barAreaWidth, double targetBarwidth) {
BackfillResult result;
result.startIndex = startIndex;
result.numAveragedSamples = numAveragedSamples;
result.availableSamples = endIndex - startIndex;
result.addedHistoricCount = 0;
int futureRawCount = endIndex - startIndex; // Raw samples (before averaging)
int historicRawCount = startIndex; // Raw samples available before startIndex
// Calculate barwidth BEFORE backfill
result.barwidthBefore = (double)barAreaWidth / numAveragedSamples;
if (historicRawCount > 0) {
// Calculate current bar width
double pixelsPerBar = (double)barAreaWidth / numAveragedSamples;
int currentSpacing = (pixelsPerBar >= 3.0) ? 1 : 0;
double currentBarWidth = pixelsPerBar - currentSpacing;
// Scenario 2: Bars > 5px target - add data to reduce toward target
if (currentBarWidth > targetBarwidth) {
// Target: 5px bars with 1px spacing = 6px total per bar
int targetBars = barAreaWidth / (targetBarwidth + 1.0);
int barsToAdd = targetBars - numAveragedSamples;
if (barsToAdd > 0) {
int samplesToAdd = barsToAdd * avgFactor;
result.addedHistoricCount = std::min(samplesToAdd, historicRawCount);
result.startIndex -= result.addedHistoricCount;
// Bounds safety: ensure startIndex never goes negative
if (result.startIndex < 0) {
result.addedHistoricCount += result.startIndex; // Reduce by overflow amount
result.startIndex = 0;
}
result.availableSamples = endIndex - result.startIndex;
result.numAveragedSamples = result.availableSamples / avgFactor;
}
}
// Scenario 1: Bars <= 5px - fill empty space with historic data
else {
double currentPixelsPerBar = pixelsPerBar; // Use pixelsPerBar (not barwidth)
int currentPixelsInt = (int)currentPixelsPerBar; // e.g., 3 from 3.05px
// Keep adding bars as long as pixelsPerBar doesn't drop below next integer
while (historicRawCount > 0) {
// Try adding avgFactor more raw samples (= 1 more bar after averaging)
int testAveragedBars = result.numAveragedSamples + 1;
double testPixelsPerBar = (double)barAreaWidth / testAveragedBars;
int testPixelsInt = (int)testPixelsPerBar;
// Check if pixelsPerBar stays at or above current integer value
// This ensures bars don't shrink (e.g., stay at 3px, don't drop to 2px)
if (testPixelsInt >= currentPixelsInt) {
// Pixels per bar still >= current integer - add this bar
result.numAveragedSamples = testAveragedBars;
historicRawCount -= avgFactor; // Consume raw samples
result.addedHistoricCount += avgFactor;
result.startIndex -= avgFactor; // Move start backwards into historic data
// Bounds safety: ensure startIndex never goes negative
if (result.startIndex < 0) {
result.addedHistoricCount += result.startIndex; // Reduce by overflow amount
result.numAveragedSamples = (endIndex - 0) / avgFactor; // Recalculate with startIndex=0
result.startIndex = 0;
break; // Stop backfill
}
} else {
// Next bar would drop below current integer, done
break;
}
}
// Update availableSamples after backfill
if (result.addedHistoricCount > 0) {
result.availableSamples = endIndex - result.startIndex;
}
}
}
// Calculate barwidth after backfill
result.barwidthAfter = (double)barAreaWidth / result.numAveragedSamples;
return result;
}
// Data structure for averaged price points
struct AveragedDataPoint {
time_t time;
double price;
int hour;
int minute;
};
// Helper function: Find cheapest consecutive blocks for appliance scheduling
// Returns: CheapBlock struct
struct CheapBlock {
int start;
int end;
};
struct CheapBlocks {
CheapBlock block1;
CheapBlock block2;
};
CheapBlocks findCheapestConsecutiveBlocks(const AveragedDataPoint* avgData, int numSamples,
int blockSamples, time_t now) {
CheapBlocks result;
result.block1.start = -1;
result.block1.end = -1;
result.block2.start = -1;
result.block2.end = -1;
if (blockSamples <= 0 || blockSamples > numSamples) {
return result; // Invalid or disabled
}
// Find all candidate blocks (simple: sum all prices in each block)
struct BlockCandidate {
int start;
int end;
double sum;
};
std::vector<BlockCandidate> candidates;
// Scan through visible data to find all valid consecutive blocks
for (int i = 0; i <= numSamples - blockSamples; i++) {
const time_t block_start_time = avgData[i].time;
// Only consider future blocks (skip past data)
if (block_start_time < now) continue;
// Calculate sum for this block
double blockSum = 0;
for (int j = 0; j < blockSamples; j++) {
blockSum += avgData[i + j].price;
}
BlockCandidate candidate;
candidate.start = i;
candidate.end = i + blockSamples - 1;
candidate.sum = blockSum;
candidates.push_back(candidate);
}
// Sort candidates by sum (cheapest first)
std::sort(candidates.begin(), candidates.end(),
[](const BlockCandidate& a, const BlockCandidate& b) { return a.sum < b.sum; });
// Pick up to 2 non-overlapping cheap blocks
if (candidates.size() > 0) {
result.block1.start = candidates[0].start;
result.block1.end = candidates[0].end;
// Find second block that doesn't overlap with first
for (size_t i = 1; i < candidates.size(); i++) {
bool overlaps = (candidates[i].start <= result.block1.end && candidates[i].end >= result.block1.start);
if (!overlaps) {
result.block2.start = candidates[i].start;
result.block2.end = candidates[i].end;
break;
}
}
}
return result;
}
bool getDayAheadFeed(String& filename, JsonObject& cfgobj, tagRecord*& taginfo, imgParam& imageParams) {
wsLog("get dayahead prices");
// Magic number constants for bar width calculations
const double TARGET_BARWIDTH = 5.0; // Optimal bar width in pixels (good visibility)
const double MIN_BARWIDTH = 2.0; // Minimum bar width (below this, bars hard to see)
const int MIN_LABEL_SPACING = 30; // Minimum pixels between label centers
const int DASH_LENGTH = 3; // Dashed line segment length
const int GAP_LENGTH = 2; // Gap between dashed line segments
const int STEM_WIDTH = 3; // Current time arrow stem width
const int ARROW_WIDTH = 8; // Current time arrow head width
const int ARROW_HEIGHT = 6; // Current time arrow head height
const int STEM_HEIGHT = 10; // Current time arrow stem height
const int GAP_AFTER_ARROW = 2; // Gap between arrow tip and vertical line
JsonDocument loc;
getTemplate(loc, 27, taginfo->hwType);
// This is a link to a Google Apps Script script, which fetches (and caches) the tariff from https://transparency.entsoe.eu/
// I made it available to provide easy access to the data, but please don't use this link in any projects other than OpenEpaperLink.
String URL = "https://script.google.com/macros/s/AKfycbwMmeGAaPrWzVZrESSpmPmD--O132PzW_acnBsuEottKNATTqCRn6h8zN0Yts7S56ggsg/exec?country=" + cfgobj["country"].as<String>();
time_t now;
time(&now);
HTTPClient http;
http.begin(URL);
http.setTimeout(10000);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
int httpCode = http.GET();
if (httpCode != 200) {
wsErr("getDayAhead http error " + String(httpCode));
return false;
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString());
if (error) {
wsErr(error.c_str());
}
http.end();
TFT_eSprite spr = TFT_eSprite(&tft);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
int originalDataSize = doc.size();
if (originalDataSize == 0) {
wsErr("No data in dayahead feed");
return false;
}
// Detect native data interval (15-min, hourly, etc.)
int nativeIntervalMinutes = 15; // Default to 15 minutes (most common in Europe)
if (originalDataSize >= 2) {
time_t time0 = doc[0]["time"];
time_t time1 = doc[1]["time"];
nativeIntervalMinutes = (time1 - time0) / 60;
}
// Get user's preferred display interval (0 = native, 30 = 30 minutes, 60 = 1 hour)
int targetIntervalMinutes = cfgobj["interval"] ? cfgobj["interval"].as<int>() : 0;
if (targetIntervalMinutes == 0) {
targetIntervalMinutes = nativeIntervalMinutes; // Use native interval
}
// Calculate averaging factor (how many native samples per target interval)
int avgFactor = targetIntervalMinutes / nativeIntervalMinutes;
if (avgFactor < 1) avgFactor = 1; // Safety: no upsampling
// Get bar area width for calculations
int barAreaWidth = loc["bars"][1].as<int>();
// Store RAW current price BEFORE averaging (for accurate "now" display)
// We'll calculate this after we have units/tariff variables below
double rawCurrentPrice = std::numeric_limits<double>::quiet_NaN();
int rawCurrentIndex = -1;
int rawCurrentHour = 0; // Hour from the native interval containing "now"
int rawCurrentMinute = 0; // Minute from the native interval containing "now"
// Calculate data range and align to interval boundaries (Steps 1-3)
DataRange range = calculateDataRangeAndAlignment(doc, originalDataSize, now, avgFactor,
targetIntervalMinutes, barAreaWidth);
int startIndex = range.startIndex;
int endIndex = range.endIndex;
int numAveragedSamples = range.numAveragedSamples;
int availableSamples = range.availableSamples;
// Safety check
if (numAveragedSamples == 0) {
wsErr("Not enough data for selected interval");
return false;
}
// Perform historic data backfill to optimize bar width (Step 4)
BackfillResult backfill = performHistoricBackfill(startIndex, endIndex, numAveragedSamples,
avgFactor, barAreaWidth, TARGET_BARWIDTH);
startIndex = backfill.startIndex;
numAveragedSamples = backfill.numAveragedSamples;
availableSamples = backfill.availableSamples;
// Debug logging (always visible in web console)
int futureRawCount = endIndex - (startIndex + backfill.addedHistoricCount);
int historicRawCount = startIndex + backfill.addedHistoricCount;
wsLog("Day-ahead bar sizing: future_raw=" + String(futureRawCount) +
" (incl now), historic_raw=" + String(historicRawCount) +
", barwidth_before=" + String(backfill.barwidthBefore, 1) +
"px, added_historic=" + String(backfill.addedHistoricCount) +
", barwidth_after=" + String(backfill.barwidthAfter, 1) +
"px (target=" + String(TARGET_BARWIDTH, 1) + "px)");
int units = cfgobj["units"].as<int>();
if (units == 0) units = 1;
double tarifkwh;
double tariftax = cfgobj["tarifftax"].as<double>();
// Create averaged data array
AveragedDataPoint avgData[numAveragedSamples];
// Parse tariff array if provided
JsonDocument doc2;
JsonArray tariffArray;
std::string tariffString = cfgobj["tariffkwh"].as<std::string>();
if (tariffString.front() == '[') {
if (deserializeJson(doc2, tariffString) == DeserializationError::Ok) {
tariffArray = doc2.as<JsonArray>();
} else {
Serial.println("Error in tariffkwh array");
}
}
// Average the data according to selected interval
for (int i = 0; i < numAveragedSamples; i++) {
double priceSum = 0;
time_t blockTime = doc[startIndex + i * avgFactor]["time"];
// Average prices across the interval
for (int j = 0; j < avgFactor; j++) {
int idx = startIndex + i * avgFactor + j;
if (idx >= endIndex) break; // Safety check - respect endIndex
const JsonObject& obj = doc[idx];
const time_t item_time = obj["time"];
struct tm item_timeinfo;
localtime_r(&item_time, &item_timeinfo);
if (tariffArray.size() == 24) {
tarifkwh = tariffArray[item_timeinfo.tm_hour].as<double>();
} else {
tarifkwh = cfgobj["tariffkwh"].as<double>();
}
double price = (obj["price"].as<double>() / 10 + tarifkwh) * (1 + tariftax / 100) / units;
priceSum += price;
}
// Store averaged data
avgData[i].price = priceSum / avgFactor;
avgData[i].time = blockTime;
struct tm blockTimeInfo;
localtime_r(&blockTime, &blockTimeInfo);
avgData[i].hour = blockTimeInfo.tm_hour;
avgData[i].minute = blockTimeInfo.tm_min;
}
// Now work with averaged data (n becomes numAveragedSamples)
int n = numAveragedSamples;
// Calculate RAW current price (find closest PAST/CURRENT data point, never future)
if (std::isnan(rawCurrentPrice)) {
time_t closestTimeDiff = std::numeric_limits<time_t>::max();
for (int i = 0; i < originalDataSize; i++) {
time_t dataTime = doc[i]["time"];
// Skip future timestamps - only consider past and current
if (dataTime > now) continue;
time_t diff = now - dataTime;
if (diff < closestTimeDiff) {
closestTimeDiff = diff;
rawCurrentIndex = i;
// Calculate raw price with tariff (same logic as averaging loop)
struct tm item_timeinfo;
localtime_r(&dataTime, &item_timeinfo);
if (tariffArray.size() == 24) {
tarifkwh = tariffArray[item_timeinfo.tm_hour].as<double>();
} else {
tarifkwh = cfgobj["tariffkwh"].as<double>();
}
rawCurrentPrice = (doc[i]["price"].as<double>() / 10 + tarifkwh) * (1 + tariftax / 100) / units;
rawCurrentHour = item_timeinfo.tm_hour; // Store the native interval's hour
rawCurrentMinute = item_timeinfo.tm_min; // Store the native interval's minute
}
}
}
// Calculate min/max and create sorted price array for percentiles
double minPrice = std::numeric_limits<double>::max();
double maxPrice = std::numeric_limits<double>::lowest();
double prices[n];
for (int i = 0; i < n; i++) {
prices[i] = avgData[i].price;
minPrice = std::min(minPrice, prices[i]);
maxPrice = std::max(maxPrice, prices[i]);
}
std::sort(prices, prices + n);
YAxisScale yAxisScale = calculateYAxisScale(minPrice, maxPrice, loc["yaxis"][2].as<int>());
minPrice = yAxisScale.min;
uint16_t yAxisX = loc["yaxis"][1].as<int>();
uint16_t yAxisY = loc["yaxis"][3].as<int>() | 9;
uint16_t barBottom = loc["bars"][3].as<int>();
for (double i = minPrice; i <= maxPrice; i += yAxisScale.step) {
int y = mapDouble(i, minPrice, maxPrice, spr.height() - barBottom, spr.height() - barBottom - loc["bars"][2].as<int>());
spr.drawLine(0, y, spr.width(), y, TFT_BLACK);
if (loc["yaxis"][0]) {
String label = (maxPrice * units < 10) ? String(i * units, 1) : String(int(i * units));
drawString(spr, label, yAxisX, y - yAxisY, loc["yaxis"][0], TL_DATUM, TFT_BLACK);
}
}
// Step 4: Calculate optimal bar width and spacing
// Goal: Fill entire width with bars, using spacing when room allows
int availableWidth = loc["bars"][1].as<int>();
int pixelsPerBar = availableWidth / n; // How many pixels each bar+spacing can use
int barwidth;
int barSpacing;
if (pixelsPerBar >= 3) {
// Room for spacing - use 1px gap between bars
barSpacing = 1;
barwidth = pixelsPerBar - barSpacing; // Remaining pixels for bar itself
} else {
// Tight fit - no room for spacing
barSpacing = 0;
barwidth = pixelsPerBar; // Use all available pixels for bar
}
// Result: n × (barwidth + barSpacing) ≈ availableWidth (within rounding)
uint16_t barheight = loc["bars"][2].as<int>() / (maxPrice - minPrice);
uint16_t arrowY = 0;
if (loc["bars"].size() >= 5) arrowY = loc["bars"][4].as<int>();
uint16_t barX = loc["bars"][0].as<int>();
double pricenow = std::numeric_limits<double>::quiet_NaN();
bool showcurrent = true;
if (cfgobj["showcurr"] && cfgobj["showcurr"] == "0") showcurrent = false;
// Calculate label interval based on display width and number of bars
// Goal: Space labels far enough apart to avoid overlap
// Note: availableWidth already declared above, reuse it here
int maxLabels = availableWidth / MIN_LABEL_SPACING;
int labelInterval = (n + maxLabels - 1) / maxLabels; // Round up division
// Ensure labelInterval is at least 1
if (labelInterval < 1) labelInterval = 1;
// For better aesthetics, round to nearest multiple based on target interval
int labelsPerHour = 60 / targetIntervalMinutes;
if (labelsPerHour > 0) {
// Try to show labels at nice intervals (every hour, every 2 hours, etc.)
int hoursPerLabel = (labelInterval + labelsPerHour - 1) / labelsPerHour; // Round up
if (hoursPerLabel < 1) hoursPerLabel = 1;
labelInterval = hoursPerLabel * labelsPerHour;
// But don't exceed our max labels constraint
if (n / labelInterval > maxLabels) {
// Fall back to spacing-based calculation
labelInterval = (n + maxLabels - 1) / maxLabels;
}
}
// Find cheapest consecutive block(s) for appliance scheduling (Step 5)
double cheapBlockHoursRaw = cfgobj["cheapblock"] ? cfgobj["cheapblock"].as<double>() : 3.0;
int blockSamples = (int)(cheapBlockHoursRaw * labelsPerHour); // Convert hours to sample count
CheapBlocks cheapBlocks = findCheapestConsecutiveBlocks(avgData, n, blockSamples, now);
int cheapBlockStart1 = cheapBlocks.block1.start;
int cheapBlockEnd1 = cheapBlocks.block1.end;
int cheapBlockStart2 = cheapBlocks.block2.start;
int cheapBlockEnd2 = cheapBlocks.block2.end;
// Store pointer position for drawing after all bars
int pointerX = -1; // -1 means no pointer to draw
int currentHour = 0; // Hour of the current interval
int currentMinute = 0; // Minute of the current interval
int currentBarHeight = 0; // Height of the current bar (for narrow bar line skipping)
// Draw light grey background for cheapest blocks (BEFORE labels/ticks so they draw on top)
int baselineY = spr.height() - barBottom;
int xAxisHeight = 20; // Height of X-axis area (ticks + labels)
if (cheapBlockStart1 >= 0 && cheapBlockEnd1 >= 0) {
int rectX = barX + cheapBlockStart1 * (barwidth + barSpacing);
int rectWidth = (cheapBlockEnd1 - cheapBlockStart1 + 1) * (barwidth + barSpacing);
spr.fillRect(rectX, baselineY + 1, rectWidth, xAxisHeight - 1, TFT_LIGHTGREY);
}
if (cheapBlockStart2 >= 0 && cheapBlockEnd2 >= 0) {
int rectX = barX + cheapBlockStart2 * (barwidth + barSpacing);
int rectWidth = (cheapBlockEnd2 - cheapBlockStart2 + 1) * (barwidth + barSpacing);
spr.fillRect(rectX, baselineY + 1, rectWidth, xAxisHeight - 1, TFT_LIGHTGREY);
}
for (int i = 0; i < n; i++) {
// Get data from avgData array (already trimmed to visible range)
const time_t item_time = avgData[i].time;
const int item_hour = avgData[i].hour;
const int item_minute = avgData[i].minute;
const double price = avgData[i].price;
uint16_t barcolor = getPercentileColor(prices, numAveragedSamples, price, imageParams.hwdata);
uint16_t thisbarh = mapDouble(price, minPrice, maxPrice, 0, loc["bars"][2].as<int>());
spr.fillRect(barX + i * (barwidth + barSpacing), spr.height() - barBottom - thisbarh, barwidth, thisbarh, barcolor);
// Draw tickmarks and labels at appropriate intervals
int labelX = barX + i * (barwidth + barSpacing) + barwidth / 2;
int tickY = spr.height() - barBottom;
// Check if this bar represents the start of an hour (minute = 0)
bool isHourStart = (item_minute == 0);
// Check if this bar represents a half-hour (minute = 30)
bool isHalfHour = (item_minute == 30);
// Skip tick marks on displays with limited vertical space (height < 150px)
// to prevent labels from being pushed off the bottom edge
bool skipTicks = (spr.height() < 150);
// Every 2 hours: Black tick (4px) + label
if (i % labelInterval == 0 && loc["time"][0]) {
if (!skipTicks) {
spr.drawLine(labelX, tickY, labelX, tickY + 4, TFT_BLACK);
}
drawString(spr, String(item_hour), labelX, tickY + (skipTicks ? 3 : 6), loc["time"][0], TC_DATUM, TFT_BLACK);
}
// Every hour (not already labeled): Dark grey tick (3px)
else if (isHourStart && !skipTicks) {
spr.drawLine(labelX, tickY, labelX, tickY + 3, getColor("darkgray"));
}
// Every half hour: Very short dark grey tick (1px)
else if (isHalfHour && !skipTicks) {
spr.drawPixel(labelX, tickY + 1, getColor("darkgray"));
}
// Store pointer position for drawing after all bars
// Use RAW current price (stored before averaging) for accurate display
// Find the bar that contains "now" by checking if now falls within this bar's time interval
// Important: Use targetIntervalMinutes (the actual bar interval after averaging/display)
if (std::isnan(pricenow) && showcurrent) {
time_t barEndTime = item_time + (targetIntervalMinutes * 60); // End of this bar's time range
if (now >= item_time && now < barEndTime) {
pointerX = barX + i * (barwidth + barSpacing) + barwidth / 2; // Center of the bar
pricenow = rawCurrentPrice; // Use raw price, not averaged
currentHour = rawCurrentHour; // Use native interval's hour (not averaged block)
currentMinute = rawCurrentMinute; // Use native interval's minute (not averaged block)
currentBarHeight = thisbarh; // Store bar height for line drawing
}
}
}
// Draw current hour pointer AFTER all bars (so it won't be chopped off)
// Use constant narrow stem with wider arrow head for a proper arrow shape
if (pointerX >= 0 && showcurrent) {
// Arrow stem (narrow rectangle)
spr.fillRect(pointerX - STEM_WIDTH / 2, 5 + arrowY, STEM_WIDTH, STEM_HEIGHT, imageParams.highlightColor);
// Arrow head (wider triangle)
spr.fillTriangle(pointerX - ARROW_WIDTH / 2, 15 + arrowY,
pointerX + ARROW_WIDTH / 2, 15 + arrowY,
pointerX, 15 + arrowY + ARROW_HEIGHT, imageParams.highlightColor);
// Vertical line: dashed from arrow to bar, solid from baseline to bottom
int lineStartY = 21 + arrowY + GAP_AFTER_ARROW;
int baselineY = spr.height() - barBottom;
int barTopY = baselineY - currentBarHeight;
// Draw DASHED line from arrow to top of bar (with gap) - for visual clarity
for (int y = lineStartY; y < barTopY - 2; y += DASH_LENGTH + GAP_LENGTH) {
int segmentEnd = min(y + DASH_LENGTH, barTopY - 2);
spr.drawLine(pointerX, y, pointerX, segmentEnd, TFT_BLACK);
}
// Draw solid line from baseline to bottom (for time indication in X-axis area)
spr.drawLine(pointerX, baselineY, pointerX, spr.height(), TFT_BLACK);
}
if (showcurrent) {
if (barwidth < 5) {
drawString(spr, String(currentHour) + ":" + (currentMinute < 10 ? "0" : "") + String(currentMinute), spr.width() / 2, 5, "calibrib16.vlw", TC_DATUM, TFT_BLACK, 30);
drawString(spr, String(pricenow) + "/kWh", spr.width() / 2, 25, loc["head"][0], TC_DATUM, TFT_BLACK, 30);
} else {
drawString(spr, String(currentHour) + ":" + (currentMinute < 10 ? "0" : "") + String(currentMinute), barX, 5, loc["head"][0], TL_DATUM, TFT_BLACK, 30);
drawString(spr, String(pricenow) + "/kWh", spr.width() - barX, 5, loc["head"][0], TR_DATUM, TFT_BLACK, 30);
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
return true;
}
#endif
#ifdef CONTENT_QR
void drawQR(String &filename, String qrcontent, String title, tagRecord *&taginfo, imgParam &imageParams) {
TFT_eSprite spr = TFT_eSprite(&tft);
const char *text = qrcontent.c_str();
QRCode qrcode;
uint8_t version = findFittingVersion_text(ECC_MEDIUM, text);
uint8_t qrcodeData[qrcode_getBufferSize(version)];
// https://github.com/ricmoo/QRCode
qrcode_initText(&qrcode, qrcodeData, version, ECC_MEDIUM, text);
JsonDocument loc;
getTemplate(loc, 10, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
drawString(spr, title, loc["title"][0], loc["title"][1], loc["title"][2], TC_DATUM, TFT_BLACK, loc["title"][3]);
const int size = qrcode.size;
const int dotsize = int((imageParams.height - loc["pos"][1].as<int>()) / size);
const int xpos = loc["pos"][0].as<int>() - dotsize * size / 2;
const int ypos = loc["pos"][1].as<int>() + (imageParams.height - loc["pos"][1].as<int>() - dotsize * size) / 2;
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
if (qrcode_getModule(&qrcode, x, y)) {
spr.fillRect(xpos + x * dotsize, ypos + y * dotsize, dotsize, dotsize, TFT_BLACK);
}
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
#endif
#ifdef CONTENT_BUIENRADAR
uint8_t drawBuienradar(String &filename, JsonObject &cfgobj, tagRecord *&taginfo, imgParam &imageParams) {
uint8_t refresh = 60;
wsLog("get buienradar");
getLocation(cfgobj);
HTTPClient http;
String lat = cfgobj["#lat"];
String lon = cfgobj["#lon"];
// logLine("http drawBuienradar");
http.begin("https://gadgets.buienradar.nl/data/raintext/?lat=" + lat + "&lon=" + lon);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(5000);
int httpCode = http.GET();
if (httpCode == 200) {
TFT_eSprite spr = TFT_eSprite(&tft);
JsonDocument loc;
getTemplate(loc, 16, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
tft.setTextWrap(false, false);
String response = http.getString();
drawString(spr, cfgobj["location"], loc["location"][0], loc["location"][1], loc["location"][2]);
const auto &bars = loc["bars"];
const auto &cols = loc["cols"];
const int cols0 = cols[0].as<int>();
const int cols1 = cols[1].as<int>();
const int cols2 = cols[2].as<int>();
const String cols3 = cols[3].as<String>();
const int bars0 = bars[0].as<int>();
const int bars1 = bars[1].as<int>();
const int bars2 = bars[2].as<int>();
float factor = (float)bars1 / 111;
for (int i = 0; i < imageParams.width; i += 4) {
int yCoordinates[] = {1, 20, 29, 39, 49, 55, 59};
for (int y : yCoordinates) {
spr.drawPixel(i, bars1 - (y * factor), TFT_BLACK);
}
}
drawString(spr, "Buienradar", loc["title"][0], loc["title"][1], loc["title"][2]);
for (int i = 0; i < 24; i++) {
const int startPos = i * 10;
uint8_t value = response.substring(startPos, startPos + 3).toInt();
const String timestring = response.substring(startPos + 4, startPos + 9);
const int minutes = timestring.substring(3).toInt();
if (value < 70) {
value = 70;
} else if (value > 180) {
value = 180;
}
if (value > 70) {
if (i < 12) {
refresh = 10;
} else if (refresh > 10) {
refresh = 20;
}
}
value = value - 70;
spr.fillRect(i * cols2 + bars0, bars1 - (value * factor), bars2, value * factor, (value > 50 ? imageParams.highlightColor : TFT_BLACK));
if (minutes % 15 == 0) {
drawString(spr, timestring, i * cols2 + cols0, cols1, cols3);
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
} else {
wsErr("Buitenradar http " + String(httpCode));
}
http.end();
return refresh;
}
#endif
void drawAPinfo(String &filename, JsonObject &cfgobj, tagRecord *&taginfo, imgParam &imageParams) {
if (taginfo->hwType == SOLUM_SEG_UK) {
imageParams.symbols = 0x00;
sprintf(imageParams.segments, "");
return;
}
TFT_eSprite spr = TFT_eSprite(&tft);
JsonDocument loc;
uint8_t screenCurrentOrientation = 0;
getTemplate(loc, 21, taginfo->hwType);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
const JsonArray jsonArray = loc.as<JsonArray>();
for (const JsonVariant &elem : jsonArray) {
drawElement(elem, spr, imageParams, screenCurrentOrientation);
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
#ifdef CONTENT_TIMESTAMP
void drawTimestamp(String &filename, JsonObject &cfgobj, tagRecord *&taginfo, imgParam &imageParams) {
Serial.println("make Timestamp");
time_t now;
time(&now);
struct tm timeinfo;
JsonDocument loc;
getTemplate(loc, 1, taginfo->hwType);
TFT_eSprite spr = TFT_eSprite(&tft);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
if (!cfgobj["#init"]) {
Serial.println("init");
// init preload images
char hexmac[17];
mac2hex(taginfo->mac, hexmac);
String filename2 = "/temp/" + String(hexmac) + "-2.raw";
drawString(spr, cfgobj["button1"].as<String>(), spr.width() / 2, 40, "calibrib30.vlw", TC_DATUM, TFT_BLACK);
drawString(spr, "Well done!", spr.width() / 2, 90, "calibrib30.vlw", TC_DATUM, TFT_BLACK);
spr2buffer(spr, filename2, imageParams);
if (imageParams.zlib) {
imageParams.dataType = DATATYPE_IMG_ZLIB;
} else if (imageParams.g5) {
imageParams.dataType = DATATYPE_IMG_G5;
}
struct imageDataTypeArgStruct arg = {0};
arg.preloadImage = 1;
arg.specialType = 17; // button 2
arg.lut = 0;
prepareDataAvail(filename2, imageParams.dataType, *((uint8_t *)&arg), taginfo->mac, 5 | 0x8000);
spr.fillRect(0, 0, spr.width(), spr.height(), TFT_WHITE);
filename2 = "/temp/" + String(hexmac) + "-3.raw";
drawString(spr, cfgobj["button2"].as<String>(), spr.width() / 2, 40, "calibrib30.vlw", TC_DATUM, TFT_BLACK);
drawString(spr, "Well done!", spr.width() / 2, 90, "calibrib30.vlw", TC_DATUM, TFT_BLACK);
spr2buffer(spr, filename2, imageParams);
arg.preloadImage = 1;
arg.specialType = 16; // button 1
arg.lut = 0;
prepareDataAvail(filename2, imageParams.dataType, *((uint8_t *)&arg), taginfo->mac, 5 | 0x8000);
cfgobj["#init"] = "1";
}
uint8_t offsety = 0;
uint8_t buttony = 145;
spr.fillRect(0, 0, spr.width(), spr.height(), TFT_WHITE);
if (imageParams.rotate == 2) {
offsety = 25;
buttony = 10;
spr.fillTriangle(spr.width() - 27, spr.height() - 160, spr.width() - 37, spr.height() - 160, spr.width() - 32, spr.height() - 165, TFT_BLACK);
spr.fillTriangle(spr.width() - 127, spr.height() - 160, spr.width() - 117, spr.height() - 160, spr.width() - 122, spr.height() - 165, TFT_BLACK);
drawString(spr, cfgobj["button1"], spr.width() - 32, buttony, "calibrib16.vlw", TC_DATUM, TFT_BLACK);
drawString(spr, cfgobj["button2"], spr.width() - 122, buttony, "calibrib16.vlw", TC_DATUM, TFT_BLACK);
} else {
spr.fillTriangle(27, 160, 37, 160, 32, 165, TFT_BLACK);
spr.fillTriangle(127, 160, 117, 160, 122, 165, TFT_BLACK);
drawString(spr, cfgobj["button1"], 32, buttony, "calibrib16.vlw", TC_DATUM, TFT_BLACK);
drawString(spr, cfgobj["button2"], 122, buttony, "calibrib16.vlw", TC_DATUM, TFT_BLACK);
}
drawString(spr, cfgobj["title"], spr.width() / 2, 10 + offsety, "calibrib30.vlw", TC_DATUM, TFT_BLACK);
spr.drawLine(0, 40 + offsety, spr.width(), 40 + offsety, TFT_BLACK);
uint8_t mode = cfgobj["mode"].as<int>();
switch (taginfo->wakeupReason) {
case WAKEUP_REASON_BUTTON2:
Serial.println("button 1");
cfgobj["last1"] = now;
if (mode == 0) {
// 1 timestamp
cfgobj["last2"] = cfgobj["button1"].as<String>();
}
break;
case WAKEUP_REASON_BUTTON1:
Serial.println("button 2");
if (mode == 0) {
// 1 timestamp
cfgobj["last1"] = now;
cfgobj["last2"] = cfgobj["button2"].as<String>();
} else {
cfgobj["last2"] = now;
// 2 timestamps
}
break;
}
char dateString1[40];
uint32_t nextaction = cfgobj["nextaction"].as<uint32_t>();
String dateformat = languageDateFormat[0] + " %H:%M";
time_t timestamp = cfgobj["last1"].as<uint32_t>();
localtime_r(&timestamp, &timeinfo);
strftime(dateString1, sizeof(dateString1), dateformat.c_str(), &timeinfo);
if (timestamp == 0) strcpy(dateString1, "never");
if (mode == 0) {
drawString(spr, "last:", 10, 50 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString1, spr.width() / 2, 50 + offsety, "bahnschrift30.vlw", TC_DATUM, TFT_BLACK);
drawString(spr, cfgobj["last2"].as<String>(), spr.width() / 2, 80 + offsety, "bahnschrift30.vlw", TC_DATUM, TFT_BLACK);
if (nextaction > 0 && timestamp > 0) {
timestamp += nextaction * 24 * 3600;
if (timestamp < taginfo->nextupdate) taginfo->nextupdate = timestamp;
localtime_r(&timestamp, &timeinfo);
strftime(dateString1, sizeof(dateString1), languageDateFormat[0].c_str(), &timeinfo);
drawString(spr, "next:", 10, 115 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString1, 50, 115 + offsety, "calibrib16.vlw", TL_DATUM, timestamp < now ? imageParams.highlightColor : TFT_BLACK);
}
} else {
drawString(spr, cfgobj["button1"].as<String>(), 10, 50 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString1, 20, 67 + offsety, "fonts/bahnschrift20", TL_DATUM, TFT_BLACK);
if (nextaction > 0 && timestamp > 0) {
timestamp += nextaction * 24 * 3600;
if (timestamp < taginfo->nextupdate) taginfo->nextupdate = timestamp;
localtime_r(&timestamp, &timeinfo);
strftime(dateString1, sizeof(dateString1), languageDateFormat[0].c_str(), &timeinfo);
drawString(spr, "next", 200, 50 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString1, 210, 67 + offsety, "fonts/bahnschrift20", TL_DATUM, timestamp < now ? imageParams.highlightColor : TFT_BLACK);
}
char dateString2[40];
time_t timestamp = cfgobj["last2"].as<uint32_t>();
localtime_r(&timestamp, &timeinfo);
strftime(dateString2, sizeof(dateString2), dateformat.c_str(), &timeinfo);
if (timestamp == 0) strcpy(dateString2, "never");
drawString(spr, cfgobj["button2"].as<String>(), 10, 90 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString2, 20, 107 + offsety, "fonts/bahnschrift20", TL_DATUM, TFT_BLACK);
if (nextaction > 0 && timestamp > 0) {
timestamp += nextaction * 24 * 3600;
localtime_r(&timestamp, &timeinfo);
strftime(dateString2, sizeof(dateString2), languageDateFormat[0].c_str(), &timeinfo);
drawString(spr, "next", 200, 90 + offsety, "calibrib16.vlw", TL_DATUM, TFT_BLACK);
drawString(spr, dateString2, 210, 107 + offsety, "fonts/bahnschrift20", TL_DATUM, timestamp < now ? imageParams.highlightColor : TFT_BLACK);
}
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
#endif
bool getJsonTemplateFile(String &filename, String jsonfile, tagRecord *&taginfo, imgParam &imageParams) {
if (jsonfile.c_str()[0] != '/') {
jsonfile = "/" + jsonfile;
}
File file = contentFS->open(jsonfile, "r");
if (file) {
drawJsonStream(file, filename, taginfo, imageParams);
file.close();
// contentFS->remove(jsonfile);
return true;
}
return false;
}
/// @brief Extract a variable with the given path from the given json
/// @note Float and double values are rounded to 2 decimal places
/// @param json Json document
/// @param path Path in form of a.b.1.c
/// @return Value as string
String extractValueFromJson(JsonDocument &json, const String &path) {
JsonVariant currentObj = json.as<JsonVariant>();
char *segment = strtok(const_cast<char *>(path.c_str()), ".");
while (segment != NULL) {
if (currentObj.is<JsonObject>()) {
currentObj = currentObj.as<JsonObject>()[segment];
} else if (currentObj.is<JsonArray>()) {
int index = atoi(segment);
currentObj = currentObj.as<JsonArray>()[index];
} else {
Serial.printf("Invalid JSON structure at path segment: %s\r\n", segment);
return "";
}
segment = strtok(NULL, ".");
}
if (!currentObj.is<int>() && currentObj.is<float>()) {
return String(currentObj.as<float>(), 2);
}
return currentObj.as<String>();
}
/// @brief Replaces json placeholders ({.a.b.1.c}) with variables
class DataInterceptor : public Stream {
private:
/// @brief Stream being wrapped
Stream &_stream;
/// @brief Json containing variables
JsonDocument &_variables;
/// @brief Parsing buffer
String _buffer;
/// @brief Buffer size
const size_t _bufferSize = 32;
public:
DataInterceptor(Stream &stream, JsonDocument &variables)
: _stream(stream), _variables(variables) {
}
int available() override {
return _buffer.length() + _stream.available();
}
int read() override {
findAndReplace();
if (_buffer.length() > 0) {
const int data = _buffer[0];
_buffer.remove(0, 1);
return data;
}
return -1; // No more data
}
int peek() override {
findAndReplace();
return _buffer.length() ? _buffer[0] : -1;
}
size_t write(uint8_t data) override {
return _stream.write(data);
}
private:
/// @brief Fill buffer, find and replace json variables
void findAndReplace() {
unsigned int len;
while ((len = _buffer.length()) < _bufferSize) {
const int data = _stream.read();
if (data == -1) {
break; // No more data to read
}
_buffer += (char)data;
}
if (len < 4) {
// There are no variables with less than 4 characters
return;
}
int endIndex = findVar(_buffer, 0);
if (endIndex == -1) {
return;
}
const String varCleaned = _buffer.substring(1, endIndex - 1);
String replacement = extractValueFromJson(_variables, varCleaned);
// Check for operator and second variable
if (endIndex + 3 < len) {
const char op = _buffer[endIndex];
if ((op == '*' || op == '/' || op == '+' || op == '-')) {
const int endIndex2 = findVar(_buffer, endIndex + 1);
if (endIndex2 != -1) {
const String var2Cleaned = _buffer.substring(endIndex + 2, endIndex2 - 1);
const float v2 = extractValueFromJson(_variables, var2Cleaned).toFloat();
endIndex = endIndex2;
if (op == '*') {
replacement = String(replacement.toFloat() * v2, 0);
} else if (op == '/') {
replacement = abs(v2) > 0.0f ? String(replacement.toFloat() / v2, 0) : "0";
} else if (op == '+') {
replacement = String(replacement.toFloat() + v2, 0);
} else if (op == '-') {
replacement = String(replacement.toFloat() - v2, 0);
}
}
}
}
_buffer = replacement + _buffer.substring(endIndex);
}
/// @brief Find a var at given start index
/// @param buffer Buffer to search in
/// @param index Index to look at
/// @return Endindex
int findVar(const String &buffer, const int index) {
if (buffer[index] != '{' || buffer[index + 1] != '.') {
return -1;
}
return buffer.indexOf("}", index + 2) + 1;
}
};
bool getJsonTemplateFileExtractVariables(String &filename, String jsonfile, JsonDocument &variables, tagRecord *&taginfo, imgParam &imageParams) {
if (jsonfile.c_str()[0] != '/') {
jsonfile = "/" + jsonfile;
}
File file = contentFS->open(jsonfile, "r");
if (file) {
auto interceptor = DataInterceptor(file, variables);
drawJsonStream(interceptor, filename, taginfo, imageParams);
file.close();
// contentFS->remove(jsonfile);
return true;
}
return false;
}
int getJsonTemplateUrl(String &filename, String URL, time_t fetched, String MAC, tagRecord *&taginfo, imgParam &imageParams) {
HTTPClient http;
http.useHTTP10(true);
logLine("http getJsonTemplateUrl " + URL);
http.begin(URL);
http.addHeader("If-Modified-Since", formatHttpDate(fetched));
http.addHeader("X-ESL-MAC", MAC);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(20000);
const int httpCode = http.GET();
if (httpCode == 200) {
drawJsonStream(http.getStream(), filename, taginfo, imageParams);
} else {
if (httpCode != 304) {
wsErr("http " + URL + " status " + String(httpCode));
}
}
http.end();
return httpCode;
}
void drawJsonStream(Stream &stream, String &filename, tagRecord *&taginfo, imgParam &imageParams) {
TFT_eSprite spr = TFT_eSprite(&tft);
initSprite(spr, imageParams.width, imageParams.height, imageParams);
uint8_t screenCurrentOrientation = 0;
JsonDocument doc;
if (stream.find("[")) {
do {
DeserializationError error = deserializeJson(doc, stream);
if (error) {
wsErr("json error " + String(error.c_str()));
break;
} else {
drawElement(doc.as<JsonObject>(), spr, imageParams, screenCurrentOrientation);
doc.clear();
}
} while (stream.findUntil(",", "]"));
}
spr2buffer(spr, filename, imageParams);
spr.deleteSprite();
}
void rotateBuffer(uint8_t rotation, uint8_t &currentOrientation, TFT_eSprite &spr, imgParam &imageParams) {
rotation = rotation % 4; // First of all, let's be sure that the rotation have a valid value (0, 1, 2 or 3)
if (rotation != currentOrientation) { // If we have a rotation to do, let's do it
int stepToDo = (currentOrientation - rotation + 4) % 4;
// 2: upside down
// 3: 270° rotation
// 1: 90° rotation
if (abs(stepToDo) == 2) { // If we have to do a 180° rotation:
TFT_eSprite sprCpy = TFT_eSprite(&tft); // We create a new sprite that will act as a buffer
initSprite(sprCpy, spr.width(), spr.height(), imageParams); // initialisation of the new sprite
spr.pushRotated(&sprCpy, 180, TFT_WHITE); // We fill the new sprite with the old one rotated by 180°
spr.fillSprite(TFT_WHITE); // We fill the old one in white as anything that's white will be ignored by the pushRotated function
sprCpy.pushRotated(&spr, 0, TFT_WHITE); // We copy the buffer sprite to the main one
sprCpy.deleteSprite(); // We delete the buffer sprite to avoid memory leak
} else {
int angle = (stepToDo == 3) ? 270 : 90;
TFT_eSprite sprCpy = TFT_eSprite(&tft);
initSprite(sprCpy, spr.height(), spr.width(), imageParams);
spr.pushRotated(&sprCpy, angle, TFT_WHITE);
spr.deleteSprite();
initSprite(spr, sprCpy.width(), sprCpy.height(), imageParams);
sprCpy.pushToSprite(&spr, 0, 0);
sprCpy.deleteSprite();
imageParams.rotatebuffer = 1 - (imageParams.rotatebuffer % 2);
}
currentOrientation = rotation;
}
}
TFT_eSprite sprDraw = TFT_eSprite(&tft);
bool spr_draw(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap) {
sprDraw.pushImage(x, y, w, h, bitmap);
return 1;
}
void drawElement(const JsonObject &element, TFT_eSprite &spr, imgParam &imageParams, uint8_t &currentOrientation) {
if (element["text"].is<JsonArray>()) {
const JsonArray &textArray = element["text"];
const uint16_t align = textArray[5] | 0;
const uint16_t size = textArray[6] | 0;
const String bgcolorstr = textArray[7].as<String>();
const uint16_t bgcolor = (bgcolorstr.length() > 0) ? getColor(bgcolorstr) : TFT_WHITE;
drawString(spr, textArray[2], textArray[0].as<int>(), textArray[1].as<int>(), textArray[3], align, getColor(textArray[4]), size, bgcolor);
} else if (element["textbox"].is<JsonArray>()) {
// posx, posy, width, height, text, font, color, lineheight, align
const JsonArray &textArray = element["textbox"];
float lineheight = textArray[7].as<float>();
if (lineheight == 0) lineheight = 1;
int16_t posx = textArray[0] | 0;
int16_t posy = textArray[1] | 0;
String text = textArray[4];
const uint16_t align = textArray[8] | 0;
drawTextBox(spr, text, posx, posy, textArray[2], textArray[3], textArray[5], getColor(textArray[6]), TFT_WHITE, lineheight, align);
} else if (element["box"].is<JsonArray>()) {
const JsonArray &boxArray = element["box"];
spr.fillRect(boxArray[0].as<int>(), boxArray[1].as<int>(), boxArray[2].as<int>(), boxArray[3].as<int>(), getColor(boxArray[4]));
if (boxArray.size()>=7) {
for (int i=0; i < boxArray[6].as<int>(); i++) {
spr.drawRect(boxArray[0].as<int>() + i, boxArray[1].as<int>() + i, boxArray[2].as<int>() - 2 * i, boxArray[3].as<int>() - 2 * i, getColor(boxArray[5]));
}
}
} else if (element["rbox"].is<JsonArray>()) {
const JsonArray &rboxArray = element["rbox"];
spr.fillRoundRect(rboxArray[0].as<int>(), rboxArray[1].as<int>(), rboxArray[2].as<int>(), rboxArray[3].as<int>(), rboxArray[4].as<int>(), getColor(rboxArray[5]));
if (rboxArray.size() >= 8) {
for (int i = 0; i < rboxArray[7].as<int>(); i++) {
spr.drawRoundRect(rboxArray[0].as<int>() + i, rboxArray[1].as<int>() + i, rboxArray[2].as<int>() - 2 * i, rboxArray[3].as<int>() - 2 * i, rboxArray[4].as<int>() - i / 1.41, getColor(rboxArray[6]));
if (i > 0) {
spr.drawRoundRect(rboxArray[0].as<int>() + i - 1, rboxArray[1].as<int>() + i, rboxArray[2].as<int>() - 2 * i + 2, rboxArray[3].as<int>() - 2 * i, rboxArray[4].as<int>() - i / 1.41, getColor(rboxArray[6]));
}
}
}
} else if (element["line"].is<JsonArray>()) {
const JsonArray &lineArray = element["line"];
spr.drawLine(lineArray[0].as<int>(), lineArray[1].as<int>(), lineArray[2].as<int>(), lineArray[3].as<int>(), getColor(lineArray[4]));
} else if (element["triangle"].is<JsonArray>()) {
const JsonArray &lineArray = element["triangle"];
spr.fillTriangle(lineArray[0].as<int>(), lineArray[1].as<int>(), lineArray[2].as<int>(), lineArray[3].as<int>(), lineArray[4].as<int>(), lineArray[5].as<int>(), getColor(lineArray[6]));
} else if (element["circle"].is<JsonArray>()) {
const JsonArray &circleArray = element["circle"];
spr.fillCircle(circleArray[0].as<int>(), circleArray[1].as<int>(), circleArray[2].as<int>(), getColor(circleArray[3]));
if (circleArray.size() >= 6) {
for (int i = 0; i < circleArray[5].as<int>(); i++) {
spr.drawCircle(circleArray[0].as<int>(), circleArray[1].as<int>(), circleArray[2].as<int>() - i, getColor(circleArray[4]));
if (i > 0) {
spr.drawCircle(circleArray[0].as<int>(), circleArray[1].as<int>(), circleArray[2].as<int>() - i - 0.5, getColor(circleArray[4]));
}
}
}
} else if (element["image"].is<JsonArray>()) {
const JsonArray &imgArray = element["image"];
TJpgDec.setSwapBytes(true);
TJpgDec.setJpgScale(1);
TJpgDec.setCallback(spr_draw);
uint16_t w = 0, h = 0;
String filename = imgArray[0];
if (filename[0] != '/') {
filename = "/" + filename;
}
TJpgDec.getFsJpgSize(&w, &h, filename, *contentFS);
if (w == 0 && h == 0) {
wsErr("invalid jpg");
return;
}
Serial.println("jpeg conversion " + String(w) + "x" + String(h));
sprDraw.setColorDepth(16);
sprDraw.createSprite(w, h);
if (sprDraw.getPointer() == nullptr) {
wsErr("Failed to create sprite in contentmanager");
} else {
TJpgDec.drawFsJpg(0, 0, filename, *contentFS);
sprDraw.pushToSprite(&spr, imgArray[1].as<int>(), imgArray[2].as<int>());
sprDraw.deleteSprite();
}
} else if (element["rotate"].is<uint8_t>()) {
uint8_t rotation = element["rotate"].as<int>();
rotateBuffer(rotation, currentOrientation, spr, imageParams);
}
}
uint16_t getColor(const String &color) {
if (color == "0" || color == "white") return TFT_WHITE;
if (color == "1" || color == "" || color == "black") return TFT_BLACK;
if (color == "2" || color == "red") return TFT_RED;
if (color == "3" || color == "yellow") return TFT_YELLOW;
if (color == "4" || color == "lightgray") return 0xBDF7;
if (color == "5" || color == "darkgray") return TFT_DARKGREY;
if (color == "6" || color == "pink") return 0xFBCF;
if (color == "7" || color == "brown") return 0x8400;
if (color == "8" || color == "green") return TFT_GREEN;
if (color == "9" || color == "blue") return TFT_BLUE;
if (color == "10" || color == "orange") return 0xFBE0;
uint16_t r, g, b;
if (color.length() == 7 && color[0] == '#' &&
sscanf(color.c_str(), "#%2hx%2hx%2hx", &r, &g, &b) == 3) {
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
}
return TFT_WHITE;
}
char *formatHttpDate(const time_t t) {
static char buf[40];
struct tm *timeinfo;
timeinfo = localtime(&t); // Get the local time
const time_t utcTime = mktime(timeinfo); // Convert to UTC
timeinfo = gmtime(&utcTime);
strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", timeinfo);
return buf;
}
String urlEncode(const char *msg) {
constexpr const char *hex = "0123456789ABCDEF";
String encodedMsg = "";
while (*msg != '\0') {
if (
('a' <= *msg && *msg <= 'z') || ('A' <= *msg && *msg <= 'Z') || ('0' <= *msg && *msg <= '9') || *msg == '-' || *msg == '_' || *msg == '.' || *msg == '~') {
encodedMsg += *msg;
} else {
encodedMsg += '%';
encodedMsg += hex[(unsigned char)*msg >> 4];
encodedMsg += hex[*msg & 0xf];
}
msg++;
}
return encodedMsg;
}
int windSpeedToBeaufort(const float windSpeed) {
constexpr const float speeds[] = {0.3, 1.5, 3.3, 5.5, 8, 10.8, 13.9, 17.2, 20.8, 24.5, 28.5, 32.7};
constexpr const int numSpeeds = sizeof(speeds) / sizeof(speeds[0]);
int beaufort = 0;
for (int i = 0; i < numSpeeds; i++) {
if (windSpeed >= speeds[i]) {
beaufort = i + 1;
}
}
return beaufort;
}
String windDirectionIcon(const int degrees) {
const String directions[] = {"\uf044", "\uf043", "\uf048", "\uf087", "\uf058", "\uf057", "\uf04d", "\uf088"};
int index = (degrees + 22) / 45;
if (index >= 8) {
index = 0;
}
return directions[index];
}
void getLocation(JsonObject &cfgobj) {
const String lat = cfgobj["#lat"];
const String lon = cfgobj["#lon"];
if (util::isEmptyOrNull(lat) || util::isEmptyOrNull(lon)) {
wsLog("get location");
JsonDocument filter;
filter["results"][0]["latitude"] = true;
filter["results"][0]["longitude"] = true;
filter["results"][0]["timezone"] = true;
JsonDocument doc;
if (util::httpGetJson("https://geocoding-api.open-meteo.com/v1/search?name=" + urlEncode(cfgobj["location"]) + "&count=1", doc, 5000, &filter)) {
cfgobj["#lat"] = doc["results"][0]["latitude"].as<String>();
cfgobj["#lon"] = doc["results"][0]["longitude"].as<String>();
cfgobj["#tz"] = doc["results"][0]["timezone"].as<String>();
}
}
}
#ifdef CONTENT_NFCLUT
void prepareNFCReq(const uint8_t *dst, const char *url) {
uint8_t *data;
size_t len = strlen(url);
data = new uint8_t[len + 8];
// TLV
data[0] = 0x03; // NDEF message (TLV type)
data[1] = 4 + len + 1;
// ndef record
data[2] = 0xD1;
data[3] = 0x01; // well known record type
data[4] = len + 1; // payload length
data[5] = 0x55; // payload type (URI record)
data[6] = 0x00; // URI identifier code (no prepending)
memcpy(data + 7, reinterpret_cast<const uint8_t *>(url), len);
len = 7 + len;
data[len] = 0xFE;
len = 1 + len;
prepareDataAvail(data, len, DATATYPE_NFC_RAW_CONTENT, dst);
}
#endif
#ifdef CONTENT_TAGCFG
void prepareConfigFile(const uint8_t *dst, const JsonObject &config) {
struct tagsettings tagSettings;
tagSettings.settingsVer = 1;
tagSettings.enableFastBoot = config["fastboot"].as<int>();
tagSettings.enableRFWake = config["rfwake"].as<int>();
tagSettings.enableTagRoaming = config["tagroaming"].as<int>();
tagSettings.enableScanForAPAfterTimeout = config["tagscanontimeout"].as<int>();
tagSettings.enableLowBatSymbol = config["showlowbat"].as<int>();
tagSettings.enableNoRFSymbol = config["shownorf"].as<int>();
tagSettings.customMode = 0;
tagSettings.fastBootCapabilities = 0;
tagSettings.minimumCheckInTime = 1;
tagSettings.fixedChannel = config["fixedchannel"].as<int>();
tagSettings.batLowVoltage = config["lowvoltage"].as<int>();
prepareDataAvail((uint8_t *)&tagSettings, sizeof(tagSettings), 0xA8, dst);
}
#endif
#ifdef CONTENT_TIME_RAWDATA
bool is_leap_year(int year) {// Somehow mktime did not return the local unix time so lets to it in a manual way
year += 1900;
return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
}
int days_in_month(int month, int year) {
static const int days[] = { 31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31 };
if (month == 1 && is_leap_year(year)) {
return 29;
}
return days[month];
}
uint32_t convert_tm_to_seconds(struct tm *t) {
const int SECONDS_PER_MINUTE = 60;
const int SECONDS_PER_HOUR = 3600;
const int SECONDS_PER_DAY = 86400;
long long total_days = 0;
for (int year = 70; year < t->tm_year; year++) {
total_days += is_leap_year(year) ? 366 : 365;
}
for (int month = 0; month < t->tm_mon; month++) {
total_days += days_in_month(month, t->tm_year);
}
total_days += (t->tm_mday - 1);
long long total_seconds = total_days * SECONDS_PER_DAY;
total_seconds += t->tm_hour * SECONDS_PER_HOUR;
total_seconds += t->tm_min * SECONDS_PER_MINUTE;
total_seconds += t->tm_sec;
return total_seconds;
}
void prepareTIME_RAW(const uint8_t *dst, time_t now) {
uint8_t *data;
size_t len = 1 + 4 + 4;
struct tm timeinfo;
localtime_r(&now, &timeinfo);
uint32_t local_time = convert_tm_to_seconds(&timeinfo) + 20;// Adding 20 seconds for the average of upload time
uint32_t unix_time = now + 20;
data = new uint8_t[len + 1];
data[0] = len;// Length all
data[1] = 0x01;// Version of Time and RAW
data[2] = ((uint8_t*)&local_time)[0];
data[3] = ((uint8_t*)&local_time)[1];
data[4] = ((uint8_t*)&local_time)[2];
data[5] = ((uint8_t*)&local_time)[3];
data[6] = ((uint8_t*)&unix_time)[0];
data[7] = ((uint8_t*)&unix_time)[1];
data[8] = ((uint8_t*)&unix_time)[2];
data[9] = ((uint8_t*)&unix_time)[3];
prepareDataAvail(data, len + 1, DATATYPE_TIME_RAW_DATA, dst);
}
#endif
void getTemplate(JsonDocument &json, const uint8_t id, const uint8_t hwtype) {
JsonDocument filter;
JsonDocument doc;
const String idstr = String(id);
constexpr const char *templateKey = "template";
char filename[20];
snprintf(filename, sizeof(filename), "/tagtypes/%02X.json", hwtype);
File jsonFile = contentFS->open(filename, "r");
if (jsonFile) {
filter[templateKey][idstr] = true;
filter["usetemplate"] = true;
const DeserializationError error = deserializeJson(doc, jsonFile, DeserializationOption::Filter(filter));
jsonFile.close();
if (!error && doc[templateKey].is<JsonVariant>() && doc[templateKey][idstr].is<JsonVariant>()) {
json.set(doc[templateKey][idstr]);
return;
}
if (!error && doc["usetemplate"].is<uint8_t>()) {
getTemplate(json, id, doc["usetemplate"]);
return;
}
Serial.println("json error in " + String(filename));
Serial.println(error.c_str());
} else {
Serial.println("Failed to open " + String(filename));
}
}