diff --git a/ESPTimeCast.ino b/ESPTimeCast.ino index 610395c..42a3b18 100644 --- a/ESPTimeCast.ino +++ b/ESPTimeCast.ino @@ -11,14 +11,15 @@ #include #include -#include "mfactoryfont.h" // Replace with your font, or comment/remove if not using custom -#include "tz_lookup.h" // Timezone lookup, do not duplicate mapping here! +#include "mfactoryfont.h" // Custom font +#include "tz_lookup.h" // Timezone lookup, do not duplicate mapping here! +#include "days_lookup.h" // Languages for the Days of the Week #define HARDWARE_TYPE MD_MAX72XX::FC16_HW #define MAX_DEVICES 4 -#define CLK_PIN 12 -#define DATA_PIN 15 -#define CS_PIN 13 +#define CLK_PIN 12 +#define DATA_PIN 15 +#define CS_PIN 13 MD_Parola P = MD_Parola(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES); AsyncWebServer server(80); @@ -30,6 +31,8 @@ char openWeatherCity[64] = ""; char openWeatherCountry[64] = ""; char weatherUnits[12] = "metric"; char timeZone[64] = ""; +char language[8] = "en"; + unsigned long clockDuration = 10000; unsigned long weatherDuration = 5000; @@ -42,6 +45,14 @@ bool showHumidity = false; char ntpServer1[64] = "pool.ntp.org"; char ntpServer2[64] = "time.nist.gov"; +// DIMMING SETTINGS +bool dimmingEnabled = false; +int dimStartHour = 18; // 6pm default +int dimStartMinute = 0; +int dimEndHour = 8; // 8am default +int dimEndMinute = 0; +int dimBrightness = 2; // Dimming level (0-15) + WiFiClient client; const byte DNS_PORT = 53; DNSServer dnsServer; @@ -61,8 +72,6 @@ int currentHumidity = -1; bool ntpSyncSuccessful = false; -char daysOfTheWeek[7][12] = {"&", "*", "/", "?", "@", "=", "$"}; - // NTP Synchronization State Machine enum NtpState { NTP_IDLE, @@ -73,42 +82,22 @@ enum NtpState { NtpState ntpState = NTP_IDLE; unsigned long ntpStartTime = 0; -const int ntpTimeout = 30000; // 30 seconds +const int ntpTimeout = 30000; // 30 seconds const int maxNtpRetries = 30; int ntpRetryCount = 0; -void printConfigToSerial() { - Serial.println(F("========= Loaded Configuration =========")); - Serial.print(F("WiFi SSID: ")); Serial.println(ssid); - Serial.print(F("WiFi Password: ")); Serial.println(password); - Serial.print(F("OpenWeather City: ")); Serial.println(openWeatherCity); - Serial.print(F("OpenWeather Country: ")); Serial.println(openWeatherCountry); - Serial.print(F("OpenWeather API Key: ")); Serial.println(openWeatherApiKey); - Serial.print(F("Temperature Unit: ")); Serial.println(weatherUnits); - Serial.print(F("Clock duration: ")); Serial.println(clockDuration); - Serial.print(F("Weather duration: ")); Serial.println(weatherDuration); - Serial.print(F("TimeZone (IANA): ")); Serial.println(timeZone); - Serial.print(F("Brightness: ")); Serial.println(brightness); - Serial.print(F("Flip Display: ")); Serial.println(flipDisplay ? "Yes" : "No"); - Serial.print(F("Show 12h Clock: ")); Serial.println(twelveHourToggle ? "Yes" : "No"); - Serial.print(F("Show Day of the Week: ")); Serial.println(showDayOfWeek ? "Yes" : "No"); - Serial.print(F("Show Humidity ")); Serial.println(showHumidity ? "Yes" : "No"); - Serial.print(F("NTP Server 1: ")); Serial.println(ntpServer1); - Serial.print(F("NTP Server 2: ")); Serial.println(ntpServer2); - Serial.println(F("========================================")); - Serial.println(); -} +// --- Globals for non-blocking IP display --- +bool showingIp = false; +int ipDisplayCount = 0; // How many times IP has been shown +const int ipDisplayMax = 1; // Number of repeats +String pendingIpToShow = ""; void loadConfig() { Serial.println(F("[CONFIG] Loading configuration...")); - if (!LittleFS.begin()) { - Serial.println(F("[ERROR] LittleFS mount failed")); - return; - } if (!LittleFS.exists("/config.json")) { Serial.println(F("[CONFIG] config.json not found, creating with defaults...")); - DynamicJsonDocument doc(512); + DynamicJsonDocument doc(512); // Sufficient for initial defaults doc[F("ssid")] = ""; doc[F("password")] = ""; doc[F("openWeatherApiKey")] = ""; @@ -118,6 +107,7 @@ void loadConfig() { doc[F("clockDuration")] = 10000; doc[F("weatherDuration")] = 5000; doc[F("timeZone")] = ""; + doc[F("language")] = "en"; doc[F("brightness")] = brightness; doc[F("flipDisplay")] = flipDisplay; doc[F("twelveHourToggle")] = twelveHourToggle; @@ -125,6 +115,10 @@ void loadConfig() { doc[F("showHumidity")] = showHumidity; doc[F("ntpServer1")] = ntpServer1; doc[F("ntpServer2")] = ntpServer2; + doc[F("dimmingEnabled")] = dimmingEnabled; + doc[F("dimStartHour")] = dimStartHour; + doc[F("dimEndHour")] = dimEndHour; + doc[F("dimBrightness")] = dimBrightness; File f = LittleFS.open("/config.json", "w"); if (f) { serializeJsonPretty(doc, f); @@ -134,42 +128,58 @@ void loadConfig() { Serial.println(F("[ERROR] Failed to create default config.json")); } } + File configFile = LittleFS.open("/config.json", "r"); if (!configFile) { - Serial.println(F("[ERROR] Failed to open config.json")); + Serial.println(F("[ERROR] Failed to open config.json for reading. Cannot load config.")); return; } - size_t size = configFile.size(); - if (size == 0 || size > 1024) { - Serial.println(F("[ERROR] Invalid config file size")); - configFile.close(); - return; - } - String jsonString = configFile.readString(); - configFile.close(); - DynamicJsonDocument doc(2048); - DeserializationError error = deserializeJson(doc, jsonString); + + DynamicJsonDocument doc(2048); // Use 2048 to match the save handler's capacity + DeserializationError error = deserializeJson(doc, configFile); // Read directly from file + configFile.close(); // Close after reading + if (error) { - Serial.print(F("[ERROR] JSON parse failed: ")); + Serial.print(F("[ERROR] JSON parse failed during load: ")); Serial.println(error.f_str()); return; } - if (doc.containsKey("ssid")) strlcpy(ssid, doc["ssid"], sizeof(ssid)); - if (doc.containsKey("password")) strlcpy(password, doc["password"], sizeof(password)); - if (doc.containsKey("openWeatherApiKey")) strlcpy(openWeatherApiKey, doc["openWeatherApiKey"], sizeof(openWeatherApiKey)); - if (doc.containsKey("openWeatherCity")) strlcpy(openWeatherCity, doc["openWeatherCity"], sizeof(openWeatherCity)); - if (doc.containsKey("openWeatherCountry")) strlcpy(openWeatherCountry, doc["openWeatherCountry"], sizeof(openWeatherCountry)); - if (doc.containsKey("weatherUnits")) strlcpy(weatherUnits, doc["weatherUnits"], sizeof(weatherUnits)); - if (doc.containsKey("clockDuration")) clockDuration = doc["clockDuration"]; - if (doc.containsKey("weatherDuration")) weatherDuration = doc["weatherDuration"]; - if (doc.containsKey("timeZone")) strlcpy(timeZone, doc["timeZone"], sizeof(timeZone)); - if (doc.containsKey("brightness")) brightness = doc["brightness"]; - if (doc.containsKey("flipDisplay")) flipDisplay = doc["flipDisplay"]; - if (doc.containsKey("twelveHourToggle")) twelveHourToggle = doc["twelveHourToggle"]; - if (doc.containsKey("showDayOfWeek")) showDayOfWeek = doc["showDayOfWeek"]; // <-- NEW - if (doc.containsKey("showHumidity")) showHumidity = doc["showHumidity"]; else showHumidity = false; - if (doc.containsKey("ntpServer1")) strlcpy(ntpServer1, doc["ntpServer1"], sizeof(ntpServer1)); - if (doc.containsKey("ntpServer2")) strlcpy(ntpServer2, doc["ntpServer2"], sizeof(ntpServer2)); + + // Populate global variables from loaded JSON, using default values if keys are missing + strlcpy(ssid, doc["ssid"] | "", sizeof(ssid)); + strlcpy(password, doc["password"] | "", sizeof(password)); + strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); + strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity)); + strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry)); + strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits)); + clockDuration = doc["clockDuration"] | 10000; + weatherDuration = doc["weatherDuration"] | 5000; + strlcpy(timeZone, doc["timeZone"] | "Etc/UTC", sizeof(timeZone)); + if (doc.containsKey("language")) { + strlcpy(language, doc["language"], sizeof(language)); + } else { + strlcpy(language, "en", sizeof(language)); // Fallback if key missing + Serial.println(F("[CONFIG] 'language' key not found in config.json, defaulting to 'en'.")); + } + + brightness = doc["brightness"] | 7; + flipDisplay = doc["flipDisplay"] | false; + twelveHourToggle = doc["twelveHourToggle"] | false; + showDayOfWeek = doc["showDayOfWeek"] | true; + showHumidity = doc["showHumidity"] | false; + + String de = doc["dimmingEnabled"].as(); + dimmingEnabled = (de == "true" || de == "on" || de == "1"); + + dimStartHour = doc["dimStartHour"] | 18; + dimStartMinute = doc["dimStartMinute"] | 0; + dimEndHour = doc["dimEndHour"] | 8; + dimEndMinute = doc["dimEndMinute"] | 0; + dimBrightness = doc["dimBrightness"] | 0; + + strlcpy(ntpServer1, doc["ntpServer1"] | "pool.ntp.org", sizeof(ntpServer1)); + strlcpy(ntpServer2, doc["ntpServer2"] | "time.nist.gov", sizeof(ntpServer2)); + if (strcmp(weatherUnits, "imperial") == 0) tempSymbol = 'F'; else if (strcmp(weatherUnits, "standard") == 0) @@ -179,26 +189,65 @@ void loadConfig() { Serial.println(F("[CONFIG] Configuration loaded.")); } +const char *DEFAULT_AP_PASSWORD = "12345678"; +const char *AP_SSID = "ESPTimeCast"; + void connectWiFi() { Serial.println(F("[WIFI] Connecting to WiFi...")); + + bool credentialsExist = (strlen(ssid) > 0); + + if (!credentialsExist) { + Serial.println(F("[WIFI] No saved credentials. Starting AP mode directly.")); + WiFi.mode(WIFI_AP); + WiFi.disconnect(true); + delay(100); + + if (strlen(DEFAULT_AP_PASSWORD) < 8) { + WiFi.softAP(AP_SSID); + Serial.println(F("[WIFI] AP Mode started (no password, too short).")); + } else { + WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD); + Serial.println(F("[WIFI] AP Mode started.")); + } + + IPAddress apIP(192, 168, 4, 1); + WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); + dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); + Serial.print(F("AP IP address: ")); + Serial.println(WiFi.softAPIP()); + isAPMode = true; + Serial.println(F("[WIFI] AP Mode Started")); + return; + } + WiFi.disconnect(true); delay(100); WiFi.begin(ssid, password); unsigned long startAttemptTime = millis(); - const unsigned long timeout = 15000; + const unsigned long timeout = 25000; unsigned long animTimer = 0; int animFrame = 0; bool animating = true; + while (animating) { unsigned long now = millis(); if (WiFi.status() == WL_CONNECTED) { - Serial.println(F("[WiFi] Connected: ") + WiFi.localIP().toString()); + Serial.println(F("[WIFI] Connected: ") + WiFi.localIP().toString()); isAPMode = false; animating = false; + + // --- NON-BLOCKING: Schedule IP display in loop() for 1 repeat --- + pendingIpToShow = WiFi.localIP().toString(); + showingIp = true; + ipDisplayCount = 0; + P.displayClear(); + P.setCharSpacing(1); + P.displayScroll(pendingIpToShow.c_str(), PA_CENTER, PA_SCROLL_LEFT, 120); break; } else if (now - startAttemptTime >= timeout) { Serial.println(F("\r\n[WiFi] Failed. Starting AP mode...")); - WiFi.softAP("ESPTimeCast", "12345678"); + WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD); Serial.print(F("AP IP address: ")); Serial.println(WiFi.softAPIP()); dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); @@ -211,9 +260,9 @@ void connectWiFi() { animTimer = now; P.setTextAlignment(PA_CENTER); switch (animFrame % 3) { - case 0: P.print("WiFi©"); break; - case 1: P.print("WiFiª"); break; - case 2: P.print("WiFi«"); break; + case 0: P.print(F("W @ F @ ©")); break; + case 1: P.print(F("W @ F @ ª")); break; + case 2: P.print(F("W @ F @ «")); break; } animFrame++; } @@ -223,23 +272,81 @@ void connectWiFi() { void setupTime() { sntp_stop(); - Serial.println(F("[TIME] Starting NTP sync...")); - configTime(0, 0, ntpServer1, ntpServer2); // Use custom NTP servers + if (!isAPMode) { + Serial.println(F("[TIME] Starting NTP sync...")); + } + configTime(0, 0, ntpServer1, ntpServer2); setenv("TZ", ianaToPosix(timeZone), 1); tzset(); ntpState = NTP_SYNCING; ntpStartTime = millis(); ntpRetryCount = 0; - ntpSyncSuccessful = false; // Reset the flag + ntpSyncSuccessful = false; // Reset the flag } +void printConfigToSerial() { + Serial.println(F("========= Loaded Configuration =========")); + Serial.print(F("WiFi SSID: ")); + Serial.println(ssid); + Serial.print(F("WiFi Password: ")); + Serial.println(password); + Serial.print(F("OpenWeather City: ")); + Serial.println(openWeatherCity); + Serial.print(F("OpenWeather Country: ")); + Serial.println(openWeatherCountry); + Serial.print(F("OpenWeather API Key: ")); + Serial.println(openWeatherApiKey); + Serial.print(F("Temperature Unit: ")); + Serial.println(weatherUnits); + Serial.print(F("Clock duration: ")); + Serial.println(clockDuration); + Serial.print(F("Weather duration: ")); + Serial.println(weatherDuration); + Serial.print(F("TimeZone (IANA): ")); + Serial.println(timeZone); + Serial.print(F("Days of the Week language): ")); + Serial.println(language); + Serial.print(F("Brightness: ")); + Serial.println(brightness); + Serial.print(F("Flip Display: ")); + Serial.println(flipDisplay ? "Yes" : "No"); + Serial.print(F("Show 12h Clock: ")); + Serial.println(twelveHourToggle ? "Yes" : "No"); + Serial.print(F("Show Day of the Week: ")); + Serial.println(showDayOfWeek ? "Yes" : "No"); + Serial.print(F("Show Humidity ")); + Serial.println(showHumidity ? "Yes" : "No"); + Serial.print(F("NTP Server 1: ")); + Serial.println(ntpServer1); + Serial.print(F("NTP Server 2: ")); + Serial.println(ntpServer2); + Serial.print(F("Dimming Enabled: ")); + Serial.println(dimmingEnabled); + Serial.print(F("Dimming Start Hour: ")); + Serial.println(dimStartHour); + Serial.print(F("Dimming Start Minute: ")); + Serial.println(dimStartMinute); + Serial.print(F("Dimming End Hour: ")); + Serial.println(dimEndHour); + Serial.print(F("Dimming End Minute: ")); + Serial.println(dimEndMinute); + Serial.print(F("Dimming Brightness: ")); + Serial.println(dimBrightness); + Serial.println(F("========================================")); + Serial.println(); +} + +// This tells the compiler that handleCaptivePortal exists somewhere later in the code. +void handleCaptivePortal(AsyncWebServerRequest *request); + void setupWebServer() { Serial.println(F("[WEBSERVER] Setting up web server...")); - server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ + server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { Serial.println(F("[WEBSERVER] Request: /")); request->send(LittleFS, "/index.html", "text/html"); }); - server.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *request){ + + server.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *request) { Serial.println(F("[WEBSERVER] Request: /config.json")); File f = LittleFS.open("/config.json", "r"); if (!f) { @@ -261,62 +368,134 @@ void setupWebServer() { serializeJson(doc, response); request->send(200, "application/json", response); }); - server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request){ - Serial.println(F("[WEBSERVER] Request: /save")); + + server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request) { + Serial.println(F("[WEBSERVER] Request: /save")); + DynamicJsonDocument doc(2048); + + // Load existing config.json into the document + File configFile = LittleFS.open("/config.json", "r"); + if (configFile) { + Serial.println(F("[WEBSERVER] Existing config.json found, loading...")); + DeserializationError err = deserializeJson(doc, configFile); + configFile.close(); + if (err) { + // Log the error but proceed, allowing the new config to potentially fix it + Serial.print(F("[WEBSERVER] Error parsing existing config.json: ")); + Serial.println(err.f_str()); + } + } else { + Serial.println(F("[WEBSERVER] config.json not found, starting with empty doc.")); + } + + // Iterate through incoming parameters from the web form and update the document for (int i = 0; i < request->params(); i++) { - const AsyncWebParameter* p = request->getParam(i); + const AsyncWebParameter *p = request->getParam(i); String n = p->name(); String v = p->value(); + + Serial.printf("[SAVE] Param: %s = %s\n", n.c_str(), v.c_str()); + + // Specific type casting for known boolean/integer fields if (n == "brightness") doc[n] = v.toInt(); else if (n == "flipDisplay") doc[n] = (v == "true" || v == "on" || v == "1"); else if (n == "twelveHourToggle") doc[n] = (v == "true" || v == "on" || v == "1"); - else if (n == "showDayOfWeek") doc[n] = (v == "true" || v == "on" || v == "1"); + else if (n == "showDayOfWeek") doc[n] = (v == "true" || v == "on" || v == "1"); else if (n == "showHumidity") doc[n] = (v == "true" || v == "on" || v == "1"); - else doc[n] = v; + else if (n == "dimStartHour") doc[n] = v.toInt(); + else if (n == "dimStartMinute") doc[n] = v.toInt(); + else if (n == "dimEndHour") doc[n] = v.toInt(); + else if (n == "dimEndMinute") doc[n] = v.toInt(); + else if (n == "dimBrightness") doc[n] = v.toInt(); + else doc[n] = v; // Generic for all other string parameters } + + // --- DEBUGGING CODE --- + Serial.print(F("[SAVE] Document content before saving: ")); + serializeJson(doc, Serial); // Print the JSON document to Serial + Serial.println(); + + // Get file system info for ESP8266 + FSInfo fs_info; + LittleFS.info(fs_info); + Serial.printf("[SAVE] LittleFS total bytes: %u, used bytes: %u\n", fs_info.totalBytes, fs_info.usedBytes); + // --- END DEBUGGING CODE --- + + // Save the updated doc if (LittleFS.exists("/config.json")) { - LittleFS.rename("/config.json", "/config.bak"); + Serial.println(F("[SAVE] Renaming /config.json to /config.bak")); + LittleFS.rename("/config.json", "/config.bak"); // Create a backup } File f = LittleFS.open("/config.json", "w"); if (!f) { - Serial.println(F("[WEBSERVER] Failed to open /config.json for writing")); + Serial.println(F("[SAVE] ERROR: Failed to open /config.json for writing!")); DynamicJsonDocument errorDoc(256); - errorDoc[F("error")] = "Failed to write config"; + errorDoc[F("error")] = "Failed to write config file."; String response; serializeJson(errorDoc, response); request->send(500, "application/json", response); return; } - serializeJson(doc, f); - f.close(); + + size_t bytesWritten = serializeJson(doc, f); + Serial.printf("[SAVE] Bytes written to /config.json: %u\n", bytesWritten); + f.close(); // Close the file to ensure data is flushed + Serial.println(F("[SAVE] /config.json file closed.")); + + // Verification step + Serial.println(F("[SAVE] Attempting to open /config.json for verification.")); File verify = LittleFS.open("/config.json", "r"); + if (!verify) { + Serial.println(F("[SAVE] ERROR: Failed to open /config.json for reading during verification!")); + DynamicJsonDocument errorDoc(256); + errorDoc[F("error")] = "Verification failed: Could not re-open config file."; + String response; + serializeJson(errorDoc, response); + request->send(500, "application/json", response); + return; + } + + // --- DEBUGGING CODE --- + Serial.println(F("[SAVE] Content of /config.json during verification read:")); + // Read and print the content character by character + while (verify.available()) { + Serial.write(verify.read()); + } + Serial.println(); // Newline after file content + verify.seek(0); // Reset file pointer to beginning for deserializeJson + // --- END DEBUGGING CODE --- + DynamicJsonDocument test(2048); DeserializationError err = deserializeJson(test, verify); verify.close(); + if (err) { - Serial.print(F("[WEBSERVER] Config corrupted after save: ")); + Serial.print(F("[SAVE] Config corrupted after save: ")); Serial.println(err.f_str()); DynamicJsonDocument errorDoc(256); - errorDoc[F("error")] = "Config corrupted. Reboot cancelled."; + errorDoc[F("error")] = String("Config corrupted. Reboot cancelled. Error: ") + err.f_str(); String response; serializeJson(errorDoc, response); request->send(500, "application/json", response); return; } + + Serial.println(F("[SAVE] Config verification successful.")); DynamicJsonDocument okDoc(128); okDoc[F("message")] = "Saved successfully. Rebooting..."; String response; serializeJson(okDoc, response); request->send(200, "application/json", response); - Serial.println(F("[WEBSERVER] Rebooting...")); + Serial.println(F("[WEBSERVER] Sending success response and scheduling reboot...")); + request->onDisconnect([]() { - Serial.println(F("[WEBSERVER] Rebooting...")); + Serial.println(F("[WEBSERVER] Client disconnected, rebooting ESP...")); ESP.restart(); }); }); - server.on("/restore", HTTP_POST, [](AsyncWebServerRequest *request){ + server.on("/restore", HTTP_POST, [](AsyncWebServerRequest *request) { Serial.println(F("[WEBSERVER] Request: /restore")); if (LittleFS.exists("/config.bak")) { File src = LittleFS.open("/config.bak", "r"); @@ -340,7 +519,7 @@ void setupWebServer() { request->send(500, "application/json", response); return; } - // Copy contents + while (src.available()) { dst.write(src.read()); } @@ -367,7 +546,7 @@ void setupWebServer() { } }); - server.on("/ap_status", HTTP_GET, [](AsyncWebServerRequest *request){ + server.on("/ap_status", HTTP_GET, [](AsyncWebServerRequest *request) { Serial.print(F("[WEBSERVER] Request: /ap_status. isAPMode = ")); Serial.println(isAPMode); String json = "{\"isAP\": "; @@ -376,10 +555,88 @@ void setupWebServer() { request->send(200, "application/json", json); }); + server.on("/set_brightness", HTTP_POST, [](AsyncWebServerRequest *request) { + if (!request->hasParam("value", true)) { + request->send(400, "application/json", "{\"error\":\"Missing value\"}"); + return; + } + int newBrightness = request->getParam("value", true)->value().toInt(); + if (newBrightness < 0) newBrightness = 0; + if (newBrightness > 15) newBrightness = 15; + brightness = newBrightness; + P.setIntensity(brightness); + Serial.printf("[WEBSERVER] Set brightness to %d\n", brightness); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/set_flip", HTTP_POST, [](AsyncWebServerRequest *request) { + bool flip = false; + if (request->hasParam("value", true)) { + String v = request->getParam("value", true)->value(); + flip = (v == "1" || v == "true" || v == "on"); + } + flipDisplay = flip; + P.setZoneEffect(0, flipDisplay, PA_FLIP_UD); + P.setZoneEffect(0, flipDisplay, PA_FLIP_LR); + Serial.printf("[WEBSERVER] Set flipDisplay to %d\n", flipDisplay); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/set_twelvehour", HTTP_POST, [](AsyncWebServerRequest *request) { + bool twelveHour = false; + if (request->hasParam("value", true)) { + String v = request->getParam("value", true)->value(); + twelveHour = (v == "1" || v == "true" || v == "on"); + } + twelveHourToggle = twelveHour; + Serial.printf("[WEBSERVER] Set twelveHourToggle to %d\n", twelveHourToggle); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/set_dayofweek", HTTP_POST, [](AsyncWebServerRequest *request) { + bool showDay = false; + if (request->hasParam("value", true)) { + String v = request->getParam("value", true)->value(); + showDay = (v == "1" || v == "true" || v == "on"); + } + showDayOfWeek = showDay; + Serial.printf("[WEBSERVER] Set showDayOfWeek to %d\n", showDayOfWeek); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/set_humidity", HTTP_POST, [](AsyncWebServerRequest *request) { + bool showHumidityNow = false; + if (request->hasParam("value", true)) { + String v = request->getParam("value", true)->value(); + showHumidityNow = (v == "1" || v == "true" || v == "on"); + } + showHumidity = showHumidityNow; + Serial.printf("[WEBSERVER] Set showHumidity to %d\n", showHumidity); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/set_language", HTTP_POST, [](AsyncWebServerRequest *request) { + if (!request->hasParam("value", true)) { + request->send(400, "application/json", "{\"error\":\"Missing value\"}"); + return; + } + String lang = request->getParam("value", true)->value(); + strlcpy(language, lang.c_str(), sizeof(language)); + Serial.printf("[WEBSERVER] Set language to %s\n", language); + request->send(200, "application/json", "{\"ok\":true}"); + }); + server.begin(); Serial.println(F("[WEBSERVER] Web server started")); } +// --- handleCaptivePortal FUNCTION DEFINITION --- +void handleCaptivePortal(AsyncWebServerRequest *request) { + Serial.print(F("[WEBSERVER] Captive Portal Redirecting: ")); + Serial.println(request->url()); + request->redirect(String("http://") + WiFi.softAPIP().toString() + "/"); +} + void fetchWeather() { Serial.println(F("[WEATHER] Fetching weather data...")); if (WiFi.status() != WL_CONNECTED) { @@ -400,7 +657,7 @@ void fetchWeather() { } Serial.println(F("[WEATHER] Connecting to OpenWeatherMap...")); - const char* host = "api.openweathermap.org"; + const char *host = "api.openweathermap.org"; String url = "/data/2.5/weather?q=" + String(openWeatherCity) + "," + String(openWeatherCountry) + "&appid=" + openWeatherApiKey + "&units=" + String(weatherUnits); Serial.println(F("[WEATHER] URL: ") + url); @@ -418,9 +675,7 @@ void fetchWeather() { } Serial.println(F("[WEATHER] Connected, sending request...")); - String request = String("GET ") + url + " HTTP/1.1\r\n" + - "Host: " + host + "\r\n" + - "Connection: close\r\n\r\n"; + String request = String("GET ") + url + " HTTP/1.1\r\n" + F("Host: ") + host + F("\r\n") + F("Connection: close\r\n\r\n"); if (!client.print(request)) { Serial.println(F("[WEATHER] Failed to send request!")); @@ -430,20 +685,17 @@ void fetchWeather() { } unsigned long weatherStart = millis(); - const unsigned long weatherTimeout = 10000; // 10 seconds max + const unsigned long weatherTimeout = 10000; bool isBody = false; String payload = ""; String line = ""; - // Read headers and then the entire body at once - while ((client.connected() || client.available()) && - millis() - weatherStart < weatherTimeout && - WiFi.status() == WL_CONNECTED) { + while ((client.connected() || client.available()) && millis() - weatherStart < weatherTimeout && WiFi.status() == WL_CONNECTED) { line = client.readStringUntil('\n'); if (line.length() == 0) continue; - if (line.startsWith("HTTP/1.1")) { + if (line.startsWith(F("HTTP/1.1"))) { int statusCode = line.substring(9, 12).toInt(); if (statusCode != 200) { Serial.print(F("[WEATHER] HTTP error: ")); @@ -454,16 +706,15 @@ void fetchWeather() { } } - if (!isBody && line == "\r") { + if (!isBody && line == F("\r")) { isBody = true; // Read the entire body at once while (client.available()) { payload += (char)client.read(); } - break; // All done! + break; } yield(); - delay(1); } client.stop(); @@ -485,8 +736,8 @@ void fetchWeather() { return; } - if (doc.containsKey("main") && doc["main"].containsKey("temp")) { - float temp = doc["main"]["temp"]; + if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("temp"))) { + float temp = doc[F("main")][F("temp")]; currentTemp = String((int)round(temp)) + "º"; Serial.printf("[WEATHER] Temp: %s\n", currentTemp.c_str()); weatherAvailable = true; @@ -496,16 +747,15 @@ void fetchWeather() { return; } - if (doc.containsKey("main") && doc["main"].containsKey("humidity")) { - currentHumidity = doc["main"]["humidity"]; - Serial.printf("[WEATHER] Humidity: %d%%\n", currentHumidity); + if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("humidity"))) { + currentHumidity = doc[F("main")][F("humidity")]; + Serial.printf("[WEATHER] Humidity: %d%%\n", currentHumidity); } else { currentHumidity = -1; } - if (doc.containsKey("weather") && doc["weather"].is() && doc["weather"][0].containsKey("main")) { - const char* desc = doc["weather"][0]["main"]; - weatherDescription = String(desc); + if (doc.containsKey(F("weather")) && doc[F("weather")].is() && doc[F("weather")][0].containsKey(F("main"))) { + const char *desc = doc[F("weather")][0][F("main")]; Serial.printf("[WEATHER] Description: %s\n", weatherDescription.c_str()); } else { Serial.println(F("[WEATHER] Weather description not found in JSON payload")); @@ -514,32 +764,45 @@ void fetchWeather() { } void setup() { - Serial.begin(74880); + Serial.begin(115200); Serial.println(); Serial.println(F("[SETUP] Starting setup...")); + + if (!LittleFS.begin()) { + Serial.println(F("[ERROR] LittleFS mount failed in setup! Halting.")); + while (true) { // Halt execution if file system cannot be mounted + delay(1000); + } + } + Serial.println(F("[SETUP] LittleFS file system mounted successfully.")); + P.begin(); - P.setFont(mFactory); // Custom font - loadConfig(); // Load config before setting intensity & flip + P.setCharSpacing(0); + P.setFont(mFactory); // Custom font + loadConfig(); // Load config before setting intensity & flip P.setIntensity(brightness); P.setZoneEffect(0, flipDisplay, PA_FLIP_UD); P.setZoneEffect(0, flipDisplay, PA_FLIP_LR); Serial.println(F("[SETUP] Parola (LED Matrix) initialized")); connectWiFi(); - Serial.println(F("[SETUP] Wifi connected")); + Serial.println(F("[SETUP] Wifi connected")); setupWebServer(); - Serial.println(F("[SETUP] Webserver setup complete")); + Serial.println(F("[SETUP] Webserver setup complete")); Serial.println(F("[SETUP] Setup complete")); Serial.println(); printConfigToSerial(); - setupTime(); // Start NTP sync process + setupTime(); // Start NTP sync process displayMode = 0; lastSwitch = millis(); lastColonBlink = millis(); } void loop() { + if (isAPMode) { + dnsServer.processNextRequest(); + } - // --- AP Mode Animation --- + // --- AP Mode Animation remains unchanged --- static unsigned long apAnimTimer = 0; static int apAnimFrame = 0; if (isAPMode) { @@ -550,12 +813,60 @@ void loop() { } P.setTextAlignment(PA_CENTER); switch (apAnimFrame % 3) { - case 0: P.print("AP©"); break; - case 1: P.print("APª"); break; - case 2: P.print("AP«"); break; + case 0: P.print(F("A P ©")); break; + case 1: P.print(F("A P ª")); break; + case 2: P.print(F("A P «")); break; } - yield(); // Let system do background work - return; // Don't run normal display logic + yield(); + return; + } + + // --- Dimming logic with hour and minute --- + time_t now = time(nullptr); + struct tm timeinfo; + localtime_r(&now, &timeinfo); + int curHour = timeinfo.tm_hour; + int curMinute = timeinfo.tm_min; + + int curTotal = curHour * 60 + curMinute; + int startTotal = dimStartHour * 60 + dimStartMinute; + int endTotal = dimEndHour * 60 + dimEndMinute; + bool isDimming = false; + + if (dimmingEnabled) { + if (startTotal < endTotal) { + // Dimming in same day (e.g. 18:45 to 23:00) + isDimming = (curTotal >= startTotal && curTotal < endTotal); + } else { + // Dimming overnight (e.g. 18:45 to 08:30) + isDimming = (curTotal >= startTotal || curTotal < endTotal); + } + if (isDimming) { + P.setIntensity(dimBrightness); + } else { + P.setIntensity(brightness); + } + } else { + P.setIntensity(brightness); + } + + // --- NON-BLOCKING: Show IP after WiFi connect for 1 scrolls, then resume normal display --- + if (showingIp) { + if (P.displayAnimate()) { + ipDisplayCount++; + if (ipDisplayCount < ipDisplayMax) { + // Scroll again + P.displayScroll(pendingIpToShow.c_str(), PA_CENTER, PA_SCROLL_LEFT, 120); + } else { + // Done showing IP, resume normal display + showingIp = false; + P.displayClear(); + displayMode = 0; // Force clock mode + lastSwitch = millis(); // Reset timer so clock mode gets full duration + } + } + yield(); + return; // Skip normal display logic while showing IP } static bool colonVisible = true; @@ -568,37 +879,34 @@ void loop() { static unsigned long ntpAnimTimer = 0; static int ntpAnimFrame = 0; static bool tzSetAfterSync = false; - static time_t lastPrint = 0; - // WEATHER FETCH static unsigned long lastFetch = 0; - const unsigned long fetchInterval = 300000; // 5 minutes + const unsigned long fetchInterval = 300000; // 5 minutes - // NTP state machine switch (ntpState) { - case NTP_IDLE: - break; - case NTP_SYNCING: { - time_t now = time(nullptr); - if (now > 1000) { - Serial.println(F("\n[TIME] NTP sync successful.")); - ntpSyncSuccessful = true; - ntpState = NTP_SUCCESS; - } else if (millis() - ntpStartTime > ntpTimeout || ntpRetryCount > maxNtpRetries) { - Serial.println(F("\n[TIME] NTP sync failed.")); - ntpSyncSuccessful = false; - ntpState = NTP_FAILED; - } else { - if (millis() - ntpStartTime > (ntpRetryCount * 1000)) { - Serial.print("."); - ntpRetryCount++; + case NTP_IDLE: break; + case NTP_SYNCING: + { + time_t now = time(nullptr); + if (now > 1000) { + Serial.println(F("\n[TIME] NTP sync successful.")); + ntpSyncSuccessful = true; + ntpState = NTP_SUCCESS; + } else if (millis() - ntpStartTime > ntpTimeout || ntpRetryCount > maxNtpRetries) { + Serial.println(F("\n[TIME] NTP sync failed.")); + ntpSyncSuccessful = false; + ntpState = NTP_FAILED; + } else { + if (millis() - ntpStartTime > ((unsigned long)ntpRetryCount * 1000)) { + Serial.print(F(".")); + ntpRetryCount++; + } } + break; } - break; - } case NTP_SUCCESS: if (!tzSetAfterSync) { - const char* posixTz = ianaToPosix(timeZone); + const char *posixTz = ianaToPosix(timeZone); setenv("TZ", posixTz, 1); tzset(); tzSetAfterSync = true; @@ -611,46 +919,50 @@ void loop() { ntpAnimFrame = 0; break; } - + if (WiFi.status() == WL_CONNECTED) { if (!weatherFetchInitiated) { - weatherFetchInitiated = true; - fetchWeather(); - lastFetch = millis(); + weatherFetchInitiated = true; + fetchWeather(); + lastFetch = millis(); } if (millis() - lastFetch > fetchInterval) { - Serial.println(F("[LOOP] Fetching weather data...")); - weatherFetched = false; - fetchWeather(); - lastFetch = millis(); + Serial.println(F("[LOOP] Fetching weather data...")); + weatherFetched = false; + fetchWeather(); + lastFetch = millis(); } } else { weatherFetchInitiated = false; } - // Time display logic - time_t now = time(nullptr); - struct tm timeinfo; - localtime_r(&now, &timeinfo); + const char *const *daysOfTheWeek = getDaysOfWeek(language); + const char *daySymbol = daysOfTheWeek[timeinfo.tm_wday]; - int dayOfWeek = timeinfo.tm_wday; - char* daySymbol = daysOfTheWeek[dayOfWeek]; - - char timeStr[9]; // enough for "12:34 AM" + char timeStr[9]; if (twelveHourToggle) { int hour12 = timeinfo.tm_hour % 12; if (hour12 == 0) hour12 = 12; - sprintf(timeStr, "%d:%02d", hour12, timeinfo.tm_min); + sprintf(timeStr, " %d:%02d", hour12, timeinfo.tm_min); } else { - sprintf(timeStr, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); + sprintf(timeStr, " %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); } - // Only prepend day symbol if showDayOfWeek is true + char timeSpacedStr[20]; + int j = 0; + for (int i = 0; timeStr[i] != '\0'; i++) { + timeSpacedStr[j++] = timeStr[i]; + if (timeStr[i + 1] != '\0') { + timeSpacedStr[j++] = ' '; + } + } + timeSpacedStr[j] = '\0'; + String formattedTime; if (showDayOfWeek) { - formattedTime = String(daySymbol) + " " + String(timeStr); + formattedTime = String(daySymbol) + " " + String(timeSpacedStr); } else { - formattedTime = String(timeStr); + formattedTime = String(timeSpacedStr); } unsigned long displayDuration = (displayMode == 0) ? clockDuration : weatherDuration; @@ -663,27 +975,28 @@ void loop() { P.setTextAlignment(PA_CENTER); static bool weatherWasAvailable = false; - if (displayMode == 0) { // Clock + if (displayMode == 0) { + P.setCharSpacing(0); if (ntpState == NTP_SYNCING) { if (millis() - ntpAnimTimer > 750) { ntpAnimTimer = millis(); switch (ntpAnimFrame % 3) { - case 0: P.print("sync®"); break; - case 1: P.print("sync¯"); break; - case 2: P.print("sync°"); break; + case 0: P.print(F("$ ®")); break; + case 1: P.print(F("$ ¯")); break; + case 2: P.print(F("$ °")); break; } ntpAnimFrame++; } } else if (!ntpSyncSuccessful) { - P.print("no ntp"); + P.print(F("? /")); } else { String timeString = formattedTime; if (!colonVisible) timeString.replace(":", " "); P.print(timeString); } - } else { // Weather mode + } else { + P.setCharSpacing(1); if (weatherAvailable) { - // --- Weather display string with humidity toggle and 99% cap --- String weatherDisplay; if (showHumidity && currentHumidity != -1) { int cappedHumidity = (currentHumidity > 99) ? 99 : currentHumidity; @@ -701,18 +1014,13 @@ void loop() { if (ntpSyncSuccessful) { String timeString = formattedTime; if (!colonVisible) timeString.replace(":", " "); + P.setCharSpacing(0); P.print(timeString); } else { - P.print("no temp"); + P.print(F("? *")); } } } - static unsigned long lastDisplayUpdate = 0; - const unsigned long displayUpdateInterval = 50; - if (millis() - lastDisplayUpdate >= displayUpdateInterval) { - lastDisplayUpdate = millis(); - } - yield(); } \ No newline at end of file diff --git a/data/config.json b/data/config.json index 9044b46..601ebce 100644 --- a/data/config.json +++ b/data/config.json @@ -14,5 +14,6 @@ "ntpServer2": "time.nist.gov", "twelveHourToggle": false, "showDayOfWeek": true, - "showHumidity": false + "showHumidity": false, + "language": "en" } \ No newline at end of file diff --git a/data/index.html b/data/index.html index bee7559..e8470cc 100644 --- a/data/index.html +++ b/data/index.html @@ -72,6 +72,7 @@ margin-top: 0.75rem; } input[type="text"], + input[type="time"], input[type="password"], input[type="number"], select { width: 100%; padding: 0.75rem; @@ -81,6 +82,12 @@ color: #ffffff; font-size: 1rem; appearance: none; } + + input[type="time"]:disabled , + input[type="number"]:disabled { + color: rgba(255, 255, 255, 0.250); + } + input[type="submit"] { background-color: #007aff; color: white; padding: 0.9rem; font-size: 1rem; @@ -91,6 +98,10 @@ background-color: #005ecb; } +input[type="time"]::-webkit-calendar-picker-indicator{ + filter: invert(100%); +} + input:-webkit-autofill, input:-webkit-autofill:focus, input:-webkit-autofill:hover { @@ -389,6 +400,38 @@ textarea::placeholder { + + + +
@@ -422,7 +465,7 @@ textarea::placeholder { @@ -431,7 +474,7 @@ textarea::placeholder { @@ -440,7 +483,7 @@ textarea::placeholder { @@ -449,17 +492,45 @@ textarea::placeholder { - + +

+ + + + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
- @@ -511,6 +582,7 @@ window.onload = function () { document.getElementById('weatherUnits').value = data.weatherUnits || 'metric'; document.getElementById('clockDuration').value = (data.clockDuration || 10000) / 1000; document.getElementById('weatherDuration').value = (data.weatherDuration || 5000) / 1000; + document.getElementById('language').value = data.language || ''; // Advanced: document.getElementById('brightnessSlider').value = typeof data.brightness !== "undefined" ? data.brightness : 10; document.getElementById('brightnessValue').textContent = document.getElementById('brightnessSlider').value; @@ -520,6 +592,33 @@ window.onload = function () { document.getElementById('twelveHourToggle').checked = !!data.twelveHourToggle; document.getElementById('showDayOfWeek').checked = !!data.showDayOfWeek; document.getElementById('showHumidity').checked = !!data.showHumidity; + + // Dimming controls +const dimmingEnabledEl = document.getElementById('dimmingEnabled'); +const isDimming = (data.dimmingEnabled === true || data.dimmingEnabled === "true" || data.dimmingEnabled === 1); +dimmingEnabledEl.checked = isDimming; + +// Defer field enabling until checkbox state is rendered +setTimeout(() => { + setDimmingFieldsEnabled(dimmingEnabledEl.checked); +}, 0); + +dimmingEnabledEl.addEventListener('change', function () { + setDimmingFieldsEnabled(this.checked); +}); + + document.getElementById('dimStartTime').value = + (data.dimStartHour !== undefined ? String(data.dimStartHour).padStart(2, '0') : "18") + ":" + + (data.dimStartMinute !== undefined ? String(data.dimStartMinute).padStart(2, '0') : "00"); + +document.getElementById('dimEndTime').value = + (data.dimEndHour !== undefined ? String(data.dimEndHour).padStart(2, '0') : "08") + ":" + + (data.dimEndMinute !== undefined ? String(data.dimEndMinute).padStart(2, '0') : "00"); + + document.getElementById('dimBrightness').value = (data.dimBrightness !== undefined ? data.dimBrightness : 2); + + setDimmingFieldsEnabled(!!data.dimmingEnabled); + // Auto-detect browser's timezone if not set in config if (!data.timeZone) { try { @@ -581,6 +680,24 @@ async function submitConfig(event) { formData.set('showDayOfWeek', document.getElementById('showDayOfWeek').checked ? 'on' : ''); formData.set('showHumidity', document.getElementById('showHumidity').checked ? 'on' : ''); + //dimming + formData.set('dimmingEnabled', document.getElementById('dimmingEnabled').checked ? 'true' : 'false'); + const dimStart = document.getElementById('dimStartTime').value; // "18:45" + const dimEnd = document.getElementById('dimEndTime').value; // "08:30" + + // Parse hour and minute + if (dimStart) { + const [startHour, startMin] = dimStart.split(":").map(x => parseInt(x, 10)); + formData.set('dimStartHour', startHour); + formData.set('dimStartMinute', startMin); + } + if (dimEnd) { + const [endHour, endMin] = dimEnd.split(":").map(x => parseInt(x, 10)); + formData.set('dimEndHour', endHour); + formData.set('dimEndMinute', endMin); + } + formData.set('dimBrightness', document.getElementById('dimBrightness').value); + const params = new URLSearchParams(); for (const pair of formData.entries()) { params.append(pair[0], pair[1]); @@ -806,6 +923,72 @@ const toggle = document.querySelector('.collapsible-toggle'); content.style.height = 'auto'; } +let brightnessDebounceTimeout = null; + +function setBrightnessLive(val) { + // Cancel the previous timeout if it exists + if (brightnessDebounceTimeout) { + clearTimeout(brightnessDebounceTimeout); + } + // Set a new timeout + brightnessDebounceTimeout = setTimeout(() => { + fetch('/set_brightness', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + encodeURIComponent(val) + }) + .then(res => res.json()) + .catch(e => {}); // Optionally handle errors + }, 150); // 150ms debounce, adjust as needed +} + +function setFlipDisplay(val) { + fetch('/set_flip', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + (val ? 1 : 0) + }); +} + +function setTwelveHour(val) { + fetch('/set_twelvehour', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + (val ? 1 : 0) + }); +} + +function setShowDayOfWeek(val) { + fetch('/set_dayofweek', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + (val ? 1 : 0) + }); +} + +function setShowHumidity(val) { + fetch('/set_humidity', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + (val ? 1 : 0) + }); +} + +function setLanguage(val) { + fetch('/set_language', { + method: 'POST', + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "value=" + encodeURIComponent(val) + }); +} + +// --- Dimming Controls Logic --- +function setDimmingFieldsEnabled(enabled) { + document.getElementById('dimStartTime').disabled = !enabled; + document.getElementById('dimEndTime').disabled = !enabled; + document.getElementById('dimBrightness').disabled = !enabled; +} + \ No newline at end of file diff --git a/days_lookup.h b/days_lookup.h new file mode 100644 index 0000000..3a0deee --- /dev/null +++ b/days_lookup.h @@ -0,0 +1,49 @@ +#ifndef DAYS_LOOKUP_H +#define DAYS_LOOKUP_H + +typedef struct { + const char* lang; + const char* days[7]; // Sunday to Saturday (tm_wday order) +} DaysOfWeekMapping; + +const DaysOfWeekMapping days_mappings[] = { + { "af", { "s&u&n", "m&a&a", "d&i&n", "w&o&e", "d&o&n", "v&r&y", "s&o&n" } }, + { "cs", { "n&e&d", "p&o&n", "u&t&e", "s&t&r", "c&t&v", "p&a&t", "s&o&b" } }, + { "da", { "s&o&n", "m&a&n", "t&i&r", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "de", { "s&o&n", "m&o&n", "d&i&e", "m&i&t", "d&o&n", "f&r&e", "s&a&m" } }, + { "en", { "s&u&n", "m&o&n", "t&u&e", "w&e&d", "t&h&u", "f&r&i", "s&a&t" } }, + { "eo", { "d&i&m", "l&u&n", "m&a&r", "m&e&r", "j&a&u", "v&e&n", "s&a&b" } }, + { "es", { "d&o&m", "l&u&n", "m&a&r", "m&i&e", "j&u&e", "v&i&e", "s&a&b" } }, + { "et", { "p&a", "e&s", "t&e", "k&o", "n&e", "r&e", "l&a" } }, + { "fi", { "s&u&n", "m&a&a", "t&i&s", "k&e&s", "t&o&r", "p&e&r", "l&a&u" } }, + { "fr", { "d&i&m", "l&u&n", "m&a&r", "m&e&r", "j&e&u", "v&e&n", "s&a&m" } }, + { "hr", { "n&e&d", "p&o&n", "u&t&o", "s&r&i", "c&e&t", "p&e&t", "s&u&b" } }, + { "hu", { "v&a&s", "h&e&t", "k&e&d", "s&z&e", "c&s&u", "p&e&t", "s&z&o" } }, + { "it", { "d&o&m", "l&u&n", "m&a&r", "m&e&r", "g&i&o", "v&e&n", "s&a&b" } }, + { "ja", { "±", "²", "³", "´", "µ", "¶", "·" } }, + { "lt", { "s&e&k", "p&i&r", "a&n&t", "t&r&e", "k&e&t", "p&e&n", "s&e&s" } }, + { "lv", { "s&v&e", "p&i&r", "o&t&r", "t&r&e", "c&e&t", "p&i&e", "s&e&s" } }, + { "nl", { "z&o&n", "m&a&a", "d&i&n", "w&o&e", "d&o&n", "v&r&i", "z&a&t" } }, + { "no", { "s&o&n", "m&a&n", "t&i&r", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "pl", { "n&i&e", "p&o&n", "w&t&o", "s&r&o", "c&z&w", "p&i&a", "s&o&b" } }, + { "pt", { "d&o&m", "s&e&g", "t&e&r", "q&u&a", "q&u&i", "s&e&x", "s&a&b" } }, + { "ro", { "d&u&m", "l&u&n", "m&a&r", "m&i&e", "j&o&i", "v&i&n", "s&a&m" } }, + { "sk", { "n&e&d", "p&o&n", "u&t&o", "s&t&r", "s&t&v", "p&i&a", "s&o&b" } }, + { "sl", { "n&e&d", "p&o&n", "t&o&r", "s&r&e", "c&e&t", "p&e&t", "s&o&b" } }, + { "sv", { "s&o&n", "m&a&n", "t&i&s", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "sw", { "j&p&l", "j&u&m", "j&t&t", "j&t&n", "a&l&k", "i&j&m", "j&m&s" } }, + { "tr", { "p&a&z", "p&a&z", "s&a&l", "c&a&r", "p&e&r", "c&u&m", "c&u&m" } } +}; + +#define DAYS_MAPPINGS_COUNT (sizeof(days_mappings)/sizeof(days_mappings[0])) + +inline const char* const* getDaysOfWeek(const char* lang) { + for (size_t i = 0; i < DAYS_MAPPINGS_COUNT; i++) { + if (strcmp(lang, days_mappings[i].lang) == 0) + return days_mappings[i].days; + } + // fallback to English if not found + return days_mappings[4].days; // "en" is index 4 +} + +#endif // DAYS_LOOKUP_H \ No newline at end of file diff --git a/mfactoryfont.h b/mfactoryfont.h index 029fded..db2fecf 100644 --- a/mfactoryfont.h +++ b/mfactoryfont.h @@ -3,7 +3,7 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = { - 1, 0, // 0 - 'Empty Cell' +1, 0, // 0 - 'Empty Cell' 1, 0, // 1 - 'Sad Smiley' 1, 0, // 2 - 'Happy Smiley' 1, 0, // 3 - 'Heart' @@ -39,18 +39,18 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 1, 0, // 33 - '!' 1, 0, // 34 - '""' 1, 0, // 35 - '#' - 11, 157, 149, 245, 1, 253, 21, 253, 1, 5, 253, 5, // 36 - '$' + 16, 72, 84, 36, 0, 12, 112, 12, 0, 124, 4, 120, 0, 56, 68, 68, 0, // 36 - '$' 6, 66, 37, 18, 72, 164, 66, // 37 - '%' - 11, 157, 149, 245, 1, 253, 129, 253, 1, 253, 5, 253, // 38 - '&' + 1, 1, // 38 - '&' 1, 0, // 39 - '' 1, 0, // 40 - '(' 1, 0, // 41 - ')' - 11, 253, 9, 253, 1, 253, 133, 253, 1, 253, 5, 253, // 42 - '*' + 18, 4, 124, 4, 0, 124, 84, 68, 0, 124, 4, 124, 4, 120, 0, 124, 20, 8, 0, // 42 - '*' 1, 0, // 43 - '+' - 1, 64, // 44 - ',' - 2, 8, 8, // 45 - '-' + 2, 64, 0, // 44 - ',' + 2, 8, 8, // 45 - '-' 1, 128, // 46 - '.' - 11, 5, 253, 5, 1, 253, 129, 253, 1, 253, 149, 133, // 47 - '/' + 12, 124, 4, 120, 0, 4, 124, 4, 0, 124, 20, 8, 0, // 47 - '/' 3, 126, 129, 126, // 48 - '0' 2, 2, 255, // 49 - '1' 3, 194, 177, 142, // 50 - '2' @@ -64,10 +64,10 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 1, 36, // 58 - ':' 1, 0, // 59 - ';' 1, 0, // 60 - '<' - 11, 253, 21, 5, 1, 253, 37, 217, 1, 133, 253, 133, // 61 - '=' + 1, 0, // 61 - '=' 1, 0, // 62 - '>' - 11, 253, 65, 253, 1, 253, 149, 133, 1, 253, 133, 121, // 63 - '?' - 11, 5, 253, 5, 1, 253, 17, 253, 1, 253, 129, 253, // 64 - '@' + 8, 124, 4, 120, 0, 56, 68, 56, 0, // 63 - '?' + 1, 250, // 64 - '@' 4, 254, 17, 17, 254, // 65 - 'A' 4, 255, 137, 137, 118, // 66 - 'B' 4, 126, 129, 129, 66, // 67 - 'C' @@ -100,41 +100,41 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 3, 8, 4, 8, // 94 - '^' 3, 32, 32, 32, // 95 - '_' 2, 4, 8, // 96 - '`' - 3, 123, 43, 123, // 97 - 'a' - 3, 62, 40, 56, // 98 - 'b' - 3, 56, 68, 68, // 99 - 'c' - 3, 56, 40, 62, // 100 - 'd' - 3, 124, 84, 68, // 101 - 'e' - 3, 8, 62, 10, // 102 - 'f' - 3, 56, 84, 116, // 103 - 'g' - 3, 62, 8, 56, // 104 - 'h' - 1, 250, // 105 - 'i' - 2, 32, 58, // 106 - 'j' - 3, 60, 16, 40, // 107 - 'k' - 1, 60, // 108 - 'l' - 5, 124, 4, 124, 4, 120, // 109 - 'm' - 3, 124, 4, 120, // 110 - 'n' - 3, 56, 68, 56, // 111 - 'o' - 3, 124, 20, 8, // 112 - 'p' - 3, 56, 40, 120, // 113 - 'q' - 3, 56, 8, 24, // 114 - 'r' - 3, 72, 84, 36, // 115 - 's' - 3, 4, 124, 4, // 116 - 't' - 3, 56, 32, 56, // 117 - 'u' - 3, 24, 32, 24, // 118 - 'v' - 3, 56, 48, 56, // 119 - 'w' - 3, 40, 16, 40, // 120 - 'x' - 3, 12, 112, 12, // 121 - 'y' - 3, 36, 52, 44, // 122 - 'z' + 3, 249, 21, 249, // 97 - 'a' + 3, 253, 149, 105, // 98 - 'b' + 3, 121, 133, 133, // 99 - 'c' + 3, 253, 133, 121, // 100 - 'd' + 3, 253, 149, 133, // 101 - 'e' + 3, 253, 21, 5, // 102 - 'f' + 3, 121, 149, 245, // 103 - 'g' + 3, 253, 17, 253, // 104 - 'h' + 3, 133, 253, 133, // 105 - 'i' + 3, 65, 129, 125, // 106 - 'j' + 3, 253, 17, 237, // 107 - 'k' + 3, 253, 129, 129, // 108 - 'l' + 3, 253, 9, 253, // 109 - 'm' + 3, 253, 5, 249, // 110 - 'n' + 3, 121, 133, 121, // 111 - 'o' + 3, 253, 21, 9, // 112 - 'p' + 3, 57, 69, 249, // 113 - 'q' + 3, 253, 21, 233, // 114 - 'r' + 3, 137, 149, 101, // 115 - 's' + 3, 5, 253, 5, // 116 - 't' + 3, 253, 129, 253, // 117 - 'u' + 3, 61, 193, 61, // 118 - 'v' + 3, 253, 65, 253, // 119 - 'w' + 3, 237, 17, 237, // 120 - 'x' + 3, 13, 241, 13, // 121 - 'y' + 3, 197, 181, 141, // 122 - 'z' 1, 0, // 123 - '{' 1, 0, // 124 - '|' 1, 0, // 125 - '}' - 0, // 126 - '~' + 0, // 126 - '~' 0, // 127 - '' - 0, // 128 - '€' + 0, // 128 - '€' 0, // 129 - '' 0, // 130 - '‚' - 0, // 131 - 'ƒ' + 0, // 131 - 'ƒ' 0, // 132 - '„' 0, // 133 - '…' 0, // 134 - '†' @@ -177,16 +177,16 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 8, 224, 224, 0, 252, 252, 0, 255, 255, // 171 - '«' 0, // 172 - '¬' 0, // 173 - '­' - 5, 64, 0, 0, 0, 0, // 174 - '®' + 5, 64, 0, 0, 0, 0, // 174 - '®' 5, 64, 0, 64, 0, 0, // 175 - '¯' - 5, 64, 0, 64, 0, 64, // 176 - '°' - 0, // 177 - '±' - 0, // 178 - '²' - 0, // 179 - '³' - 0, // 180 - '´' - 0, // 181 - 'µ' - 0, // 182 - '¶' - 0, // 183 - '·' + 5, 64, 0, 64, 0, 64, // 176 - '°' + 6, 254, 146, 146, 146, 254, 0, // 177 - '±' + 7, 128, 126, 42, 42, 170, 254, 0, // 178 - '²' + 8, 128, 152, 64, 62, 80, 136, 128, 0, // 179 - '³' + 8, 72, 40, 152, 254, 16, 40, 68, 0, // 180 - '´' + 8, 68, 36, 20, 254, 20, 36, 68, 0, // 181 - 'µ' + 8, 168, 232, 172, 250, 172, 232, 168, 0, // 182 - '¶' + 8, 128, 136, 136, 254, 136, 136, 128, 0, // 183 - '·' 0, // 184 - '¸' 0, // 185 - '¹' 3, 4, 10, 4, // 186 - 'º'