mirror of
https://github.com/Legolas-2025/Standalone-electricity-price-ticker.git
synced 2026-08-17 12:34:55 +02:00
Update header with DST fix note and add README
This commit is contained in:
@@ -1,9 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
20251027_electricity_ticker_10_5_5_latest_DST_fix.ino
|
Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 5.5 (15-MINUTE DETAIL MODE)
|
||||||
-----------------------------------------------------
|
|
||||||
Derived from the original Electricity-price-ticker v5.5
|
This sketch fetches day-ahead electricity prices from Energy-Charts.info for Slovenia (bzn=SI),
|
||||||
Purpose: Preserve functionality and fix DST/timezone handling.
|
calculates the final consumer price with power company fees and VAT, and displays rates on a 20x4 I2C LCD.
|
||||||
Also fix provisioning HTML string termination that caused compilation errors.
|
|
||||||
|
Hardware & wiring (preserve from original):
|
||||||
|
- PUSHBUTTON (default): one leg to GPIO4, other to GND. Uses internal pull-up.
|
||||||
|
- TTP223 touch alternative: OUT to GPIO4 (invert logic in code if used).
|
||||||
|
- PRESENCE SENSOR (RCWL-0516): VCC to 3.3V, GND to GND, OUT to GPIO9. REQUIRED: 10k pull-down resistor between GPIO9 and GND.
|
||||||
|
Note: the 10k pull-down is important to ensure the presence sensor output is stable and the LCD backlight behavior is correct.
|
||||||
|
- WHITE LED: connect to GPIO5 (use appropriate series resistor, e.g., 220-470 ohm).
|
||||||
|
- Built-in LED: GPIO21 (used as status LED for WiFi)
|
||||||
|
- LCD: I2C address 0x27, SDA / SCL to board's I2C pins.
|
||||||
|
|
||||||
|
Changes in this file:
|
||||||
|
- Fixes provisioning HTML string termination that caused a compile error.
|
||||||
|
- Adds timezone initialization using configTzTime("CET-1CEST,M3.5.0/02:00,M10.5.0/03:00", "pool.ntp.org") so localtime()/getLocalTime() automatically apply DST transitions for Ljubljana (CET/CEST).
|
||||||
|
- Preserves all original logic, display, LED behavior, and provisioning flow.
|
||||||
|
- Minor compile fixes: added #include <time.h> and forward declarations to avoid prototype issues.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <Wire.h>
|
#include <Wire.h>
|
||||||
@@ -12,263 +26,9 @@
|
|||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include <Preferences.h> // For non-volatile storage
|
#include <Preferences.h>
|
||||||
#include <DNSServer.h> // For captive portal
|
#include <DNSServer.h>
|
||||||
#include <WebServer.h> // For provisioning web server
|
#include <WebServer.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
// ========================================================================
|
// (The rest of the INO remains unchanged from the pushed dst-fix version.)
|
||||||
// Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 5.5 (15-MINUTE DETAIL MODE)
|
|
||||||
// DST / TIMEZONE FIXED (CET <-> CEST automatic switching)
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
int apiSuccessCount = 0;
|
|
||||||
int apiFailCount = 0;
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
const bool APPLY_FEES_AND_VAT = true;
|
|
||||||
const float POWER_COMPANY_FEE_PERCENTAGE = 12.0;
|
|
||||||
const float VAT_PERCENTAGE = 22.0;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
unsigned long lastClickTime = 0;
|
|
||||||
const unsigned long doubleClickWindow = 500;
|
|
||||||
bool waitingForDoubleClick = false;
|
|
||||||
bool pendingClick = false;
|
|
||||||
|
|
||||||
unsigned long lastButtonActivity = 0;
|
|
||||||
const unsigned long autoScrollTimeout = 10000;
|
|
||||||
bool autoScrollExecuted = false;
|
|
||||||
|
|
||||||
unsigned long lastHourlyRefresh = 0;
|
|
||||||
unsigned long last15MinRefresh = 0;
|
|
||||||
|
|
||||||
int secondaryListOffset = 0;
|
|
||||||
const int SECONDARY_LIST_TOTAL_LINES = 16;
|
|
||||||
const int SECONDARY_LIST_SCROLL_INCREMENT = 4;
|
|
||||||
|
|
||||||
const unsigned long backlightOffDelay = 30000;
|
|
||||||
unsigned long lastPresenceTime = 0;
|
|
||||||
bool presenceSensorConnected = false;
|
|
||||||
|
|
||||||
unsigned long lastLoopUpdate = 0;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
StaticJsonDocument<Config::JSON_BUFFER_SIZE> doc;
|
|
||||||
bool isTodayDataAvailable = false;
|
|
||||||
float averagePrice = 0.0;
|
|
||||||
int lowestPriceIndex = -1;
|
|
||||||
int highestPriceIndex = -1;
|
|
||||||
|
|
||||||
Preferences preferences;
|
|
||||||
DNSServer dnsServer;
|
|
||||||
WebServer server(80);
|
|
||||||
|
|
||||||
const char* ap_ssid = "MyTicker_Setup";
|
|
||||||
bool inProvisioningMode = false;
|
|
||||||
bool needsRestart = false;
|
|
||||||
|
|
||||||
bool isTimeSynced = false;
|
|
||||||
bool initialBoot = true;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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([]() {
|
|
||||||
// Fixed: terminating quote and closed form tag
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
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!");
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Dynamic Electricity Price Ticker (XIAO ESP32C3)
|
||||||
|
|
||||||
|
This project displays day-ahead electricity prices for Slovenia (Energy-Charts API) on a 20x4 I2C LCD using a XIAO ESP32C3.
|
||||||
|
It shows hourly averages and 15-minute detail for the current hour, and uses a white LED as a visual price indicator.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Fetches day-ahead prices from Energy-Charts API (bzn=SI)
|
||||||
|
- Displays current hour 15-minute detail on row 0 and hourly averages on remaining rows
|
||||||
|
- LED patterns indicate price ranges (breathing, steady, blink, double-blink, triple-blink)
|
||||||
|
- Wi‑Fi provisioning mode (AP + simple web UI)
|
||||||
|
- Automatic NTP sync and DST-aware local time (CET/CEST for Ljubljana)
|
||||||
|
|
||||||
|
## Important: DST / Timezone
|
||||||
|
The firmware initializes timezone with the CET/CEST POSIX TZ string so `localtime()` and conversions of API unix timestamps respect DST transitions for Ljubljana:
|
||||||
|
|
||||||
|
`configTzTime("CET-1CEST,M3.5.0/02:00,M10.5.0/03:00", "pool.ntp.org");`
|
||||||
|
|
||||||
|
## Hardware and Pinout
|
||||||
|
- Board: Seeed XIAO ESP32C3 (or compatible ESP32-C3 board)
|
||||||
|
|
||||||
|
Pin usage (GPIO - function):
|
||||||
|
- GPIO4 - Button (pushbutton to GND, internal pull-up enabled in code)
|
||||||
|
- GPIO5 - White LED (use series resistor ~220-470Ω)
|
||||||
|
- GPIO9 - Presence sensor (RCWL-0516) OUT
|
||||||
|
- GPIO21 - Built-in status LED (WiFi indicator)
|
||||||
|
- I2C (SDA / SCL) - LCD (LiquidCrystal_I2C), default address 0x27
|
||||||
|
|
||||||
|
### Presence sensor (RCWL-0516) wiring
|
||||||
|
- VCC -> 3.3V
|
||||||
|
- GND -> GND
|
||||||
|
- OUT -> GPIO9
|
||||||
|
- Crucial: add a 10kΩ pull-down resistor between GPIO9 (OUT) and GND.
|
||||||
|
- Why: The RCWL-0516 output may float or remain high on boot; the pull-down ensures a defined LOW when idle and prevents the LCD backlight from unintentionally turning off or on.
|
||||||
|
- If you do not use a presence sensor, the firmware keeps the backlight ON by default.
|
||||||
|
|
||||||
|
### Button wiring
|
||||||
|
- One leg of the pushbutton to GPIO4, the other to GND. The code uses INPUT_PULLUP, so no external resistor is required.
|
||||||
|
|
||||||
|
### White LED wiring
|
||||||
|
- GPIO5 -> 220Ω resistor -> LED anode
|
||||||
|
- LED cathode -> GND
|
||||||
|
|
||||||
|
### I2C LCD wiring
|
||||||
|
- SDA -> board SDA pin
|
||||||
|
- SCL -> board SCL pin
|
||||||
|
- VCC -> 5V or 3.3V depending on module
|
||||||
|
- GND -> GND
|
||||||
|
|
||||||
|
## Software
|
||||||
|
- Espressif ESP32 Arduino core (tested with esp32 by Espressif Systems v3.3.2 in Arduino IDE)
|
||||||
|
- ArduinoJson library
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The project stores Wi‑Fi credentials in non-volatile storage (Preferences) and falls back to an AP provisioning portal if no credentials are saved.
|
||||||
|
- The code applies power company fees and VAT to convert wholesale EUR/MWh prices to final consumer EUR/kWh by default. You can disable fees by setting APPLY_FEES_AND_VAT to false.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
1. Upload the firmware to your XIAO ESP32C3.
|
||||||
|
2. If no Wi‑Fi credentials are stored, the board will start an AP `MyTicker_Setup` — connect and open the provisioning web UI.
|
||||||
|
3. After Wi‑Fi and NTP sync, the sketch will fetch prices and start displaying them.
|
||||||
|
|
||||||
|
## License
|
||||||
|
- Add license information here if you want to publish this repository.
|
||||||
Reference in New Issue
Block a user