mirror of
https://github.com/Legolas-2025/Standalone-electricity-price-ticker.git
synced 2026-08-17 12:34:55 +02:00
1480 lines
44 KiB
Arduino
1480 lines
44 KiB
Arduino
#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
|
|
|
|
// ========================================================================
|
|
// Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 5.5 (15-MINUTE DETAIL MODE)
|
|
// ========================================================================
|
|
//
|
|
// This code fetches real-time day-ahead electricity prices from
|
|
// Energy-Charts.info for Slovenia, calculates the final consumer price
|
|
// with power company fees and VAT, and displays the rates on a 20x4 LCD.
|
|
//
|
|
// VERSION 5.5 NEW FEATURES:
|
|
// - **15-MINUTE DETAIL MODE:** Primary display now shows current hour average,
|
|
// followed by the 4 individual 15-minute values in compact format (XX XX XX XX),
|
|
// then the next 2 hours. This provides both hourly overview and 15-min precision.
|
|
// - **LED responds to current 15-minute interval** instead of hourly average
|
|
// for more precise price indication.
|
|
// - **Compact price format:** 15-minute values shown as 2-digit hundredths
|
|
// (e.g., "+99 -07 24 11" for prices like 0.99, -0.07, 0.24, 0.11 EUR/kWh)
|
|
//
|
|
// All previous features from V5.4 are retained (aggressive retry, state enforcement, etc.)
|
|
//
|
|
// ------------------------------------------------------------------------
|
|
// Hardware Configuration & Wiring Instructions:
|
|
// ------------------------------------------------------------------------
|
|
//
|
|
// PUSHBUTTON (DEFAULT CONFIGURATION):
|
|
// - Connect one leg to GPIO 4, other to GND
|
|
// - Uses internal pull-up, no external resistor needed
|
|
//
|
|
// ALTERNATIVE: TTP223 CAPACITIVE TOUCH BUTTON:
|
|
// - If you prefer a capacitive touch button instead of mechanical pushbutton:
|
|
// WIRING:
|
|
// - VCC to 3.3V power rail
|
|
// - GND to GND
|
|
// - OUT to GPIO 4 (same pin as pushbutton)
|
|
// CODE CHANGES REQUIRED:
|
|
// - Find line: "int reading = digitalRead(buttonPin);"
|
|
// - Change to: "int reading = !digitalRead(buttonPin);"
|
|
// - This inverts the logic since TTP223 outputs HIGH when touched,
|
|
// while pushbutton pulls LOW when pressed
|
|
// - No other changes needed - all timing and debounce logic remains the same
|
|
// BENEFITS:
|
|
// - No mechanical wear, sealed operation
|
|
// - Can be mounted behind thin non-metallic panels
|
|
// - More modern, sleek appearance
|
|
//
|
|
// PRESENCE SENSOR (RCWL-0516):
|
|
// - VCC to 3.3V, GND to GND, OUT to GPIO 9
|
|
// - REQUIRED: 10kOhm pull-down resistor between GPIO 9 and GND
|
|
// - Can be hidden behind non-metallic surfaces
|
|
//
|
|
// WHITE LED:
|
|
// - Connect to GPIO 5 (with appropriate resistor)
|
|
// - Provides visual price indication
|
|
//
|
|
// ========================================================================
|
|
|
|
// ========================================================================
|
|
// CONFIGURATION CONSTANTS
|
|
// ========================================================================
|
|
struct Config {
|
|
static const int JSON_BUFFER_SIZE = 4096; // Reduced from 8192
|
|
static const int HTTP_TIMEOUT = 10000; // 10 second timeout
|
|
static const int HTTP_CONNECT_TIMEOUT = 5000; // 5 second connection timeout
|
|
static const int WIFI_RETRY_MAX = 20; // Reduced from 50
|
|
static const int NTP_TIMEOUT = 15000; // Reduced from 30000
|
|
static const int LOOP_UPDATE_INTERVAL = 100; // Non-critical operations every 100ms
|
|
};
|
|
|
|
// Debug level control: 0=none, 1=errors, 2=info, 3=verbose
|
|
#define DEBUG_LEVEL 2 // Changed to 2 for better troubleshooting
|
|
#define HAS_WHITE_LED true // Set based on your hardware configuration
|
|
|
|
// REMOVED: Hard-coded NETWORK CREDENTIALS
|
|
// We will now load them from NVS or set up a provisioning portal.
|
|
|
|
// ========================================================================
|
|
// HARDWARE PIN DEFINITIONS
|
|
// ========================================================================
|
|
const int whiteLedPin = 5;
|
|
const int builtinLedPin = 21; // Correct pin for XIAO ESP32C3 built-in LED
|
|
const int buttonPin = 4; // GPIO pin for the pushbutton
|
|
const int presencePin = 9; // GPIO pin for the presence sensor
|
|
|
|
// ========================================================================
|
|
// LED CONTROL VARIABLES
|
|
// ========================================================================
|
|
bool ledsConnected = HAS_WHITE_LED;
|
|
bool areLedsOn = false;
|
|
|
|
// Variables for LED effects
|
|
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;
|
|
|
|
// Price thresholds for LEDs in EUR/kWh
|
|
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;
|
|
|
|
// ========================================================================
|
|
// SYSTEM VARIABLES
|
|
// ========================================================================
|
|
LiquidCrystal_I2C lcd(0x27, 20, 4);
|
|
|
|
const char* api_url = "https://api.energy-charts.info/price?bzn=SI";
|
|
|
|
// Timing and fetch control
|
|
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;
|
|
|
|
// V2.4a: API Success tracking for secondary list
|
|
int apiSuccessCount = 0;
|
|
int apiFailCount = 0;
|
|
|
|
// Time zone settings for Slovenia (CET/CEST)
|
|
const long gmtOffset_sec = 3600;
|
|
const int daylightOffset_sec = 3600;
|
|
|
|
// Power company and VAT fees
|
|
const bool APPLY_FEES_AND_VAT = true;
|
|
const float POWER_COMPANY_FEE_PERCENTAGE = 12.0;
|
|
const float VAT_PERCENTAGE = 22.0;
|
|
|
|
// Button handling with long press for manual refresh
|
|
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 = 2000; // 2 seconds for long press
|
|
|
|
// V2.0: Double-click detection variables (MINIMAL ADDITION)
|
|
unsigned long lastClickTime = 0;
|
|
const unsigned long doubleClickWindow = 500; // 500ms window for double-click
|
|
bool waitingForDoubleClick = false;
|
|
bool pendingClick = false;
|
|
|
|
// V2.3: Auto-scroll timeout feature
|
|
unsigned long lastButtonActivity = 0;
|
|
const unsigned long autoScrollTimeout = 10000; // 10 seconds timeout
|
|
bool autoScrollExecuted = false; // FIXED: Flag to prevent repeated auto-scroll
|
|
|
|
// V2.5: Display refresh timing for idle state
|
|
unsigned long lastHourlyRefresh = 0; // Track last hourly refresh to prevent multiple refreshes per hour
|
|
unsigned long last15MinRefresh = 0; // Track last 15-minute refresh for updating ">" placeholders
|
|
|
|
// V2.4a: Secondary list scrolling variables
|
|
int secondaryListOffset = 0; // Tracks current scroll position (increments of 4)
|
|
const int SECONDARY_LIST_TOTAL_LINES = 16;
|
|
const int SECONDARY_LIST_SCROLL_INCREMENT = 4;
|
|
|
|
// Backlight control
|
|
const unsigned long backlightOffDelay = 30000; // 30 seconds
|
|
unsigned long lastPresenceTime = 0;
|
|
bool presenceSensorConnected = false;
|
|
|
|
// Loop optimization
|
|
unsigned long lastLoopUpdate = 0;
|
|
|
|
// Display state management
|
|
enum DisplayState {
|
|
CURRENT_PRICES,
|
|
CUSTOM_MESSAGE,
|
|
NO_DATA_OFFSET,
|
|
};
|
|
DisplayState displayState = CURRENT_PRICES;
|
|
int timeOffsetHours = 0;
|
|
|
|
// V2.0: Simple list toggle (MINIMAL ADDITION)
|
|
enum ListType {
|
|
PRIMARY_LIST,
|
|
SECONDARY_LIST
|
|
};
|
|
ListType currentList = PRIMARY_LIST;
|
|
|
|
// Data storage - OPTIMIZED: StaticJsonDocument instead of Dynamic
|
|
StaticJsonDocument<Config::JSON_BUFFER_SIZE> doc;
|
|
bool isTodayDataAvailable = false;
|
|
float averagePrice = 0.0;
|
|
int lowestPriceIndex = -1;
|
|
int highestPriceIndex = -1;
|
|
|
|
// ========================================================================
|
|
// PROVISIONING VARIABLES
|
|
// ========================================================================
|
|
Preferences preferences; // NEW
|
|
DNSServer dnsServer; // NEW
|
|
WebServer server(80); // NEW
|
|
|
|
const char* ap_ssid = "MyTicker_Setup";
|
|
bool inProvisioningMode = false;
|
|
bool needsRestart = false;
|
|
|
|
// NEW: Non-blocking NTP sync flag
|
|
bool isTimeSynced = false;
|
|
|
|
// NEW: Flag to track if this is the initial boot
|
|
bool initialBoot = true;
|
|
|
|
|
|
// ========================================================================
|
|
// CUSTOM CHARACTER DEFINITIONS
|
|
// ========================================================================
|
|
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
|
|
};
|
|
|
|
// ========================================================================
|
|
// UTILITY FUNCTIONS
|
|
// ========================================================================
|
|
|
|
// Enhanced debug printing with levels
|
|
void debugPrint(int level, const String& message) {
|
|
#if DEBUG_LEVEL >= 1
|
|
if (DEBUG_LEVEL >= level) {
|
|
Serial.println("[DEBUG] " + message);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Helper function for special characters
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optimized comma printing
|
|
void commaPrint(float value, int places) {
|
|
String numStr = String(value, places);
|
|
numStr.replace('.', ',');
|
|
lcd.print(numStr);
|
|
}
|
|
|
|
// Enhanced time validation
|
|
bool isValidUnixTime(unsigned long timestamp) {
|
|
return (timestamp > 946684800UL && timestamp < 2147483647UL); // Valid range: 2000-2038
|
|
}
|
|
|
|
// ========================================================================
|
|
// WIFI PROVISIONING FUNCTIONS (NEW)
|
|
// ========================================================================
|
|
|
|
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'></form>";
|
|
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();
|
|
}
|
|
|
|
// ========================================================================
|
|
// WIFI AND NETWORK FUNCTIONS
|
|
// ========================================================================
|
|
|
|
void connectToWiFi() {
|
|
String stored_ssid = "";
|
|
String stored_pass = "";
|
|
|
|
// Load credentials from NVS
|
|
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");
|
|
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); // Prevent overflow on display
|
|
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!");
|
|
// If connection fails, start provisioning
|
|
startProvisioning();
|
|
}
|
|
} else {
|
|
// If no credentials found in NVS, start provisioning
|
|
startProvisioning();
|
|
}
|
|
}
|
|
|
|
|
|
// REMOVED: The blocking waitUntilTimeIsSet() function has been removed.
|
|
|
|
// ========================================================================
|
|
// LED CONTROL FUNCTIONS
|
|
// ========================================================================
|
|
|
|
void updateLeds() {
|
|
// MODIFIED: This function now requires isTimeSynced to be true
|
|
if (!ledsConnected || !areLedsOn || !isTodayDataAvailable || !isTimeSynced) {
|
|
digitalWrite(whiteLedPin, LOW);
|
|
breatheValue = 0;
|
|
breatheDir = 1;
|
|
blinkState = LOW;
|
|
doubleBlinkCount = 0;
|
|
return;
|
|
}
|
|
|
|
JsonArray prices = doc["price"];
|
|
|
|
struct tm timeinfo;
|
|
// MODIFIED: This check is now required here since we don't have a blocking time sync
|
|
if (!getLocalTime(&timeinfo)) return;
|
|
|
|
int currentHour = timeinfo.tm_hour; // Get current hour (0-23)
|
|
int quarterHourIndex = getCurrentQuarterHourIndex(); // Get current 15-min interval (0-3)
|
|
|
|
// Calculate the exact 15-minute interval index
|
|
int currentIntervalIndex = currentHour * 4 + quarterHourIndex;
|
|
|
|
if (currentIntervalIndex >= (int)prices.size()) {
|
|
digitalWrite(whiteLedPin, LOW);
|
|
return;
|
|
}
|
|
|
|
// Get the current 15-minute interval price (not hourly average)
|
|
float currentRate = prices[currentIntervalIndex].as<float>();
|
|
|
|
if (currentRate <= 0) {
|
|
digitalWrite(whiteLedPin, 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; // Convert to EUR/kWh
|
|
|
|
// LED patterns based on price thresholds
|
|
if (finalPrice <= PRICE_THRESHOLD_0_05) {
|
|
// Breathing effect
|
|
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) {
|
|
digitalWrite(whiteLedPin, HIGH);
|
|
doubleBlinkCount = 0;
|
|
} else if (finalPrice <= PRICE_THRESHOLD_0_25) {
|
|
if (millis() - lastBlinkMillis > BLINK_INTERVAL_1000MS) {
|
|
blinkState = !blinkState;
|
|
digitalWrite(whiteLedPin, blinkState);
|
|
lastBlinkMillis = millis();
|
|
}
|
|
doubleBlinkCount = 0;
|
|
} else if (finalPrice <= PRICE_THRESHOLD_0_35) {
|
|
if (millis() - lastBlinkMillis > BLINK_INTERVAL_500MS) {
|
|
blinkState = !blinkState;
|
|
digitalWrite(whiteLedPin, blinkState);
|
|
lastBlinkMillis = millis();
|
|
}
|
|
doubleBlinkCount = 0;
|
|
} else if (finalPrice <= PRICE_THRESHOLD_0_50) {
|
|
// Double-blinking
|
|
int targetBlinks = 2;
|
|
if (doubleBlinkCount < targetBlinks * 2) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
|
doubleBlinkState = !doubleBlinkState;
|
|
digitalWrite(whiteLedPin, doubleBlinkState);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
|
doubleBlinkCount = 0;
|
|
lastDoubleBlinkMillis = millis();
|
|
}
|
|
}
|
|
} else {
|
|
// Triple-blinking (short, short, long)
|
|
if (doubleBlinkCount == 0) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
|
digitalWrite(whiteLedPin, HIGH);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else if (doubleBlinkCount == 1) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
|
digitalWrite(whiteLedPin, LOW);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else if (doubleBlinkCount == 2) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
|
digitalWrite(whiteLedPin, HIGH);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else if (doubleBlinkCount == 3) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
|
digitalWrite(whiteLedPin, LOW);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else if (doubleBlinkCount == 4) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
|
digitalWrite(whiteLedPin, HIGH);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount++;
|
|
}
|
|
} else if (doubleBlinkCount == 5) {
|
|
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_LONG_ON_INTERVAL) {
|
|
digitalWrite(whiteLedPin, LOW);
|
|
lastDoubleBlinkMillis = millis();
|
|
doubleBlinkCount = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// DATA FETCHING AND PROCESSING
|
|
// ========================================================================
|
|
|
|
// Helper function to calculate 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; // 4 values per hour (15-minute intervals)
|
|
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 function to get current 15-minute interval index within the hour
|
|
int getCurrentQuarterHourIndex() {
|
|
struct tm timeinfo;
|
|
if (!getLocalTime(&timeinfo)) return 0;
|
|
|
|
int minute = timeinfo.tm_min;
|
|
return minute / 15; // Returns 0, 1, 2, or 3
|
|
}
|
|
|
|
// Helper function to format 15-minute price values in compact format
|
|
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; // Convert to EUR/kWh
|
|
|
|
// Convert to hundredths (0.1234 becomes 12.34)
|
|
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); // Includes the minus sign
|
|
}
|
|
}
|
|
|
|
// Helper function to display 15-minute details for current hour
|
|
void display15MinuteDetails(int row, int hourIndex, const JsonArray& prices) {
|
|
lcd.setCursor(0, row);
|
|
|
|
if (hourIndex < 0 || hourIndex >= 24) {
|
|
lcd.print(" ");
|
|
return;
|
|
}
|
|
|
|
// Enhanced wraparound detection (same logic as displayPriceRow)
|
|
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;
|
|
|
|
// Case 1: Early hours (0-5) shown when we're in evening (>= 18) - likely wraparound
|
|
if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) {
|
|
lcd.print(" ");
|
|
return;
|
|
}
|
|
|
|
// Case 2: General past hours (except near midnight transitions)
|
|
if (hourIndex < currentHour && currentHour < 22) {
|
|
lcd.print(" ");
|
|
return;
|
|
}
|
|
}
|
|
|
|
int startIndex = hourIndex * 4;
|
|
char priceBuffer[4];
|
|
int cursorPos = 0;
|
|
|
|
// Calculate current 15-minute segment (0-3) for the displayed hour
|
|
int currentSegment = -1;
|
|
if (hasValidTime && hourIndex == currentHour) {
|
|
currentSegment = currentMinute / 15; // 0-14min=0, 15-29min=1, 30-44min=2, 45-59min=3
|
|
}
|
|
|
|
for (int i = 0; i < 4; i++) {
|
|
bool shouldShowPlaceholder = false;
|
|
|
|
// Show placeholder for past segments if this is the current hour
|
|
if (hasValidTime && hourIndex == currentHour && i < currentSegment) {
|
|
shouldShowPlaceholder = true;
|
|
}
|
|
|
|
if (shouldShowPlaceholder) {
|
|
// Show placeholder for past 15-minute segments
|
|
lcd.print(" > ");
|
|
cursorPos += 3;
|
|
} else if (startIndex + i < (int)prices.size()) {
|
|
// Show actual price for current and future segments
|
|
float price = prices[startIndex + i].as<float>();
|
|
format15MinPrice(price, priceBuffer, sizeof(priceBuffer));
|
|
lcd.print(priceBuffer);
|
|
cursorPos += 3;
|
|
} else {
|
|
// No data available
|
|
lcd.print("---");
|
|
cursorPos += 3;
|
|
}
|
|
|
|
if (i < 3) {
|
|
lcd.print(" "); // Add spacing between values
|
|
cursorPos += 2;
|
|
}
|
|
}
|
|
|
|
// Fill remaining space (20 - cursorPos)
|
|
for (int i = cursorPos; i < 20; i++) {
|
|
lcd.print(" ");
|
|
}
|
|
}
|
|
|
|
void fetchAndProcessData() {
|
|
// MODIFIED: This function now requires isTimeSynced to be true
|
|
if (WiFi.status() != WL_CONNECTED || !isTimeSynced) {
|
|
debugPrint(1, "Not connected to WiFi or time not synced.");
|
|
apiFailCount++; // V2.4a: Track failures
|
|
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));
|
|
|
|
// 💡 OPTIMIZED: Deserialize directly from the HTTP stream to save RAM
|
|
doc.clear();
|
|
DeserializationError error = deserializeJson(doc, http.getStream());
|
|
|
|
if (error) {
|
|
debugPrint(1, "deserializeJson() failed: " + String(error.c_str()));
|
|
apiFailCount++;
|
|
// V5.0 FIX: Force display update on JSON failure
|
|
displayPrices();
|
|
http.end();
|
|
return;
|
|
}
|
|
|
|
// Success - update counters and timestamps
|
|
apiSuccessCount++;
|
|
time_t now;
|
|
time(&now);
|
|
lastSuccessfulFetchTime = now;
|
|
|
|
// Process and validate data
|
|
processJsonData();
|
|
|
|
httpGetRetryCount = 0;
|
|
|
|
debugPrint(2, "Data fetched and processed successfully");
|
|
|
|
// V5.0 FIX: Force an immediate display update after the entire process.
|
|
displayPrices();
|
|
|
|
// V5.3 QUICK RE-SCHEDULE: If fetch succeeds but the data is marked as stale,
|
|
// schedule the next fetch in 10 minutes (600 seconds).
|
|
if (!isTodayDataAvailable) {
|
|
time_t now;
|
|
time(&now);
|
|
nextScheduledFetchTime = now + 600;
|
|
debugPrint(2, "Data is stale. Next fetch aggressively scheduled in 10 minutes.");
|
|
}
|
|
|
|
} else {
|
|
debugPrint(1, "HTTP request failed with code: " + String(httpResponseCode));
|
|
apiFailCount++;
|
|
httpGetRetryCount++;
|
|
|
|
// V5.0 FIX: Force display update on HTTP failure
|
|
displayPrices();
|
|
|
|
// *** MODIFICATION START - V5.4: Aggressive Fetch Retry on Failure ***
|
|
// If HTTP failed, schedule an aggressive retry in 5 minutes (300 seconds)
|
|
time_t now;
|
|
time(&now);
|
|
nextScheduledFetchTime = now + 300;
|
|
debugPrint(2, "HTTP failed. Next fetch aggressively scheduled in 5 minutes.");
|
|
// *** MODIFICATION END ***
|
|
}
|
|
|
|
http.end();
|
|
}
|
|
|
|
void processJsonData() {
|
|
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;
|
|
}
|
|
|
|
// 💡 CORRECTED LOGIC: Check if the API data is for the current day
|
|
unsigned long firstUnixTime = unixSeconds[0].as<unsigned long>();
|
|
time_t firstDataTime = (time_t)firstUnixTime;
|
|
struct tm* timeinfo_ptr = localtime(&firstDataTime);
|
|
|
|
struct tm currentTimeinfo;
|
|
if (!getLocalTime(¤tTimeinfo)) {
|
|
debugPrint(1, "Could not get current time for data validation.");
|
|
isTodayDataAvailable = false;
|
|
return;
|
|
}
|
|
|
|
if (timeinfo_ptr != NULL && timeinfo_ptr->tm_mday != currentTimeinfo.tm_mday) {
|
|
debugPrint(1, "Fetched data is not for the current day. Ignoring.");
|
|
isTodayDataAvailable = false;
|
|
|
|
// V5.2 FIX: Reset the display state to force the NO_DATA_OFFSET screen.
|
|
if (displayState != NO_DATA_OFFSET) {
|
|
displayState = CURRENT_PRICES;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Data is for today, proceed with processing
|
|
isTodayDataAvailable = true;
|
|
|
|
// Calculate daily average and find min/max using hourly averages
|
|
float sum = 0;
|
|
float minPrice = 999999; // Initialize with large value
|
|
float maxPrice = -999999; // Initialize with small value
|
|
lowestPriceIndex = 0;
|
|
highestPriceIndex = 0;
|
|
|
|
// Calculate hourly averages and find min/max among them
|
|
for (int hour = 0; hour < 24; hour++) {
|
|
float hourlyAvg = getHourlyAverage(hour, prices);
|
|
if (hourlyAvg > 0) { // Only consider valid hourly averages
|
|
sum += hourlyAvg;
|
|
|
|
if (hourlyAvg < minPrice) {
|
|
minPrice = hourlyAvg;
|
|
lowestPriceIndex = hour * 4; // Convert back to 15-minute index for display
|
|
}
|
|
if (hourlyAvg > maxPrice) {
|
|
maxPrice = hourlyAvg;
|
|
highestPriceIndex = hour * 4; // Convert back to 15-minute index for display
|
|
}
|
|
}
|
|
}
|
|
|
|
averagePrice = sum / 24; // Average of 24 hourly averages
|
|
|
|
debugPrint(3, "Processed " + String(prices.size()) + " price entries (15-min intervals)");
|
|
debugPrint(3, "Daily average: " + String(averagePrice) + " EUR/MWh");
|
|
}
|
|
|
|
void displayPriceRow(int row, int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds) {
|
|
lcd.setCursor(0, row);
|
|
|
|
// Enhanced wraparound detection to prevent showing early hours after late hours
|
|
struct tm timeinfo;
|
|
if (getLocalTime(&timeinfo)) {
|
|
int currentHour = timeinfo.tm_hour;
|
|
|
|
// Case 1: Early hours (0-5) shown when we're in evening (>= 18) - likely wraparound
|
|
if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) {
|
|
lcd.print(" ");
|
|
return;
|
|
}
|
|
|
|
// Case 2: General past hours (except near midnight transitions)
|
|
if (hourIndex < currentHour && currentHour < 22) {
|
|
lcd.print(" ");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (hourIndex >= 0 && hourIndex < 24) {
|
|
// Calculate hourly average from 15-minute data
|
|
float rates = getHourlyAverage(hourIndex, prices);
|
|
|
|
// Use the first 15-minute timestamp of the hour for time display
|
|
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]; // 💡 OPTIMIZED: Use a buffer for formatting
|
|
int hour = time_ptr->tm_hour;
|
|
int minute = 0; // Always show full hour (00 minutes)
|
|
snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute);
|
|
//snprintf(buffer, sizeof(buffer), "%2dh", hour);
|
|
lcd.print(buffer);
|
|
|
|
// Price indicators based on hourly averages
|
|
if (dataIndex == lowestPriceIndex) {
|
|
lcd.setCursor(7, row);
|
|
lcd.write(byte(3)); // Low price indicator
|
|
lcd.print(" ");
|
|
} else if (dataIndex == highestPriceIndex) {
|
|
lcd.setCursor(7, row);
|
|
lcd.write(byte(4)); // High price indicator
|
|
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;
|
|
}
|
|
|
|
// Find current hour base index
|
|
int baseHour = 0;
|
|
struct tm timeinfo;
|
|
if (getLocalTime(&timeinfo)) {
|
|
baseHour = timeinfo.tm_hour; // Always use current hour as base
|
|
}
|
|
|
|
// Calculate display hours with smart end-of-day handling
|
|
int displayStartHour = baseHour + timeOffsetHours;
|
|
|
|
// Special end-of-day adjustment: if we're at hour 21-23, modify the base for scrolling
|
|
if (baseHour >= 21 && timeOffsetHours > 0) {
|
|
// Allow seeing hours 21, 22, 23 by adjusting the base
|
|
int adjustedBase = 21;
|
|
displayStartHour = adjustedBase + timeOffsetHours;
|
|
}
|
|
|
|
// Handle day wraparound
|
|
if (displayStartHour >= 24) {
|
|
displayStartHour = displayStartHour % 24;
|
|
}
|
|
|
|
// Row 0: 15-minute details for current hour (MOVED TO TOP)
|
|
display15MinuteDetails(0, displayStartHour, prices);
|
|
|
|
// Row 1: Current hour average (MOVED TO SECOND)
|
|
displayPriceRow(1, displayStartHour, prices, unixSeconds);
|
|
|
|
// Row 2: Next hour average
|
|
int nextHour = (displayStartHour + 1) % 24;
|
|
displayPriceRow(2, nextHour, prices, unixSeconds);
|
|
|
|
// Row 3: Hour after that
|
|
int hourAfter = (displayStartHour + 2) % 24;
|
|
displayPriceRow(3, hourAfter, prices, unixSeconds);
|
|
}
|
|
|
|
// V2.4a: Enhanced secondary list with 16 scrollable lines
|
|
void displaySecondaryList() {
|
|
// Generate all 16 lines of content
|
|
char lines[SECONDARY_LIST_TOTAL_LINES][21]; // 💡 OPTIMIZED: Use char arrays
|
|
|
|
// Line 0: Current Date & Time (HH:MM DD.MM.YYYY)
|
|
struct tm timeinfo;
|
|
if (getLocalTime(&timeinfo)) {
|
|
// V5.2 FIX: Reduced spaces from 6 to 3 to ensure fit: HH:MM DD.MM.YYYY (5 + 3 + 10 = 18 chars)
|
|
snprintf(lines[0], sizeof(lines[0]), "%d:%02d %d.%d.%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 ");
|
|
}
|
|
|
|
// Line 1: Separator
|
|
snprintf(lines[1], sizeof(lines[1]), "--------------------");
|
|
|
|
// Line 2: "Zadnja posodobitev:" header
|
|
snprintf(lines[2], sizeof(lines[2]), "Zadnja posodobitev:");
|
|
|
|
// Line 3: Timestamp (DD.MM.YYYY ob HH:MM)
|
|
struct tm* timeinfo_ptr = localtime(&lastSuccessfulFetchTime);
|
|
if (timeinfo_ptr != NULL) {
|
|
snprintf(lines[3], sizeof(lines[3]), "%d.%d.%04d ob %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 ");
|
|
}
|
|
|
|
// Line 4: Empty separator
|
|
snprintf(lines[4], sizeof(lines[4]), "");
|
|
|
|
// Line 5: "Dnevno povpre~je:" header
|
|
snprintf(lines[5], sizeof(lines[5]), "Dnevno povpre^je:");
|
|
|
|
// Line 6: Daily average value OR "Cene niso na voljo"
|
|
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]), numStr.c_str());
|
|
} else {
|
|
snprintf(lines[6], sizeof(lines[6]), "Cene niso na voljo.");
|
|
}
|
|
|
|
|
|
// Line 7: Empty separator
|
|
snprintf(lines[7], sizeof(lines[7]), "");
|
|
|
|
// Line 8: WiFi signal strength
|
|
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");
|
|
}
|
|
|
|
// Line 9: IP Address
|
|
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");
|
|
}
|
|
|
|
// Line 10: API Success Rate
|
|
int totalApiCalls = apiSuccessCount + apiFailCount;
|
|
if (totalApiCalls > 0) {
|
|
int successRate = (apiSuccessCount * 100) / totalApiCalls;
|
|
snprintf(lines[10], sizeof(lines[10]), "API: %d%% (%d/%d)", successRate, apiSuccessCount, totalApiCalls);
|
|
} else {
|
|
snprintf(lines[10], sizeof(lines[10]), "API: No calls yet");
|
|
}
|
|
|
|
// Line 11: System uptime
|
|
unsigned long uptimeMs = millis();
|
|
unsigned long days = uptimeMs / (24 * 60 * 60 * 1000);
|
|
uptimeMs %= (24 * 60 * 60 * 1000);
|
|
unsigned long hours = uptimeMs / (60 * 60 * 1000);
|
|
uptimeMs %= (60 * 60 * 1000);
|
|
unsigned long minutes = uptimeMs / (60 * 1000);
|
|
snprintf(lines[11], sizeof(lines[11]), "Up: %ldd %ldh %ldm", days, hours, minutes);
|
|
|
|
// Lines 12-15: Static credits
|
|
snprintf(lines[12], sizeof(lines[12]), "energy-charts.info");
|
|
snprintf(lines[13], sizeof(lines[13]), "dynamic electricity");
|
|
snprintf(lines[14], sizeof(lines[14]), "price ticker v5.5");
|
|
snprintf(lines[15], sizeof(lines[15]), "by Amir Toki^ (2025)");
|
|
|
|
// Display current 4-line window based on scroll offset
|
|
for (int i = 0; i < 4; i++) {
|
|
int lineIndex = secondaryListOffset + i;
|
|
lcd.setCursor(0, i);
|
|
|
|
if (lineIndex < SECONDARY_LIST_TOTAL_LINES) {
|
|
lcdPrint(lines[lineIndex]);
|
|
// Clear remainder
|
|
for (int j = strlen(lines[lineIndex]); j < 20; j++) {
|
|
lcd.print(" ");
|
|
}
|
|
} else {
|
|
// Clear empty lines
|
|
lcd.print(" ");
|
|
}
|
|
}
|
|
}
|
|
|
|
void displayPrices() {
|
|
lcd.clear();
|
|
// MODIFIED: This check has been moved to the start of the function
|
|
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;
|
|
}
|
|
|
|
// *** MODIFICATION START - V5.4 CORE FIX: Absolute State Enforcement ***
|
|
if (!isTodayDataAvailable) {
|
|
// If data is unavailable (LED off), absolutely force the NO_DATA_OFFSET state.
|
|
// This resolves the screen/LED desync and ensures stale data is not displayed.
|
|
displayState = NO_DATA_OFFSET;
|
|
timeOffsetHours = 0; // Reset scroll position too
|
|
} else if (displayState == NO_DATA_OFFSET) {
|
|
// If data IS available now, and we were stuck on the NO_DATA screen, reset to CURRENT_PRICES.
|
|
displayState = CURRENT_PRICES;
|
|
timeOffsetHours = 0;
|
|
}
|
|
// *** MODIFICATION END ***
|
|
|
|
|
|
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;
|
|
}
|
|
// NEW: Reset the initialBoot flag after the first successful display update.
|
|
initialBoot = false;
|
|
}
|
|
|
|
void resetDisplayToTop() {
|
|
// Always reset to show current hour (offset 0)
|
|
timeOffsetHours = 0;
|
|
secondaryListOffset = 0;
|
|
displayState = CURRENT_PRICES;
|
|
currentList = PRIMARY_LIST;
|
|
|
|
debugPrint(2, "Auto-scroll timeout - Reset display to current hour");
|
|
displayPrices();
|
|
}
|
|
|
|
void advanceDisplayOffset() {
|
|
// MODIFIED: This function now requires isTimeSynced to be true
|
|
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 {
|
|
// Get current hour for scrolling logic
|
|
struct tm timeinfo;
|
|
int currentHour = 0;
|
|
if (getLocalTime(&timeinfo)) {
|
|
currentHour = timeinfo.tm_hour;
|
|
}
|
|
|
|
// Calculate maximum offset with minimum scrollability at end of day
|
|
int maxOffset = 23 - currentHour;
|
|
|
|
// Ensure minimum scrollability at end of day (can see at least 2 previous hours)
|
|
if (maxOffset < 2 && currentHour >= 21) {
|
|
maxOffset = 2;
|
|
}
|
|
|
|
// Advance by 1 hour, but don't exceed the maximum
|
|
int nextOffsetHours = timeOffsetHours + 1;
|
|
|
|
if (nextOffsetHours > maxOffset) {
|
|
// Reset to beginning (current hour)
|
|
timeOffsetHours = 0;
|
|
displayState = CURRENT_PRICES;
|
|
} else {
|
|
timeOffsetHours = nextOffsetHours;
|
|
}
|
|
}
|
|
displayPrices();
|
|
}
|
|
|
|
void toggleList() {
|
|
// MODIFIED: This function now requires isTimeSynced to be true
|
|
if (!isTimeSynced) return;
|
|
currentList = (currentList == PRIMARY_LIST) ? SECONDARY_LIST : PRIMARY_LIST;
|
|
debugPrint(2, "Toggled to " + String(currentList == PRIMARY_LIST ? "PRIMARY" : "SECONDARY") + " list");
|
|
displayPrices();
|
|
}
|
|
|
|
void handleButton() {
|
|
// MODIFIED: Button presses are now handled even if time isn't synced
|
|
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;
|
|
|
|
// Clear screen and show message immediately
|
|
lcd.clear();
|
|
lcd.setCursor(0, 0);
|
|
lcd.print("Manual Refresh...");
|
|
lcd.setCursor(0, 1);
|
|
lcd.print("Please wait...");
|
|
|
|
// Force the fetch immediately
|
|
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 AND DATA FETCHING
|
|
// ========================================================================
|
|
|
|
void handleDataFetching() {
|
|
// MODIFIED: Now requires isTimeSynced to be true to run
|
|
if (!isTimeSynced) {
|
|
return;
|
|
}
|
|
time_t now;
|
|
time(&now);
|
|
|
|
if (now >= nextScheduledFetchTime) {
|
|
debugPrint(2, "Scheduled data fetch triggered");
|
|
fetchAndProcessData();
|
|
|
|
struct tm* timeinfo_ptr = localtime(&now);
|
|
if (timeinfo_ptr != NULL) {
|
|
lastSuccessfulFetchDay = timeinfo_ptr->tm_mday;
|
|
|
|
// Only reschedule to the top of the next hour if we aren't already aggressively retrying
|
|
// Aggressive retries are 300s (HTTP failed) or 600s (Stale data)
|
|
if (nextScheduledFetchTime < now + 300) {
|
|
time_t nextHour = now - (timeinfo_ptr->tm_min * 60) - timeinfo_ptr->tm_sec + 3600;
|
|
nextScheduledFetchTime = nextHour;
|
|
debugPrint(2, "Next fetch scheduled for the top of the next hour.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// SETUP AND MAIN LOOP
|
|
// ========================================================================
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
debugPrint(2, "Starting Dynamic Electricity Ticker v5.5 (15-MINUTE DETAIL MODE)");
|
|
|
|
pinMode(builtinLedPin, OUTPUT);
|
|
pinMode(whiteLedPin, OUTPUT);
|
|
pinMode(buttonPin, INPUT_PULLUP);
|
|
|
|
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);
|
|
|
|
// MODIFIED: connectToWiFi() no longer just connects, it also provisions
|
|
connectToWiFi();
|
|
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
// MODIFIED: Time sync is now non-blocking
|
|
configTime(gmtOffset_sec, daylightOffset_sec, "pool.ntp.org");
|
|
// REMOVED: No more blocking `waitUntilTimeIsSet()` call
|
|
}
|
|
|
|
debugPrint(2, "Setup completed successfully");
|
|
}
|
|
|
|
void loop() {
|
|
if (needsRestart) {
|
|
delay(100);
|
|
ESP.restart();
|
|
}
|
|
|
|
if (inProvisioningMode) {
|
|
handleProvisioning();
|
|
return; // Do not run main ticker loop logic
|
|
}
|
|
|
|
// NEW: Non-blocking time sync check
|
|
if (!isTimeSynced) {
|
|
struct tm timeinfo;
|
|
if (getLocalTime(&timeinfo)) {
|
|
debugPrint(2, "NTP synchronization successful");
|
|
isTimeSynced = true;
|
|
lastButtonActivity = millis();
|
|
autoScrollExecuted = false;
|
|
// First fetch after time sync
|
|
time_t now;
|
|
time(&now);
|
|
nextScheduledFetchTime = now;
|
|
displayPrices(); // Update display to show real data
|
|
} else {
|
|
// Display syncing message while we wait for time
|
|
lcd.setCursor(0, 0);
|
|
lcd.print("Connecting...");
|
|
lcd.setCursor(0, 1);
|
|
lcd.print("Syncing Time...");
|
|
return; // Skip the rest of the loop until time is synced
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// Hourly refresh at top of hour
|
|
if (currentMinute == 0 && lastHourlyRefresh != currentHour) {
|
|
debugPrint(2, "Top of hour refresh - Hour: " + String(currentHour));
|
|
displayPrices();
|
|
lastHourlyRefresh = currentHour;
|
|
}
|
|
|
|
// 15-minute refresh for updating ">" placeholders (at minutes 0, 15, 30, 45)
|
|
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();
|
|
}
|
|
}
|