mirror of
https://github.com/Legolas-2025/Standalone-electricity-price-ticker.git
synced 2026-08-17 12:34:55 +02:00
VERSION 6.1.2 CHANGES (2026-03-12): ---------------------------------- Fixed LED indicator not working properly (constantly dimly lit, not responding to price patterns, staying on when LCD backlight turns off). Root cause: Mixing analogWrite() and digitalWrite() on ESP32 causes PWM channel to remain active even after digitalWrite(LOW), resulting in dim LED. LED now properly turns off when backlight is off (areLedsOn = false). LED now correctly shows breathing/blinking patterns based on price thresholds. VERSION 6.1.1 CHANGES (2026-03-07): ---------------------------------- Fixed daily lowest/highest hourly indicator (and daily average) now correctly considers ALL hourly averages, including negative values and 0.0 (which can be real prices). The daily average is now computed using the number of valid hours actually present in the dataset (validHourCount), instead of always dividing by 24. NOTE: - This version includes all fixes from v6.1.1 plus the LED fix.
1656 lines
54 KiB
Arduino
1656 lines
54 KiB
Arduino
/*
|
||
ESP32_standalone_electricity_ticker_6_1_2.ino
|
||
-----------------------------------------------------
|
||
|
||
VERSION 6.1.2 CHANGES (2026-03-12):
|
||
----------------------------------
|
||
FIX:
|
||
- Fixed LED indicator not working properly (constantly dimly lit, not responding
|
||
to price patterns, staying on when LCD backlight turns off).
|
||
- Root cause: Mixing analogWrite() and digitalWrite() on ESP32 causes PWM channel
|
||
to remain active even after digitalWrite(LOW), resulting in dim LED.
|
||
- Solution: Use analogWrite() consistently for all whiteLedPin control:
|
||
* analogWrite(whiteLedPin, 0) instead of digitalWrite(whiteLedPin, LOW)
|
||
* analogWrite(whiteLedPin, 255) instead of digitalWrite(whiteLedPin, HIGH)
|
||
- LED now properly turns off when backlight is off (areLedsOn = false).
|
||
- LED now correctly shows breathing/blinking patterns based on price thresholds.
|
||
|
||
VERSION 6.1.1 CHANGES (2026-03-07):
|
||
----------------------------------
|
||
FIX:
|
||
- Daily lowest/highest hourly indicator (and daily average) now correctly considers
|
||
ALL hourly averages, including negative values and 0.0 (which can be real prices).
|
||
- The daily average is now computed using the number of valid hours actually present
|
||
in the dataset (validHourCount), instead of always dividing by 24.
|
||
|
||
NOTE:
|
||
- This version includes all fixes from v6.1.1 plus the LED fix.
|
||
*/
|
||
|
||
// ========================================================================
|
||
// Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 6.1.2
|
||
// 15-MINUTE DETAIL MODE, DST / TIMEZONE FIXED (CET <-> CEST automatic)
|
||
// ========================================================================
|
||
|
||
#include <Wire.h>
|
||
#include <LiquidCrystal_I2C.h>
|
||
#include <WiFi.h>
|
||
#include <HTTPClient.h>
|
||
#include <ArduinoJson.h>
|
||
#include <math.h>
|
||
#include <Preferences.h> // For non-volatile storage
|
||
#include <DNSServer.h> // For captive portal
|
||
#include <WebServer.h> // For provisioning web server
|
||
#include <time.h>
|
||
|
||
// ========================================================================
|
||
// CONFIG STRUCT
|
||
// ========================================================================
|
||
|
||
struct Config {
|
||
static const int JSON_BUFFER_SIZE = 4096;
|
||
static const int HTTP_TIMEOUT = 10000;
|
||
static const int HTTP_CONNECT_TIMEOUT = 5000;
|
||
static const int WIFI_RETRY_MAX = 20;
|
||
static const int NTP_TIMEOUT = 15000;
|
||
static const int LOOP_UPDATE_INTERVAL = 100;
|
||
};
|
||
|
||
#define DEBUG_LEVEL 2
|
||
#define HAS_WHITE_LED true
|
||
|
||
// ========================================================================
|
||
// GLOBALS
|
||
// ========================================================================
|
||
|
||
const int whiteLedPin = 5;
|
||
const int builtinLedPin = 21;
|
||
const int buttonPin = 4;
|
||
const int presencePin = 9;
|
||
|
||
bool ledsConnected = HAS_WHITE_LED;
|
||
bool areLedsOn = false;
|
||
|
||
int breatheValue = 0;
|
||
int breatheDir = 1;
|
||
unsigned long lastBreatheMillis = 0;
|
||
const int breatheInterval = 10;
|
||
|
||
bool blinkState = false;
|
||
unsigned long lastBlinkMillis = 0;
|
||
const int BLINK_INTERVAL_1000MS = 1000;
|
||
const int BLINK_INTERVAL_500MS = 500;
|
||
const int BLINK_INTERVAL_200MS = 200;
|
||
|
||
bool doubleBlinkState = false;
|
||
int doubleBlinkCount = 0;
|
||
unsigned long lastDoubleBlinkMillis = 0;
|
||
const int DOUBLE_BLINK_FAST_INTERVAL = 200;
|
||
const int DOUBLE_BLINK_LONG_ON_INTERVAL = 400;
|
||
const int DOUBLE_BLINK_PAUSE_INTERVAL = 1000;
|
||
|
||
const float PRICE_THRESHOLD_0_05 = 0.05;
|
||
const float PRICE_THRESHOLD_0_15 = 0.15;
|
||
const float PRICE_THRESHOLD_0_25 = 0.25;
|
||
const float PRICE_THRESHOLD_0_35 = 0.35;
|
||
const float PRICE_THRESHOLD_0_50 = 0.50;
|
||
|
||
LiquidCrystal_I2C lcd(0x27, 20, 4);
|
||
|
||
const char* api_url = "https://api.energy-charts.info/price?bzn=SI";
|
||
|
||
// Scheduling & retry
|
||
time_t nextScheduledFetchTime = 0;
|
||
int lastSuccessfulFetchDay = 0;
|
||
int httpGetRetryCount = 0;
|
||
const int HTTP_GET_RETRY_MAX = 5;
|
||
const int HTTP_GET_BACKOFF_FACTOR = 2;
|
||
time_t lastSuccessfulFetchTime = 0;
|
||
|
||
int apiSuccessCount = 0;
|
||
int apiFailCount = 0;
|
||
|
||
// Timezone
|
||
const long gmtOffset_sec = 3600;
|
||
const int daylightOffset_sec = 3600;
|
||
const char* TZ_CET_CEST = "CET-1CEST,M3.5.0/02:00,M10.5.0/03:00";
|
||
|
||
// Price computation
|
||
const bool APPLY_FEES_AND_VAT = true;
|
||
const float POWER_COMPANY_FEE_PERCENTAGE = 12.0;
|
||
const float VAT_PERCENTAGE = 22.0;
|
||
|
||
// Button handling
|
||
int buttonState = 0;
|
||
int lastButtonState = 0;
|
||
unsigned long lastDebounceTime = 0;
|
||
unsigned long buttonPressStartTime = 0;
|
||
bool longPressDetected = false;
|
||
const unsigned long debounceDelay = 50;
|
||
const unsigned long longPressThreshold = 3000;
|
||
|
||
unsigned long lastClickTime = 0;
|
||
const unsigned long doubleClickWindow = 500;
|
||
bool waitingForDoubleClick = false;
|
||
bool pendingClick = false;
|
||
|
||
// Auto scroll timeout
|
||
unsigned long lastButtonActivity = 0;
|
||
const unsigned long autoScrollTimeout = 10000;
|
||
bool autoScrollExecuted = false;
|
||
|
||
// Time-based display refresh markers
|
||
unsigned long lastHourlyRefresh = 0;
|
||
unsigned long last15MinRefresh = 0;
|
||
|
||
// Secondary list / menu
|
||
int secondaryListOffset = 0;
|
||
// INCREASED: now 20 lines total (16 old + 4 NVS status)
|
||
const int SECONDARY_LIST_TOTAL_LINES = 20;
|
||
const int SECONDARY_LIST_SCROLL_INCREMENT = 4;
|
||
|
||
// Backlight / presence
|
||
const unsigned long backlightOffDelay = 30000;
|
||
unsigned long lastPresenceTime = 0;
|
||
bool presenceSensorConnected = false;
|
||
|
||
// Loop pacing
|
||
unsigned long lastLoopUpdate = 0;
|
||
|
||
// Display state
|
||
enum DisplayState { CURRENT_PRICES, CUSTOM_MESSAGE, NO_DATA_OFFSET };
|
||
DisplayState displayState = CURRENT_PRICES;
|
||
int timeOffsetHours = 0;
|
||
|
||
enum ListType { PRIMARY_LIST, SECONDARY_LIST };
|
||
ListType currentList = PRIMARY_LIST;
|
||
|
||
// JSON doc and data flags
|
||
StaticJsonDocument<Config::JSON_BUFFER_SIZE> doc;
|
||
bool isTodayDataAvailable = false;
|
||
float averagePrice = 0.0;
|
||
int lowestPriceIndex = -1;
|
||
int highestPriceIndex = -1;
|
||
|
||
// NVS and provisioning
|
||
Preferences preferences;
|
||
DNSServer dnsServer;
|
||
WebServer server(80);
|
||
|
||
const char* ap_ssid = "MyTicker_Setup";
|
||
bool inProvisioningMode = false;
|
||
bool needsRestart = false;
|
||
|
||
// Time sync flag
|
||
bool isTimeSynced = false;
|
||
bool initialBoot = true;
|
||
|
||
// Track the current local day so we can detect midnight rollover
|
||
int trackedDay = -1;
|
||
|
||
// NVS-related state (for secondary menu / debug)
|
||
bool nvsDataLoadedForToday = false;
|
||
bool nvsDataPresent = false;
|
||
int nvsStoredDay = -1;
|
||
int nvsStoredMonth = -1;
|
||
int nvsStoredYear = -1;
|
||
time_t nvsLastStoreTime = 0;
|
||
|
||
// Midnight retry tracking
|
||
int midnightRetryCount = 0; // Number of 10-minute retries attempted since midnight (max 5)
|
||
bool midnightPhaseActive = false; // true from midnight rollover until successful fetch for that day
|
||
|
||
// NEW: track whether last processJsonData() accepted dataset as "today"
|
||
bool lastProcessJsonAcceptedToday = false;
|
||
|
||
// ========================================================================
|
||
// CUSTOM CHARACTER BITMAPS
|
||
// ========================================================================
|
||
|
||
byte bitmap_c[8] = { B00100, B00000, B01110, B10001, B10000, B10001, B01110, B00000 };
|
||
byte bitmap_s[8] = { B00100, B00000, B01110, B10000, B01110, B00001, B11110, B00000 };
|
||
byte bitmap_z[8] = { B00100, B00000, B11111, B00010, B00100, B01000, B11111, B00000 };
|
||
byte lo_prc[] = { B00000, B00100, B00100, B00100, B10101, B01110, B00100, B00000 };
|
||
byte hi_prc[] = { B00000, B00100, B01110, B10101, B00100, B00100, B00100, B00000 };
|
||
|
||
// Forward declarations
|
||
int getCurrentQuarterHourIndex();
|
||
void displayPrices();
|
||
bool processJsonData(); // NOTE: now returns bool
|
||
void scheduleAfterMidnightFailure();
|
||
|
||
// ========================================================================
|
||
// UTILS
|
||
// ========================================================================
|
||
|
||
void debugPrint(int level, const String& message) {
|
||
#if DEBUG_LEVEL >= 1
|
||
if (DEBUG_LEVEL >= level) {
|
||
Serial.println("[DEBUG] " + message);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void lcdPrint(const char* text) {
|
||
for (int i = 0; text[i] != '\0'; i++) {
|
||
char currentChar = text[i];
|
||
if (currentChar == '^') {
|
||
lcd.write(byte(0));
|
||
} else if (currentChar == '~') {
|
||
lcd.write(byte(1));
|
||
} else if (currentChar == '|') {
|
||
lcd.write(byte(2));
|
||
} else {
|
||
lcd.print(currentChar);
|
||
}
|
||
}
|
||
}
|
||
|
||
void commaPrint(float value, int places) {
|
||
String numStr = String(value, places);
|
||
numStr.replace('.', ',');
|
||
lcd.print(numStr);
|
||
}
|
||
|
||
bool isValidUnixTime(unsigned long timestamp) {
|
||
return (timestamp > 946684800UL && timestamp < 2147483647UL);
|
||
}
|
||
|
||
// ========================================================================
|
||
// PROVISIONING
|
||
// ========================================================================
|
||
|
||
void startProvisioning() {
|
||
debugPrint(1, "Starting Wi-Fi Provisioning AP");
|
||
inProvisioningMode = true;
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("No Wi-Fi access!");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Setup Wi-Fi:");
|
||
lcd.setCursor(0, 2);
|
||
lcd.print("SSID: MyTicker_Setup");
|
||
|
||
WiFi.mode(WIFI_AP);
|
||
WiFi.softAP(ap_ssid);
|
||
|
||
IPAddress apIP = WiFi.softAPIP();
|
||
dnsServer.start(53, "*", apIP);
|
||
|
||
lcd.setCursor(0, 3);
|
||
lcd.print("IP: " + apIP.toString());
|
||
debugPrint(1, "AP IP address: " + apIP.toString());
|
||
|
||
server.onNotFound([]() {
|
||
String html = "<h3>Wi-Fi Setup</h3><form action='/save' method='get'>SSID: <input type='text' name='ssid'><br>Password: <input type='password' name='pass'><br><input type='submit' value='Save'>[...]";
|
||
server.send(200, "text/html", html);
|
||
});
|
||
|
||
server.on("/save", HTTP_GET, []() {
|
||
String newSsid = server.arg("ssid");
|
||
String newPass = server.arg("pass");
|
||
|
||
if (newSsid.length() > 0) {
|
||
preferences.begin("my-ticker", false);
|
||
preferences.putString("ssid", newSsid);
|
||
preferences.putString("pass", newPass);
|
||
preferences.end();
|
||
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Saved!");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Restarting...");
|
||
|
||
server.send(200, "text/html", "Wi-Fi credentials saved. Restarting ESP32...");
|
||
needsRestart = true;
|
||
debugPrint(1, "Credentials saved, restarting.");
|
||
} else {
|
||
server.send(200, "text/html", "Invalid credentials. Please go back and try again.");
|
||
}
|
||
});
|
||
|
||
server.begin();
|
||
debugPrint(1, "HTTP server started");
|
||
}
|
||
|
||
void handleProvisioning() {
|
||
dnsServer.processNextRequest();
|
||
server.handleClient();
|
||
}
|
||
|
||
void connectToWiFi() {
|
||
String stored_ssid = "";
|
||
String stored_pass = "";
|
||
|
||
preferences.begin("my-ticker", false);
|
||
stored_ssid = preferences.getString("ssid", "");
|
||
stored_pass = preferences.getString("pass", "");
|
||
preferences.end();
|
||
|
||
if (stored_ssid.length() > 0) {
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Elec. Rate SI v6.1.2");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Connecting...");
|
||
|
||
WiFi.begin(stored_ssid.c_str(), stored_pass.c_str());
|
||
int attempts = 0;
|
||
|
||
while (WiFi.status() != WL_CONNECTED && attempts < Config::WIFI_RETRY_MAX) {
|
||
delay(500);
|
||
lcd.setCursor(12 + (attempts % 8), 1);
|
||
lcd.print(".");
|
||
attempts++;
|
||
}
|
||
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
debugPrint(2, "WiFi connected successfully");
|
||
digitalWrite(builtinLedPin, HIGH);
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Connected!");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print(WiFi.localIP());
|
||
delay(2000);
|
||
lcd.backlight();
|
||
} else {
|
||
debugPrint(1, "WiFi connection failed after " + String(attempts) + " attempts");
|
||
digitalWrite(builtinLedPin, LOW);
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("WiFi Failed!");
|
||
startProvisioning();
|
||
}
|
||
} else {
|
||
startProvisioning();
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// LED HANDLING (v6.1.2 FIX: Use analogWrite consistently to avoid PWM issues)
|
||
// ========================================================================
|
||
|
||
void updateLeds() {
|
||
// v6.1.2 FIX: Use analogWrite(0) instead of digitalWrite(LOW) for proper PWM shutdown
|
||
if (!ledsConnected || !areLedsOn || !isTodayDataAvailable || !isTimeSynced) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Changed from digitalWrite(LOW)
|
||
breatheValue = 0;
|
||
breatheDir = 1;
|
||
blinkState = false;
|
||
doubleBlinkCount = 0;
|
||
return;
|
||
}
|
||
|
||
JsonArray prices = doc["price"];
|
||
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) return;
|
||
|
||
int currentHour = timeinfo.tm_hour;
|
||
int quarterHourIndex = getCurrentQuarterHourIndex();
|
||
int currentIntervalIndex = currentHour * 4 + quarterHourIndex;
|
||
|
||
if (currentIntervalIndex >= (int)prices.size()) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Changed from digitalWrite(LOW)
|
||
return;
|
||
}
|
||
|
||
float currentRate = prices[currentIntervalIndex].as<float>();
|
||
|
||
if (currentRate <= 0) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Changed from digitalWrite(LOW)
|
||
return;
|
||
}
|
||
|
||
float finalPrice = currentRate;
|
||
if (APPLY_FEES_AND_VAT) {
|
||
finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0);
|
||
}
|
||
finalPrice /= 1000.0;
|
||
|
||
if (finalPrice <= PRICE_THRESHOLD_0_05) {
|
||
// Breathing effect (already uses analogWrite)
|
||
if (millis() - lastBreatheMillis > breatheInterval) {
|
||
breatheValue += breatheDir;
|
||
if (breatheValue >= 255 || breatheValue <= 0) {
|
||
breatheDir *= -1;
|
||
}
|
||
analogWrite(whiteLedPin, breatheValue);
|
||
lastBreatheMillis = millis();
|
||
}
|
||
doubleBlinkCount = 0;
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_15) {
|
||
// Solid ON
|
||
analogWrite(whiteLedPin, 255); // v6.1.2: Changed from digitalWrite(HIGH)
|
||
doubleBlinkCount = 0;
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_25) {
|
||
// Slow blink (1000ms)
|
||
if (millis() - lastBlinkMillis > BLINK_INTERVAL_1000MS) {
|
||
blinkState = !blinkState;
|
||
analogWrite(whiteLedPin, blinkState ? 255 : 0); // v6.1.2: Use analogWrite
|
||
lastBlinkMillis = millis();
|
||
}
|
||
doubleBlinkCount = 0;
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_35) {
|
||
// Medium blink (500ms)
|
||
if (millis() - lastBlinkMillis > BLINK_INTERVAL_500MS) {
|
||
blinkState = !blinkState;
|
||
analogWrite(whiteLedPin, blinkState ? 255 : 0); // v6.1.2: Use analogWrite
|
||
lastBlinkMillis = millis();
|
||
}
|
||
doubleBlinkCount = 0;
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_50) {
|
||
// Double blink pattern
|
||
int targetBlinks = 2;
|
||
if (doubleBlinkCount < targetBlinks * 2) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
doubleBlinkState = !doubleBlinkState;
|
||
analogWrite(whiteLedPin, doubleBlinkState ? 255 : 0); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
||
doubleBlinkCount = 0;
|
||
lastDoubleBlinkMillis = millis();
|
||
}
|
||
}
|
||
} else {
|
||
// Triple blink pattern (highest prices)
|
||
if (doubleBlinkCount == 0) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 1) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 2) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 3) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 4) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 5) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_LONG_ON_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Use analogWrite
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// DATA FETCHING / NVS PERSISTENCE
|
||
// ========================================================================
|
||
|
||
// Helper: hourly average from 15-minute data
|
||
float getHourlyAverage(int hourIndex, const JsonArray& prices) {
|
||
if (hourIndex < 0 || hourIndex >= 24) return 0.0;
|
||
|
||
int startIndex = hourIndex * 4;
|
||
if (startIndex + 3 >= (int)prices.size()) return 0.0;
|
||
|
||
float sum = 0.0;
|
||
int validCount = 0;
|
||
|
||
for (int i = 0; i < 4; i++) {
|
||
if (startIndex + i < (int)prices.size()) {
|
||
sum += prices[startIndex + i].as<float>();
|
||
validCount++;
|
||
}
|
||
}
|
||
return validCount > 0 ? sum / validCount : 0.0;
|
||
}
|
||
|
||
// Helper: current 15‑minute index within hour
|
||
int getCurrentQuarterHourIndex() {
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) return 0;
|
||
|
||
int minute = timeinfo.tm_min;
|
||
return minute / 15;
|
||
}
|
||
|
||
// Helper: compact 15‑minute price formatting
|
||
void format15MinPrice(float price, char* buffer, int bufferSize) {
|
||
if (APPLY_FEES_AND_VAT) {
|
||
price = price * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0);
|
||
}
|
||
price /= 1000.0;
|
||
|
||
int hundredths = (int)round(price * 100);
|
||
|
||
if (hundredths > 99) {
|
||
snprintf(buffer, bufferSize, "+99");
|
||
} else if (hundredths < -99) {
|
||
snprintf(buffer, bufferSize, "-99");
|
||
} else if (hundredths >= 0) {
|
||
snprintf(buffer, bufferSize, " %02d", hundredths);
|
||
} else {
|
||
snprintf(buffer, bufferSize, "%03d", hundredths);
|
||
}
|
||
}
|
||
|
||
// ---- NVS helpers for data persistence ----
|
||
|
||
void saveDataToNVS(const String& rawJson) {
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) {
|
||
debugPrint(1, "saveDataToNVS: cannot get local time, aborting save");
|
||
return;
|
||
}
|
||
|
||
int day = timeinfo.tm_mday;
|
||
int month = timeinfo.tm_mon; // 0-11
|
||
int year = timeinfo.tm_year + 1900; // full year
|
||
|
||
time_t now;
|
||
time(&now);
|
||
|
||
preferences.begin("my-ticker", false);
|
||
preferences.putInt("data_day", day);
|
||
preferences.putInt("data_mon", month);
|
||
preferences.putInt("data_year", year);
|
||
preferences.putString("data_prc", rawJson);
|
||
preferences.putULong("data_last_store", (unsigned long)now);
|
||
preferences.end();
|
||
|
||
nvsDataPresent = true;
|
||
nvsStoredDay = day;
|
||
nvsStoredMonth = month;
|
||
nvsStoredYear = year;
|
||
nvsLastStoreTime = now;
|
||
nvsDataLoadedForToday = true;
|
||
|
||
debugPrint(2, "Data saved to NVS for day " + String(day) + "." + String(month + 1) + "." + String(year));
|
||
}
|
||
|
||
bool loadDataFromNVSForToday() {
|
||
preferences.begin("my-ticker", false);
|
||
|
||
int storedDay = preferences.getInt("data_day", -1);
|
||
int storedMonth = preferences.getInt("data_mon", -1);
|
||
int storedYear = preferences.getInt("data_year", -1);
|
||
String storedJson = preferences.getString("data_prc", "");
|
||
unsigned long storedTime = preferences.getULong("data_last_store", 0);
|
||
|
||
preferences.end();
|
||
|
||
if (storedDay == -1 || storedMonth == -1 || storedYear == -1 || storedJson.length() == 0) {
|
||
debugPrint(1, "NVS: no valid stored data");
|
||
nvsDataPresent = false;
|
||
nvsDataLoadedForToday = false;
|
||
nvsStoredDay = -1;
|
||
nvsStoredMonth = -1;
|
||
nvsStoredYear = -1;
|
||
nvsLastStoreTime = 0;
|
||
return false;
|
||
}
|
||
|
||
nvsDataPresent = true;
|
||
nvsStoredDay = storedDay;
|
||
nvsStoredMonth = storedMonth;
|
||
nvsStoredYear = storedYear;
|
||
nvsLastStoreTime = storedTime;
|
||
|
||
struct tm nowInfo;
|
||
if (!getLocalTime(&nowInfo)) {
|
||
debugPrint(1, "NVS: cannot get local time to validate stored data");
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
|
||
if (storedDay == nowInfo.tm_mday &&
|
||
storedMonth == nowInfo.tm_mon &&
|
||
storedYear == (nowInfo.tm_year + 1900)) {
|
||
|
||
DeserializationError err = deserializeJson(doc, storedJson);
|
||
if (err) {
|
||
debugPrint(1, "NVS: failed to parse stored JSON: " + String(err.c_str()));
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
|
||
// Process data as if freshly fetched
|
||
processJsonData();
|
||
isTodayDataAvailable = true;
|
||
nvsDataLoadedForToday = true;
|
||
|
||
debugPrint(2, "NVS: loaded data for TODAY from NVS successfully");
|
||
return true;
|
||
} else {
|
||
debugPrint(1, "NVS: stored data is NOT for today - ignoring");
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---- Core fetch & process ----
|
||
|
||
void fetchAndProcessData() {
|
||
// Require Wi‑Fi and valid time
|
||
if (WiFi.status() != WL_CONNECTED || !isTimeSynced) {
|
||
debugPrint(1, "Not connected to WiFi or time not synced.");
|
||
apiFailCount++;
|
||
return;
|
||
}
|
||
|
||
HTTPClient http;
|
||
http.begin(api_url);
|
||
http.setTimeout(Config::HTTP_TIMEOUT);
|
||
http.setConnectTimeout(Config::HTTP_CONNECT_TIMEOUT);
|
||
|
||
int httpResponseCode = http.GET();
|
||
|
||
if (httpResponseCode > 0) {
|
||
debugPrint(2, "HTTP Response: " + String(httpResponseCode));
|
||
|
||
// Read full body into String so we can also store it into NVS
|
||
String payload = http.getString();
|
||
|
||
doc.clear();
|
||
DeserializationError error = deserializeJson(doc, payload);
|
||
if (error) {
|
||
debugPrint(1, "deserializeJson() failed: " + String(error.c_str()));
|
||
apiFailCount++;
|
||
displayPrices();
|
||
http.end();
|
||
return;
|
||
}
|
||
|
||
apiSuccessCount++;
|
||
time(&lastSuccessfulFetchTime);
|
||
|
||
// Reset acceptance flag before processing
|
||
lastProcessJsonAcceptedToday = false;
|
||
bool acceptedToday = processJsonData();
|
||
|
||
if (acceptedToday && isTodayDataAvailable) {
|
||
saveDataToNVS(payload);
|
||
}
|
||
|
||
httpGetRetryCount = 0;
|
||
debugPrint(2, "Data fetched, acceptedToday=" + String(acceptedToday ? "true" : "false"));
|
||
|
||
// Force display update (may still be NO_DATA_OFFSET if not accepted)
|
||
displayPrices();
|
||
|
||
// If we are in midnight retry phase and we now have today's data,
|
||
// end the midnight phase and reset counters.
|
||
if (midnightPhaseActive && isTodayDataAvailable && acceptedToday) {
|
||
debugPrint(2, "Midnight phase completed successfully - data for today acquired");
|
||
midnightPhaseActive = false;
|
||
midnightRetryCount = 0;
|
||
}
|
||
|
||
// Note: do NOT set nextScheduledFetchTime here; handleDataFetching()
|
||
// will decide based on acceptedToday + success/failure state.
|
||
|
||
} else {
|
||
debugPrint(1, "HTTP request failed with code: " + String(httpResponseCode));
|
||
apiFailCount++;
|
||
httpGetRetryCount++;
|
||
|
||
displayPrices();
|
||
|
||
// For non-midnight usage (e.g. manual long-press refresh),
|
||
// schedule a modest backoff (10 minutes).
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (midnightPhaseActive) {
|
||
debugPrint(2, "HTTP failed during midnight phase; scheduler will adjust next retries");
|
||
// scheduleAfterMidnightFailure() will be called in handleDataFetching()
|
||
} else {
|
||
nextScheduledFetchTime = now + 600; // 10 minutes
|
||
debugPrint(2, "HTTP failed (non-midnight). Next fetch scheduled in 10 minutes.");
|
||
}
|
||
}
|
||
|
||
http.end();
|
||
}
|
||
|
||
// ========================================================================
|
||
// IMPROVED "TODAY" DETECTION AND DATA PROCESSING (v6.1.1 fix retained)
|
||
// ========================================================================
|
||
|
||
bool processJsonData() {
|
||
lastProcessJsonAcceptedToday = false;
|
||
|
||
JsonArray prices = doc["price"];
|
||
JsonArray unixSeconds = doc["unix_seconds"];
|
||
|
||
if (prices.size() == 0 || unixSeconds.size() == 0) {
|
||
debugPrint(1, "No price data in API response");
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
struct tm currentTimeinfo;
|
||
if (!getLocalTime(¤tTimeinfo)) {
|
||
debugPrint(1, "Could not get current time for data validation.");
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
// Determine "market day" from LAST timestamp of the dataset.
|
||
// The JSON you provided covers one full day at 15-min intervals.
|
||
size_t lastIndex = unixSeconds.size() - 1;
|
||
unsigned long lastUnix = unixSeconds[lastIndex].as<unsigned long>();
|
||
time_t lastDataTime = (time_t)lastUnix;
|
||
struct tm* lastDataTm = localtime(&lastDataTime);
|
||
|
||
if (!lastDataTm) {
|
||
debugPrint(1, "localtime() failed for last data timestamp.");
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
int dataDay = lastDataTm->tm_mday;
|
||
int dataMonth = lastDataTm->tm_mon;
|
||
int dataYear = lastDataTm->tm_year;
|
||
|
||
int currDay = currentTimeinfo.tm_mday;
|
||
int currMonth = currentTimeinfo.tm_mon;
|
||
int currYear = currentTimeinfo.tm_year;
|
||
|
||
bool sameDate = (dataDay == currDay &&
|
||
dataMonth == currMonth &&
|
||
dataYear == currYear);
|
||
|
||
if (!sameDate) {
|
||
debugPrint(1, "Fetched data day does not match current local day; "
|
||
"dataDay=" + String(dataDay) +
|
||
" currDay=" + String(currDay));
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
// Data is considered valid for today's market day
|
||
isTodayDataAvailable = true;
|
||
lastProcessJsonAcceptedToday = true;
|
||
|
||
// --------------------------------------------------------------------
|
||
// v6.1.1 FIX (retained in v6.1.2):
|
||
// - Include negative and zero hourly averages in min/max and average.
|
||
// - Compute daily average using count of valid hours (validHourCount).
|
||
// --------------------------------------------------------------------
|
||
float sum = 0.0;
|
||
int validHourCount = 0;
|
||
|
||
float minPrice = 999999.0;
|
||
float maxPrice = -999999.0;
|
||
|
||
lowestPriceIndex = 0;
|
||
highestPriceIndex = 0;
|
||
|
||
for (int hour = 0; hour < 24; hour++) {
|
||
int startIndex = hour * 4;
|
||
|
||
// Valid hour = we have all 4×15-min values for this hour.
|
||
// Do NOT use price value (0.0) as a validity signal, because 0 can be real.
|
||
if (startIndex + 3 >= (int)prices.size()) {
|
||
continue;
|
||
}
|
||
|
||
float hourlyAvg = getHourlyAverage(hour, prices);
|
||
|
||
sum += hourlyAvg;
|
||
validHourCount++;
|
||
|
||
if (hourlyAvg < minPrice) {
|
||
minPrice = hourlyAvg;
|
||
lowestPriceIndex = startIndex;
|
||
}
|
||
if (hourlyAvg > maxPrice) {
|
||
maxPrice = hourlyAvg;
|
||
highestPriceIndex = startIndex;
|
||
}
|
||
}
|
||
|
||
if (validHourCount > 0) {
|
||
averagePrice = sum / (float)validHourCount;
|
||
} else {
|
||
// Should not happen for a full-day dataset, but keep safe defaults
|
||
averagePrice = 0.0;
|
||
lowestPriceIndex = -1;
|
||
highestPriceIndex = -1;
|
||
}
|
||
|
||
debugPrint(3, "Processed " + String(prices.size()) + " price entries (15-min intervals)");
|
||
debugPrint(3, "Daily average: " + String(averagePrice) + " EUR/MWh");
|
||
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// DISPLAY HELPERS
|
||
// ========================================================================
|
||
|
||
void display15MinuteDetails(int row, int hourIndex, const JsonArray& prices) {
|
||
lcd.setCursor(0, row);
|
||
|
||
if (hourIndex < 0 || hourIndex >= 24) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
|
||
struct tm timeinfo;
|
||
int currentHour = -1;
|
||
int currentMinute = -1;
|
||
bool hasValidTime = false;
|
||
|
||
if (getLocalTime(&timeinfo)) {
|
||
currentHour = timeinfo.tm_hour;
|
||
currentMinute = timeinfo.tm_min;
|
||
hasValidTime = true;
|
||
|
||
// Prevent wraparound showing early hours at night
|
||
if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
if (hourIndex < currentHour && currentHour < 22) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
}
|
||
|
||
int startIndex = hourIndex * 4;
|
||
char priceBuffer[4];
|
||
int cursorPos = 0;
|
||
|
||
int currentSegment = -1;
|
||
if (hasValidTime && hourIndex == currentHour) {
|
||
currentSegment = currentMinute / 15;
|
||
}
|
||
|
||
for (int i = 0; i < 4; i++) {
|
||
bool shouldShowPlaceholder = false;
|
||
|
||
if (hasValidTime && hourIndex == currentHour && i < currentSegment) {
|
||
shouldShowPlaceholder = true;
|
||
}
|
||
|
||
if (shouldShowPlaceholder) {
|
||
lcd.print(" > ");
|
||
cursorPos += 3;
|
||
} else if (startIndex + i < (int)prices.size()) {
|
||
float price = prices[startIndex + i].as<float>();
|
||
format15MinPrice(price, priceBuffer, sizeof(priceBuffer));
|
||
lcd.print(priceBuffer);
|
||
cursorPos += 3;
|
||
} else {
|
||
lcd.print("---");
|
||
cursorPos += 3;
|
||
}
|
||
|
||
if (i < 3) {
|
||
lcd.print(" ");
|
||
cursorPos += 2;
|
||
}
|
||
}
|
||
|
||
for (int i = cursorPos; i < 20; i++) {
|
||
lcd.print(" ");
|
||
}
|
||
}
|
||
|
||
void displayPriceRow(int row, int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds) {
|
||
lcd.setCursor(0, row);
|
||
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
int currentHour = timeinfo.tm_hour;
|
||
|
||
if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
if (hourIndex < currentHour && currentHour < 22) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (hourIndex >= 0 && hourIndex < 24) {
|
||
float rates = getHourlyAverage(hourIndex, prices);
|
||
int dataIndex = hourIndex * 4;
|
||
if (dataIndex < (int)unixSeconds.size()) {
|
||
unsigned long hourlyUnixTime = unixSeconds[dataIndex].as<unsigned long>();
|
||
|
||
if (isValidUnixTime(hourlyUnixTime)) {
|
||
time_t t = (time_t)hourlyUnixTime;
|
||
struct tm *time_ptr = localtime(&t);
|
||
|
||
if (time_ptr != NULL) {
|
||
char buffer[21];
|
||
int hour = time_ptr->tm_hour;
|
||
int minute = 0;
|
||
snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute);
|
||
lcd.print(buffer);
|
||
|
||
if (dataIndex == lowestPriceIndex) {
|
||
lcd.setCursor(7, row);
|
||
lcd.write(byte(3));
|
||
lcd.print(" ");
|
||
} else if (dataIndex == highestPriceIndex) {
|
||
lcd.setCursor(7, row);
|
||
lcd.write(byte(4));
|
||
lcd.print(" ");
|
||
} else {
|
||
lcd.setCursor(6, row);
|
||
lcd.print(" ");
|
||
}
|
||
|
||
float finalPrice = rates;
|
||
if (APPLY_FEES_AND_VAT) {
|
||
finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0);
|
||
}
|
||
finalPrice /= 1000.0;
|
||
|
||
if (finalPrice < 0) {
|
||
lcd.setCursor(9, row);
|
||
} else {
|
||
lcd.setCursor(10, row);
|
||
}
|
||
|
||
commaPrint(finalPrice, 4);
|
||
lcd.print(" EUR");
|
||
} else {
|
||
lcd.print("Time Error ");
|
||
}
|
||
} else {
|
||
lcd.print("Invalid Time ");
|
||
}
|
||
} else {
|
||
lcd.print("No Data Available ");
|
||
}
|
||
} else {
|
||
lcd.print(" ");
|
||
}
|
||
}
|
||
|
||
void displayPrimaryList() {
|
||
JsonArray prices = doc["price"];
|
||
JsonArray unixSeconds = doc["unix_seconds"];
|
||
|
||
if (!isTodayDataAvailable) {
|
||
for (int i = 0; i < 4; i++) {
|
||
lcd.setCursor(0, i);
|
||
lcd.print("No data available ");
|
||
}
|
||
return;
|
||
}
|
||
|
||
int baseHour = 0;
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
baseHour = timeinfo.tm_hour;
|
||
}
|
||
|
||
int displayStartHour = baseHour + timeOffsetHours;
|
||
|
||
if (baseHour >= 21 && timeOffsetHours > 0) {
|
||
int adjustedBase = 21;
|
||
displayStartHour = adjustedBase + timeOffsetHours;
|
||
}
|
||
|
||
if (displayStartHour >= 24) {
|
||
displayStartHour = displayStartHour % 24;
|
||
}
|
||
|
||
// Row 0: 15min details
|
||
display15MinuteDetails(0, displayStartHour, prices);
|
||
|
||
// Row 1: current hour average
|
||
displayPriceRow(1, displayStartHour, prices, unixSeconds);
|
||
|
||
// Row 2: next hour
|
||
int nextHour = (displayStartHour + 1) % 24;
|
||
displayPriceRow(2, nextHour, prices, unixSeconds);
|
||
|
||
// Row 3: hour after next
|
||
int hourAfter = (displayStartHour + 2) % 24;
|
||
displayPriceRow(3, hourAfter, prices, unixSeconds);
|
||
}
|
||
|
||
// Secondary list with 20 scrollable lines (16 old + 4 new NVS lines)
|
||
void displaySecondaryList() {
|
||
char lines[SECONDARY_LIST_TOTAL_LINES][21];
|
||
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
snprintf(lines[0], sizeof(lines[0]), "%2d:%02d %2d.%2d.%04d",
|
||
timeinfo.tm_hour, timeinfo.tm_min,
|
||
timeinfo.tm_mday, timeinfo.tm_mon + 1, timeinfo.tm_year + 1900);
|
||
} else {
|
||
snprintf(lines[0], sizeof(lines[0]), "Time not available ");
|
||
}
|
||
|
||
snprintf(lines[1], sizeof(lines[1]), "--------------------");
|
||
snprintf(lines[2], sizeof(lines[2]), "Zadnja posodobitev:");
|
||
|
||
struct tm* timeinfo_ptr = localtime(&lastSuccessfulFetchTime);
|
||
if (timeinfo_ptr != NULL && lastSuccessfulFetchTime != 0) {
|
||
snprintf(lines[3], sizeof(lines[3]), "%2d.%2d.%04d %2d:%02d",
|
||
timeinfo_ptr->tm_mday, timeinfo_ptr->tm_mon + 1,
|
||
timeinfo_ptr->tm_year + 1900,
|
||
timeinfo_ptr->tm_hour, timeinfo_ptr->tm_min);
|
||
} else {
|
||
snprintf(lines[3], sizeof(lines[3]), "No data timestamp ");
|
||
}
|
||
|
||
snprintf(lines[4], sizeof(lines[4]), " ");
|
||
snprintf(lines[5], sizeof(lines[5]), "Dnevno povpre^je:");
|
||
|
||
if (isTodayDataAvailable) {
|
||
float finalPrice = averagePrice;
|
||
if (APPLY_FEES_AND_VAT) {
|
||
finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0);
|
||
}
|
||
char priceBuffer[21];
|
||
snprintf(priceBuffer, sizeof(priceBuffer), "%.4f EUR/kWh", finalPrice / 1000.0);
|
||
String numStr = String(priceBuffer);
|
||
numStr.replace('.', ',');
|
||
snprintf(lines[6], sizeof(lines[6]), "%s", numStr.c_str());
|
||
} else {
|
||
snprintf(lines[6], sizeof(lines[6]), "Cene niso na voljo.");
|
||
}
|
||
|
||
snprintf(lines[7], sizeof(lines[7]), " ");
|
||
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
snprintf(lines[8], sizeof(lines[8]), "WiFi:%5d dBm", WiFi.RSSI());
|
||
} else {
|
||
snprintf(lines[8], sizeof(lines[8]), "WiFi: Disconnected");
|
||
}
|
||
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
snprintf(lines[9], sizeof(lines[9]), "IP:%15s", WiFi.localIP().toString().c_str());
|
||
} else {
|
||
snprintf(lines[9], sizeof(lines[9]), "IP: Not connected ");
|
||
}
|
||
|
||
int totalApiCalls = apiSuccessCount + apiFailCount;
|
||
if (totalApiCalls > 0) {
|
||
int successRate = (apiSuccessCount * 100) / totalApiCalls;
|
||
snprintf(lines[10], sizeof(lines[10]), "API:%3d%% (%d/%d)", successRate, apiSuccessCount, totalApiCalls);
|
||
} else {
|
||
snprintf(lines[10], sizeof(lines[10]), "API: No calls yet ");
|
||
}
|
||
|
||
unsigned long uptimeMs = millis();
|
||
unsigned long days = uptimeMs / (24UL * 60UL * 60UL * 1000UL);
|
||
uptimeMs %= (24UL * 60UL * 60UL * 1000UL);
|
||
unsigned long hours = uptimeMs / (60UL * 60UL * 1000UL);
|
||
uptimeMs %= (60UL * 60UL * 1000UL);
|
||
unsigned long minutes = uptimeMs / (60UL * 1000UL);
|
||
snprintf(lines[11], sizeof(lines[11]), "Up:%2lud%2luh%2lum", days, hours, minutes);
|
||
|
||
// NEW NVS STATUS LINES (12–15)
|
||
snprintf(lines[12], sizeof(lines[12]), "NVS status:");
|
||
|
||
if (nvsDataPresent) {
|
||
// Data day line: "DD.MM.YYYY"
|
||
char dateBuf[16];
|
||
snprintf(dateBuf, sizeof(dateBuf), "%2d.%2d.%04d",
|
||
(nvsStoredDay > 0 ? nvsStoredDay : 0),
|
||
(nvsStoredMonth >= 0 ? nvsStoredMonth + 1 : 0),
|
||
(nvsStoredYear > 0 ? nvsStoredYear : 0));
|
||
snprintf(lines[13], sizeof(lines[13]), "Data day:%11s", dateBuf);
|
||
} else {
|
||
snprintf(lines[13], sizeof(lines[13]), "Data day: none ");
|
||
}
|
||
|
||
if (nvsLastStoreTime != 0) {
|
||
struct tm* st = localtime(&nvsLastStoreTime);
|
||
if (st != NULL) {
|
||
char storeBuf[12];
|
||
snprintf(storeBuf, sizeof(storeBuf), "%2d.%2d.%02d",
|
||
st->tm_mday, st->tm_mon + 1, (st->tm_year + 1900) % 100);
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save:%8s", storeBuf);
|
||
} else {
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save: invalid ");
|
||
}
|
||
} else {
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save: none ");
|
||
}
|
||
|
||
if (nvsDataPresent && nvsDataLoadedForToday) {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: OK (today) ");
|
||
} else if (nvsDataPresent && !nvsDataLoadedForToday) {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: old data ");
|
||
} else {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: empty ");
|
||
}
|
||
|
||
// CREDITS MOVED TO LINES 16–19
|
||
snprintf(lines[16], sizeof(lines[16]), "energy-charts.info");
|
||
snprintf(lines[17], sizeof(lines[17]), "dynamic electricity");
|
||
snprintf(lines[18], sizeof(lines[18]), "price ticker v6.1.2");
|
||
snprintf(lines[19], sizeof(lines[19]), "by Legolas-2025");
|
||
|
||
// Render current window
|
||
for (int i = 0; i < 4; i++) {
|
||
int lineIndex = secondaryListOffset + i;
|
||
lcd.setCursor(0, i);
|
||
|
||
if (lineIndex < SECONDARY_LIST_TOTAL_LINES) {
|
||
lcdPrint(lines[lineIndex]);
|
||
for (int j = strlen(lines[lineIndex]); j < 20; j++) {
|
||
lcd.print(" ");
|
||
}
|
||
} else {
|
||
lcd.print(" ");
|
||
}
|
||
}
|
||
}
|
||
|
||
void displayPrices() {
|
||
lcd.clear();
|
||
|
||
if (!isTimeSynced) {
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Syncing Time...");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Please wait...");
|
||
return;
|
||
}
|
||
|
||
if (currentList == SECONDARY_LIST) {
|
||
displaySecondaryList();
|
||
return;
|
||
}
|
||
|
||
// Enforce NO_DATA_OFFSET when no data
|
||
if (!isTodayDataAvailable) {
|
||
displayState = NO_DATA_OFFSET;
|
||
timeOffsetHours = 0;
|
||
} else if (displayState == NO_DATA_OFFSET) {
|
||
displayState = CURRENT_PRICES;
|
||
timeOffsetHours = 0;
|
||
}
|
||
|
||
switch (displayState) {
|
||
case NO_DATA_OFFSET:
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("No data for today");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Press & hold to");
|
||
lcd.setCursor(0, 2);
|
||
lcd.print("refresh manually");
|
||
break;
|
||
case CURRENT_PRICES:
|
||
displayPrimaryList();
|
||
break;
|
||
case CUSTOM_MESSAGE:
|
||
displayState = CURRENT_PRICES;
|
||
displayPrimaryList();
|
||
break;
|
||
}
|
||
|
||
initialBoot = false;
|
||
}
|
||
|
||
void resetDisplayToTop() {
|
||
timeOffsetHours = 0;
|
||
secondaryListOffset = 0;
|
||
displayState = CURRENT_PRICES;
|
||
currentList = PRIMARY_LIST;
|
||
|
||
debugPrint(2, "Auto-scroll timeout - Reset display to current hour");
|
||
displayPrices();
|
||
}
|
||
|
||
void advanceDisplayOffset() {
|
||
if (!isTimeSynced) return;
|
||
|
||
if (currentList == SECONDARY_LIST) {
|
||
secondaryListOffset += SECONDARY_LIST_SCROLL_INCREMENT;
|
||
if (secondaryListOffset >= SECONDARY_LIST_TOTAL_LINES) {
|
||
secondaryListOffset = 0;
|
||
}
|
||
displayPrices();
|
||
return;
|
||
}
|
||
|
||
if (!isTodayDataAvailable) {
|
||
displayState = NO_DATA_OFFSET;
|
||
displayPrices();
|
||
return;
|
||
}
|
||
|
||
if (displayState == NO_DATA_OFFSET) {
|
||
return;
|
||
} else {
|
||
struct tm timeinfo;
|
||
int currentHour = 0;
|
||
if (getLocalTime(&timeinfo)) {
|
||
currentHour = timeinfo.tm_hour;
|
||
}
|
||
|
||
int maxOffset = 23 - currentHour;
|
||
if (maxOffset < 2 && currentHour >= 21) {
|
||
maxOffset = 2;
|
||
}
|
||
|
||
int nextOffsetHours = timeOffsetHours + 1;
|
||
|
||
if (nextOffsetHours > maxOffset) {
|
||
timeOffsetHours = 0;
|
||
displayState = CURRENT_PRICES;
|
||
} else {
|
||
timeOffsetHours = nextOffsetHours;
|
||
}
|
||
}
|
||
displayPrices();
|
||
}
|
||
|
||
void toggleList() {
|
||
if (!isTimeSynced) return;
|
||
currentList = (currentList == PRIMARY_LIST) ? SECONDARY_LIST : PRIMARY_LIST;
|
||
debugPrint(2, "Toggled to " + String(currentList == PRIMARY_LIST ? "PRIMARY" : "SECONDARY") + " list");
|
||
displayPrices();
|
||
}
|
||
|
||
// ========================================================================
|
||
// BUTTON, BACKLIGHT, PRESENCE
|
||
// ========================================================================
|
||
|
||
void handleButton() {
|
||
int reading = !digitalRead(buttonPin);
|
||
|
||
if (reading != lastButtonState) {
|
||
lastDebounceTime = millis();
|
||
|
||
if (reading == LOW) {
|
||
buttonPressStartTime = millis();
|
||
longPressDetected = false;
|
||
}
|
||
}
|
||
|
||
if ((millis() - lastDebounceTime) > debounceDelay) {
|
||
if (reading != buttonState) {
|
||
buttonState = reading;
|
||
|
||
if (buttonState == HIGH) {
|
||
unsigned long pressDuration = millis() - buttonPressStartTime;
|
||
|
||
if (longPressDetected) {
|
||
debugPrint(1, "Long press detected - Forcing manual data refresh");
|
||
lastButtonActivity = millis();
|
||
autoScrollExecuted = false;
|
||
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Manual Refresh...");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Please wait...");
|
||
|
||
time_t now;
|
||
time(&now);
|
||
nextScheduledFetchTime = now;
|
||
|
||
} else if (pressDuration < longPressThreshold) {
|
||
unsigned long currentTime = millis();
|
||
|
||
if (waitingForDoubleClick && (currentTime - lastClickTime <= doubleClickWindow)) {
|
||
waitingForDoubleClick = false;
|
||
pendingClick = false;
|
||
lastButtonActivity = millis();
|
||
autoScrollExecuted = false;
|
||
debugPrint(2, "Double-click detected - toggling list");
|
||
toggleList();
|
||
} else {
|
||
lastClickTime = currentTime;
|
||
waitingForDoubleClick = true;
|
||
pendingClick = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (buttonState == LOW && !longPressDetected) {
|
||
if (millis() - buttonPressStartTime >= longPressThreshold) {
|
||
longPressDetected = true;
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Long press detected!");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Release to refresh");
|
||
debugPrint(2, "Long press threshold reached - waiting for release");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (waitingForDoubleClick && pendingClick && (millis() - lastClickTime > doubleClickWindow)) {
|
||
waitingForDoubleClick = false;
|
||
pendingClick = false;
|
||
lastButtonActivity = millis();
|
||
autoScrollExecuted = false;
|
||
debugPrint(3, "Single click confirmed - advancing display");
|
||
advanceDisplayOffset();
|
||
}
|
||
|
||
lastButtonState = reading;
|
||
}
|
||
|
||
void handleBacklight() {
|
||
if (!presenceSensorConnected) {
|
||
lcd.backlight();
|
||
areLedsOn = true;
|
||
lastPresenceTime = millis();
|
||
return;
|
||
}
|
||
|
||
bool isPresent = digitalRead(presencePin) == HIGH;
|
||
|
||
if (isPresent) {
|
||
lastPresenceTime = millis();
|
||
lcd.backlight();
|
||
areLedsOn = true;
|
||
} else {
|
||
if (millis() - lastPresenceTime >= backlightOffDelay) {
|
||
lcd.noBacklight();
|
||
areLedsOn = false;
|
||
} else {
|
||
lcd.backlight();
|
||
areLedsOn = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
void handlePresenceSensor() {
|
||
static bool lastPresenceState = false;
|
||
bool currentPresenceState = digitalRead(presencePin) == HIGH;
|
||
|
||
if (currentPresenceState != lastPresenceState) {
|
||
if (currentPresenceState) {
|
||
debugPrint(3, "Presence detected - turning on backlight");
|
||
lcd.backlight();
|
||
lastPresenceTime = millis();
|
||
}
|
||
lastPresenceState = currentPresenceState;
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// SCHEDULING & MIDNIGHT LOGIC
|
||
// ========================================================================
|
||
|
||
// When a fetch fails during midnight phase, set the next retry time
|
||
void scheduleAfterMidnightFailure() {
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (midnightPhaseActive) {
|
||
if (midnightRetryCount < 5) {
|
||
// Retry every 10 minutes for first 5 attempts
|
||
midnightRetryCount++;
|
||
nextScheduledFetchTime = now + 600; // 10 minutes
|
||
debugPrint(2, "Midnight retry " + String(midnightRetryCount) + "/5 in 10 minutes");
|
||
} else {
|
||
// After 5 attempts, retry only at top of each hour
|
||
struct tm* ti = localtime(&now);
|
||
if (ti != NULL) {
|
||
time_t nextHour = now - (ti->tm_min * 60) - ti->tm_sec + 3600;
|
||
nextScheduledFetchTime = nextHour;
|
||
debugPrint(2, "Midnight retries exhausted; next fetch top-of-hour");
|
||
} else {
|
||
nextScheduledFetchTime = now + 3600;
|
||
debugPrint(2, "Midnight retries exhausted; fallback 1h");
|
||
}
|
||
}
|
||
} else {
|
||
debugPrint(2, "scheduleAfterMidnightFailure called outside midnight phase");
|
||
}
|
||
}
|
||
|
||
void handleDataFetching() {
|
||
if (!isTimeSynced) return;
|
||
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (now < nextScheduledFetchTime) {
|
||
return;
|
||
}
|
||
|
||
debugPrint(2, "Scheduled data fetch triggered");
|
||
int beforeSuccessCount = apiSuccessCount;
|
||
|
||
// Reset acceptance flag before fetch
|
||
lastProcessJsonAcceptedToday = false;
|
||
fetchAndProcessData();
|
||
|
||
bool httpJsonSucceeded = (apiSuccessCount != beforeSuccessCount);
|
||
bool dataAcceptedToday = lastProcessJsonAcceptedToday;
|
||
|
||
// Treat as "success" ONLY if HTTP/JSON succeeded AND data is accepted for today
|
||
if (!httpJsonSucceeded || !dataAcceptedToday) {
|
||
// FAILURE for scheduling / daily logic
|
||
if (midnightPhaseActive) {
|
||
// Ensure "no data" UI
|
||
isTodayDataAvailable = false;
|
||
displayState = NO_DATA_OFFSET;
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Use analogWrite for consistency
|
||
displayPrices();
|
||
// Schedule next retry in midnight logic
|
||
scheduleAfterMidnightFailure();
|
||
} else {
|
||
// Non-midnight failure: schedule a modest backoff if not already set
|
||
if (nextScheduledFetchTime <= now) {
|
||
nextScheduledFetchTime = now + 600;
|
||
}
|
||
}
|
||
} else {
|
||
// SUCCESS with data for today
|
||
struct tm* timeinfo_ptr = localtime(&now);
|
||
if (timeinfo_ptr != NULL) {
|
||
lastSuccessfulFetchDay = timeinfo_ptr->tm_mday;
|
||
}
|
||
|
||
// Clear midnight phase if still active
|
||
if (midnightPhaseActive) {
|
||
midnightPhaseActive = false;
|
||
midnightRetryCount = 0;
|
||
}
|
||
|
||
// For v6.1, after any successful "today" fetch, schedule next automatic
|
||
// fetch far in the future (24h). Midnight logic will reset this next day.
|
||
nextScheduledFetchTime = now + 24 * 3600;
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// SETUP & MAIN LOOP
|
||
// ========================================================================
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
debugPrint(2, "Starting Dynamic Electricity Ticker v6.1.2 (15-MINUTE DETAIL MODE) - DST fixed");
|
||
|
||
pinMode(builtinLedPin, OUTPUT);
|
||
pinMode(whiteLedPin, OUTPUT);
|
||
pinMode(buttonPin, INPUT_PULLUP);
|
||
|
||
// v6.1.2: Ensure LED starts OFF with analogWrite for consistency
|
||
analogWrite(whiteLedPin, 0);
|
||
|
||
const int NUM_DETECTION_SAMPLES = 5;
|
||
int high_reads = 0;
|
||
|
||
pinMode(presencePin, INPUT);
|
||
delay(50);
|
||
|
||
for (int i = 0; i < NUM_DETECTION_SAMPLES; i++) {
|
||
if (digitalRead(presencePin) == HIGH) {
|
||
high_reads++;
|
||
}
|
||
delay(5);
|
||
}
|
||
|
||
presenceSensorConnected = (high_reads > 0);
|
||
if (presenceSensorConnected) {
|
||
debugPrint(2, "Presence sensor detected and connected");
|
||
} else {
|
||
debugPrint(2, "Presence sensor not detected - backlight always on");
|
||
}
|
||
|
||
lcd.init();
|
||
lcd.backlight();
|
||
lastPresenceTime = millis();
|
||
|
||
lcd.createChar(0, bitmap_c);
|
||
lcd.createChar(1, bitmap_s);
|
||
lcd.createChar(2, bitmap_z);
|
||
lcd.createChar(3, lo_prc);
|
||
lcd.createChar(4, hi_prc);
|
||
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Initializing ...");
|
||
delay(1000);
|
||
|
||
connectToWiFi();
|
||
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
configTzTime(TZ_CET_CEST, "pool.ntp.org");
|
||
}
|
||
|
||
debugPrint(2, "Setup completed successfully");
|
||
}
|
||
|
||
void loop() {
|
||
if (needsRestart) {
|
||
delay(100);
|
||
ESP.restart();
|
||
}
|
||
|
||
if (inProvisioningMode) {
|
||
handleProvisioning();
|
||
return;
|
||
}
|
||
|
||
// Non-blocking time sync
|
||
if (!isTimeSynced) {
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
debugPrint(2, "NTP synchronization successful");
|
||
isTimeSynced = true;
|
||
lastButtonActivity = millis();
|
||
autoScrollExecuted = false;
|
||
time_t now;
|
||
time(&now);
|
||
nextScheduledFetchTime = now; // initial fetch decision
|
||
|
||
trackedDay = timeinfo.tm_mday;
|
||
debugPrint(2, "Tracked day set to: " + String(trackedDay));
|
||
|
||
// Once time is known, attempt to load today's data from NVS.
|
||
bool loaded = loadDataFromNVSForToday();
|
||
if (!loaded) {
|
||
// No today's data in NVS -> schedule immediate fetch
|
||
nextScheduledFetchTime = now;
|
||
} else {
|
||
// We have today's data from NVS.
|
||
isTodayDataAvailable = true;
|
||
// No need to fetch immediately; defer next fetch to midnight
|
||
nextScheduledFetchTime = now + 24 * 3600;
|
||
debugPrint(2, "Today's data loaded from NVS; skipping initial API fetch");
|
||
}
|
||
|
||
displayPrices();
|
||
} else {
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Connecting...");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Syncing Time...");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Detect local day rollover (midnight)
|
||
time_t now_for_daycheck = time(nullptr);
|
||
struct tm* daycheck = localtime(&now_for_daycheck);
|
||
if (daycheck != nullptr) {
|
||
if (trackedDay == -1) {
|
||
trackedDay = daycheck->tm_mday;
|
||
} else if (daycheck->tm_mday != trackedDay) {
|
||
// Day rollover detected
|
||
trackedDay = daycheck->tm_mday;
|
||
debugPrint(1, "Midnight rollover detected - switching to NO_DATA_OFFSET until today's data available");
|
||
|
||
// Immediately invalidate any "today" data
|
||
isTodayDataAvailable = false;
|
||
displayState = NO_DATA_OFFSET;
|
||
timeOffsetHours = 0;
|
||
analogWrite(whiteLedPin, 0); // v6.1.2: Use analogWrite for consistency
|
||
|
||
// Start midnight phase
|
||
midnightPhaseActive = true;
|
||
midnightRetryCount = 0;
|
||
|
||
// Aggressively attempt to fetch immediately
|
||
nextScheduledFetchTime = now_for_daycheck;
|
||
|
||
// Update display so user sees "No data for today" right away
|
||
displayPrices();
|
||
}
|
||
}
|
||
|
||
handleButton();
|
||
handlePresenceSensor();
|
||
handleBacklight();
|
||
updateLeds();
|
||
handleDataFetching();
|
||
|
||
if (!autoScrollExecuted && millis() - lastButtonActivity >= autoScrollTimeout) {
|
||
resetDisplayToTop();
|
||
autoScrollExecuted = true;
|
||
}
|
||
|
||
time_t now = time(nullptr);
|
||
struct tm* timeinfo = localtime(&now);
|
||
if (timeinfo != nullptr) {
|
||
unsigned long currentHour = timeinfo->tm_hour;
|
||
unsigned long currentMinute = timeinfo->tm_min;
|
||
|
||
if (currentMinute == 0 && lastHourlyRefresh != currentHour) {
|
||
debugPrint(2, "Top of hour refresh - Hour: " + String(currentHour));
|
||
displayPrices();
|
||
lastHourlyRefresh = currentHour;
|
||
}
|
||
|
||
unsigned long current15MinBlock = (currentHour * 4) + (currentMinute / 15);
|
||
if ((currentMinute % 15 == 0) && last15MinRefresh != current15MinBlock) {
|
||
debugPrint(2, "15-minute refresh - updating placeholders");
|
||
displayPrices();
|
||
last15MinRefresh = current15MinBlock;
|
||
}
|
||
}
|
||
|
||
if (millis() - lastLoopUpdate >= Config::LOOP_UPDATE_INTERVAL) {
|
||
lastLoopUpdate = millis();
|
||
}
|
||
}
|