This guide explains how to use a Lilygo T-Display or T-Display S3 to show the electricity usage (digital counter data) live via a HomeWizard P1 meter. Optionally, you can also control Energy Sockets based on solar production.
Testing across multiple home networks shows that the Lilygo T-Display has a much better network connection than the T-Display S3. The latter sometimes fails to detect devices or suffers from poor connectivity, whereas the Lilygo T-Display performs without issues.
Lilygo T-Display has a more stable WiFi connection than T-Display S3
Open the HomeWizard app and enable the local API for your P1 meter and sockets under Settings > Meters.
You need the IP address or preferably the hostnameInserting the data by name instead of IP number has the advantage that you don't have to change anything, should the IP address change afterwards of your P1 meter and sockets.
If no P1 meter was already inserted (or if you removed it in the setup html), the board will automatically scan your network and detect P1 meter and sockets itself. After the scan, the hostnames of the found devices will be written to flash memory. After that, the board reboots.
If you want to check this yourself, you can log into your router manually or use tools such as Net Scan or Fing.
The P1 meter advertises its name and has port 80 open.
On startup, the display will try to connect to WiFi. If this fails, it starts an access point (Lily). Connect to it (first turn off your mobile data), open your browser to 192.168.4.1
and complete the form.
p1meter-123abc.local
) to avoid problems with changing IPs.
Use the Arduino IDE to upload the sketch below to your Lilygo T-Display. The sketch works on both T-Display and T-Display S3. Download this sketch
/**
* @file AutoSockets_T.ino
*
* @brief ESP32 sketch for Lilygo T-Display / T-Display S3: HomeWizard Device Scanner and AutoSockets Controller.
*
* @author johanok@gmail.com
*
* @modes
* - Scanner Mode: Scans network for HomeWizard P1 meter
* - P1 Meter Mode: P1 meter monitoring (polled every second) - Optional Automatic Socket Controller
*
* @features
* - Automatic network scanning for P1 meter
* - Multi-core scanning implementation
* - WiFi configuration via web interface
* - TFT display output for real-time data
* - Persistent storage of settings
* - Manages HomeWizard Energy Sockets based on P1 meter data
*
* @hardware
* - ESP32 microcontroller LilyGo TTGO T-Display or LilyGo TTGO T-Display S3
* - HomeWizard P1 meter
*
* @note
* - Automatic restart after 2 minutes of connection failures
*
* @section setup Setup
* Web-based configuration at 192.168.4.1:
* - Mandatory: WiFi network settings
* - Optional: Predefined P1 meter hostname or address
* - Auto-scan: Finds P1 meter and Energy Sockets if address unknown
* - Device will automatically find and connect to P1 meter
*
* @section usage Usage
* - Normal operation: Displays current power usage
* - Long press flash button: Reset WiFi settings
*
* @section BootBehavior Boot Behavior
* - ** No P1 meter found: Automatically starts scanner mode
* - ** Normal startup:** The sketch starts in P1 meter reading and "AutoSockets Controller" mode.
*
* @section SocketManagement Socket Management Details (AutoSockets Mode - to handle any network problems)
* - **Normal polling interval:** Sockets are polled every 6 seconds.
* - **Normal retry interval (on failure):** After a failure, attempts to reconnect every 60 seconds (socketRetryInterval).
* - **Extended retry interval (on persistent failure):** After 10 consecutive failures, the socket switches to a 10-minute
* - retry interval (extendedRetryInterval).
* - **Automatic recovery:** The socket automatically reverts to the normal interval once the connection is restored.
*/
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h> // store data in EEPROM memory
#include <TFT_eSPI.h> // display
#include <vector>
#define HTTP_PORT 80
HTTPClient httpClient;
WiFiClient wifiClient;
WebServer server(HTTP_PORT);
Preferences flash;
TFT_eSPI tft = TFT_eSPI(); // LilyGo TTGO T-Display: User_Setup_Select.h: Setup25_TTGO_T_Display.h
TFT_eSprite sp = TFT_eSprite(&tft); // LilyGo T-Display S3 : User_Setup_Select.h: Setup206_Lilygo_T_Display_S3.h
#define SCREEN_ROTATION_USB_LEFT 3
// scanner defines & variables
#define PROGRESS_BAR_HEIGHT 15
#define PROGRESS_BAR_WIDTH 200
#define PROGRESS_BAR_X (tft.width() / 2 - PROGRESS_BAR_WIDTH / 2)
#define PROGRESS_BAR_Y 50
#define TASK_COMPLETE_BIT(num) (1 << (num))
const int IP_RANGE_START = 1, IP_RANGE_END = 254, NUM_TASKS = 16;
int socketCount = 0, totalScanned = 0, currentIP = IP_RANGE_START;
String p1Hostname = "", socketHostnames[3];
SemaphoreHandle_t ipMutex;
EventGroupHandle_t scanEventGroup;
IPAddress baseIP;
bool p1Stored = false;
static uint32_t lastProgressUpdate = 0;
const uint32_t PROGRESS_UPDATE_INTERVAL = 200;
constexpr const char* defaultIP = "192.168.0.0";
// p1 meter & socket handling variables & structures
constexpr uint8_t numSockets = 3, textLineSpacing = 12, MAX_IP_LENGTH = 64, MAX_NAME_LENGTH = 32;
constexpr uint8_t powerBufferSize = 5, maxConsecutiveFailures = 10;
constexpr uint16_t defaultP1MeterUpdateInterval = 1000, defaultSocketUpdateInterval = 6000;
constexpr uint32_t maxHttpFailureDuration = 2 * 60 * 1000, socketRetryInterval = 60 * 1000, extendedRetryInterval = 10 * 60 * 1000;
bool hasRegisteredSockets = false, scannerMode = false, allowAutoControl = false, autoControlEnabled = false, lastButtonStateOther = HIGH;
const unsigned long hysteresisDelay = 1000, RESTART_DELAY_MS = 5000, MIN_SOCKET_SWITCH_INTERVAL_MS = 10000;
uint8_t powerBufferIndex = 0, buttonOther = 0; // "other" = opposite flash button
uint16_t spriteWidth, spriteHeight;
uint16_t socketUpdateInterval = defaultSocketUpdateInterval;
int16_t powerWattBuffer[powerBufferSize] = { 0 }, powerWattAverage = 0;
uint32_t lastButtonPressFlash = 0, lastButtonPressOther = 0;
std::vector<String> availableSSIDs;
struct Socket {
char hostNameOrIP[MAX_IP_LENGTH];
char name[MAX_NAME_LENGTH]; // (e.g. "boiler", "bathroom")
int power; // current power usage in Watts
uint8_t consecutiveFailures = 0;
bool useExtendedInterval = false;
bool lastRequestSuccess = false;
bool offline = false;
bool autoControlEnabled = false;
unsigned long lastTurnedOffMillis = 0;
uint16_t switchOnWatt = 10000; // threshold (in Watts) to automatically turn on the socket
uint16_t switchOffWatt = 0; // threshold (in Watts) to automatically switch off the socket
uint32_t offDelay = 2000; // delay (in ms) before auto turning off the socket
uint32_t lastUpdate = 0;
uint64_t lastCheck = 0;
};
Socket sockets[numSockets];
struct P1Data {
char hostnameOrIp[MAX_IP_LENGTH];
int activeTariff; // (e.g., day or night tariff)
int activePowerW; // measured power flow in Watts
uint16_t monthlyPeak; // also in Watts
unsigned long lastUpdate = 0;
uint16_t updateInterval = defaultP1MeterUpdateInterval;
};
P1Data p1;
void setup() {
Serial.begin(115200);
tft.init();
tft.setRotation(SCREEN_ROTATION_USB_LEFT);
tft.fillScreen(TFT_BLACK);
flash.begin("P1-app", true);
String savedP1Host = flash.getString("ipM", "");
flash.end();
if (savedP1Host.isEmpty()) {
scannerMode = true;
tft.drawCentreString("Scanner Mode", tft.width() / 2, 0, 4);
tft.drawCentreString("No P1 meter found", tft.width() / 2, 24, 2);
delay(1000);
setupScanner();
} else {
tft.drawCentreString("P1 meter Mode", tft.width() / 2, 0, 4);
tft.drawCentreString("Normal startup", tft.width() / 2, 24, 2);
delay(1000);
setupAutoSockets();
}
}
void loop() {
scannerMode ? loopScanner() : loopAutoSockets();
}
void showMessageNoConnection() { // message on display: no WiFi connection
const char* noC[] = { "WiFi: no connection.", "Connect to hotspot", "'Lily', open browser", "address 192.168.4.1", "for login + password." };
tft.fillScreen(TFT_NAVY);
tft.setTextColor(TFT_YELLOW);
for (uint8_t i = 0; i < 5; i++) tft.drawCentreString(noC[i], 120, i * 27, 4);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
}
// ========== SCANNER MODE FUNCTIONS ==========
void setupScanner() {
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("HomeWizard Scan", tft.width() / 2, 0, 4);
flash.begin("login_data", true);
String wifiSsid = flash.getString("ssid", ""); // we obviously need the WiFi credentials to scan the local network
String wifiPassword = flash.getString("pasw", "");
flash.end();
if (wifiSsid != "") {
tft.drawCentreString("Connecting to WiFi...", tft.width() / 2, 24, 4);
WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
unsigned long startTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startTime < 10000) delay(250);
}
if (WiFi.status() != WL_CONNECTED) {
showMessageNoConnection();
startWiFiAccessPoint();
return;
}
Serial.println("\nWiFi connected.");
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("WiFi connected!", tft.width() / 2, 24, 4);
delay(1000);
baseIP = WiFi.localIP();
String ipPrefix = String(baseIP[0]) + "." + String(baseIP[1]) + "." + String(baseIP[2]);
Serial.println("Scan IPs " + ipPrefix + ".1 --> " + ipPrefix + ".255");
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("Scanning network", tft.width() / 2, 0, 4);
tft.drawCentreString(ipPrefix + ".1 >> " + ipPrefix + ".255", tft.width() / 2, 26, 2);
tft.drawRect(PROGRESS_BAR_X - 2, PROGRESS_BAR_Y - 2, PROGRESS_BAR_WIDTH + 4, PROGRESS_BAR_HEIGHT + 4, TFT_WHITE);
updateProgressBar(0);
ipMutex = xSemaphoreCreateMutex();
scanEventGroup = xEventGroupCreate();
for (int i = 0; i < NUM_TASKS; i++) {
char taskName[16];
snprintf(taskName, sizeof(taskName), "ScannerTask%d", i);
xTaskCreatePinnedToCore(scannerTask, taskName, 8192, (void*)i, 1, NULL, 0);
}
}
void loopScanner() {
EventBits_t bits = xEventGroupWaitBits(scanEventGroup, (1 << NUM_TASKS) - 1, true, true, portMAX_DELAY);
if (bits == ((1 << NUM_TASKS) - 1)) displayScanResults(); // call displayScanResults() if NUM_TASKS bits are set to "1".
}
void updateProgressBar(int percentage) {
static int lastPercentage = -1;
if (percentage != lastPercentage || millis() - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL) {
int progressWidth = (percentage * PROGRESS_BAR_WIDTH) / 100;
if (percentage > lastPercentage)
tft.fillRect(PROGRESS_BAR_X + (lastPercentage * PROGRESS_BAR_WIDTH / 100), PROGRESS_BAR_Y,
progressWidth - (lastPercentage * PROGRESS_BAR_WIDTH / 100), PROGRESS_BAR_HEIGHT, TFT_CYAN);
if (percentage != lastPercentage) {
char buffer[8];
snprintf(buffer, sizeof(buffer), "%3d%%", percentage);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawCentreString(buffer, tft.width() / 2, PROGRESS_BAR_Y + 24, 4);
}
lastPercentage = percentage;
lastProgressUpdate = millis();
}
}
void displayScanResults() {
Serial.println("\n--- Scan complete ---");
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawCentreString("---Scan Complete---", tft.width() / 2, 2, 4);
tft.setTextColor(TFT_CYAN, TFT_BLACK);
tft.drawCentreString("Results (found hostnames)", tft.width() / 2, 22, 2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(0, 24);
flash.begin("P1-app", false);
if (p1Stored) {
Serial.println("Found P1 meter: " + p1Hostname);
flash.putString("ipM", p1Hostname);
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
tft.drawCentreString(p1Hostname, tft.width() / 2, 40, 2);
}
for (int i = 0; i < socketCount; i++) {
Serial.printf("Found socket %d: %s\n", i + 1, socketHostnames[i].c_str());
String key = "ipS" + String(i + 1);
flash.putString(key.c_str(), socketHostnames[i]);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawCentreString(socketHostnames[i], tft.width() / 2, 60 + i * 20, 2);
}
flash.end();
if (!p1Stored && socketCount == 0) {
tft.drawCentreString("No HomeWizard devices found", tft.width() / 2, tft.height() - 20, 2);
delay(2000);
startWiFiAccessPoint(); // this allows the user to manually enter the hostname of the p1 meter / sockets
} else {
tft.setTextColor(TFT_CYAN, TFT_BLACK);
tft.drawCentreString("Restarting for P1 reading mode", tft.width() / 2, tft.height() - 20, 2);
delay(2000);
ESP.restart();
}
}
void scannerTask(void* parameter) {
int taskNum = (int)parameter;
while (true) {
int ipLastOctet = -1;
xSemaphoreTake(ipMutex, portMAX_DELAY);
if (currentIP <= IP_RANGE_END) ipLastOctet = currentIP++;
xSemaphoreGive(ipMutex);
if (ipLastOctet == -1) {
xEventGroupSetBits(scanEventGroup, TASK_COMPLETE_BIT(taskNum)); // mark task as complete
vTaskDelete(NULL);
}
IPAddress targetIP(baseIP[0], baseIP[1], baseIP[2], ipLastOctet);
String url = "http://" + targetIP.toString() + "/api";
HTTPClient http;
http.setTimeout(400);
http.begin(url);
int httpCode = http.GET();
if (httpCode == 200) {
String payload = http.getString();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
const char* product_type = doc["product_type"];
const char* serial = doc["serial"];
String hostname;
if (String(product_type) == "HWE-P1") hostname = "p1meter-";
else if (String(product_type) == "HWE-SKT") hostname = "energysocket-";
else hostname = "unknown-";
hostname += String(serial).substring(strlen(serial) - 6);
hostname += ".local";
xSemaphoreTake(ipMutex, portMAX_DELAY);
if (String(product_type) == "HWE-P1" && !p1Stored) {
p1Hostname = hostname;
p1Stored = true;
} else if (String(product_type) == "HWE-SKT" && socketCount < 3) {
socketHostnames[socketCount++] = hostname;
}
xSemaphoreGive(ipMutex);
}
}
http.end();
xSemaphoreTake(ipMutex, portMAX_DELAY);
totalScanned++;
int progressPercentage = (totalScanned * 100) / (IP_RANGE_END - IP_RANGE_START + 1);
updateProgressBar(progressPercentage);
if (totalScanned % 16 == 0) {
Serial.printf("Scanned IP %s.%d\n", baseIP.toString().substring(0, baseIP.toString().lastIndexOf('.')).c_str(), totalScanned);
char buffer2[50];
snprintf(buffer2, sizeof(buffer2), "Scanned: %d/%d", totalScanned, IP_RANGE_END - IP_RANGE_START + 1);
tft.drawCentreString(buffer2, tft.width() / 2, PROGRESS_BAR_Y + 50, 4);
}
xSemaphoreGive(ipMutex);
}
}
// ========== AUTOSOCKETS MODE FUNCTIONS ==========
void setupAutoSockets() {
initializeDisplay();
loadSavedSettings();
configureButtons();
connectToWiFi();
}
void loopAutoSockets() {
getP1MeterData();
getSocketData();
if (shouldUpdateDisplay()) updateDisplay();
checkHttpFailureAndRestart(); // restart only if HTTP failures persist
}
void initializeDisplay() {
sp.setColorDepth(8);
sp.createSprite(tft.width(), min(tft.height(), (int16_t)170));
spriteWidth = sp.width();
spriteHeight = sp.height();
}
void loadSavedSettings() { // retrieve data from flash memory and assign it to variables
flash.begin("P1-app", true);
allowAutoControl = flash.getBool("autoControl", false);
autoControlEnabled = allowAutoControl; // autoControlEnabled can be toggled, allowAutoControl not
flash.getString("ipM", p1.hostnameOrIp, MAX_IP_LENGTH);
p1.updateInterval = flash.getUInt("p1Interval", defaultP1MeterUpdateInterval);
socketUpdateInterval = flash.getUInt("socketInterval", defaultSocketUpdateInterval);
hasRegisteredSockets = false; // reset before scanning
for (int i = 0; i < numSockets; i++) { // socket settings
char keyBuffer[24];
snprintf(keyBuffer, sizeof(keyBuffer), "ipS%d", i + 1);
flash.getString(keyBuffer, sockets[i].hostNameOrIP, MAX_IP_LENGTH);
if (strlen(sockets[i].hostNameOrIP) < 7) strcpy(sockets[i].hostNameOrIP, defaultIP);
if (strcmp(sockets[i].hostNameOrIP, defaultIP) != 0) hasRegisteredSockets = true;
snprintf(keyBuffer, sizeof(keyBuffer), "nameS%d", i + 1);
flash.getString(keyBuffer, sockets[i].name, MAX_NAME_LENGTH);
snprintf(keyBuffer, sizeof(keyBuffer), "autoControlS%d", i + 1);
sockets[i].autoControlEnabled = flash.getBool(keyBuffer, false);
snprintf(keyBuffer, sizeof(keyBuffer), "offDelayS%d", i + 1);
sockets[i].offDelay = flash.getUInt(keyBuffer, 2000);
snprintf(keyBuffer, sizeof(keyBuffer), "switchOnS%d", i + 1);
sockets[i].switchOnWatt = flash.getUInt(keyBuffer, 10000);
snprintf(keyBuffer, sizeof(keyBuffer), "switchOffS%d", i + 1);
sockets[i].switchOffWatt = flash.getUInt(keyBuffer, 0);
}
flash.end();
}
void handleButtonPress(void* parameter) { // function for FreeRTOS task
for (;;) { // intentional infinite loop
handleButtonInput(); // function handleButtonInput() is not in loop() because FreeRTOS does the handling
vTaskDelay(20 / portTICK_PERIOD_MS); // every 20 milliseconds
}
}
void configureButtons() { // GPIO of "other button" depending on board type
#ifdef USER_SETUP_ID // adjust this code if a display / board other than T-Display or T-Display S3 is used
#if USER_SETUP_ID == 25 // T-Display
buttonOther = 35;
#elif USER_SETUP_ID == 206 // T-Display S3
buttonOther = 14;
#endif
#endif
if (autoControlEnabled && buttonOther != 0) pinMode(buttonOther, INPUT_PULLUP);
xTaskCreatePinnedToCore(handleButtonPress, "ButtonPressed", 4096, NULL, 1, NULL, 0); // core 0 because JSON runs on core 1
}
bool processJsonRequest(const char* ipAddressOrName, std::function<void(JsonDocument&)> onSuccess, uint16_t customTimeout = 3000) {
char apiUrl[64];
snprintf(apiUrl, sizeof(apiUrl), "http://%s/api/v1/data", ipAddressOrName); // format apiUrl by inserting ipAddressOrName into "%s"
httpClient.setTimeout(customTimeout);
httpClient.begin(wifiClient, apiUrl); // start HTTP request
int httpResponseCode = httpClient.GET();
if (httpResponseCode == 200) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, httpClient.getStream());
if (!error) {
onSuccess(doc);
httpClient.end();
return true;
}
}
httpClient.end(); // close connection in case of failure
return false; // return failure
}
void parseP1MeterData(const JsonDocument& doc) {
if (!doc["active_tariff"].isNull()) p1.activeTariff = doc["active_tariff"].as<int>();
if (!doc["active_power_w"].isNull()) p1.activePowerW = doc["active_power_w"].as<int>();
if (!doc["montly_power_peak_w"].isNull()) p1.monthlyPeak = doc["montly_power_peak_w"].as<int>(); // misspelled but valid key
p1.lastUpdate = millis();
}
void getP1MeterData() {
uint32_t currentTime = millis();
if (currentTime - p1.lastUpdate < p1.updateInterval) return; // do not check if the interval has not expired
if (hasRegisteredSockets) controlSocketsBasedOnPowerFlow();
bool p1RequestSucceeded = processJsonRequest(p1.hostnameOrIp, [](JsonDocument& doc) {
parseP1MeterData(doc);
});
if (p1RequestSucceeded) {
p1.lastUpdate = millis();
return;
}
p1.activeTariff = 3; // fallback: set active tariff to 3 to indicate a connection error with the P1 meter
p1.activePowerW = 4040;
}
void getSocketData() { // function to handle socket updates independently of P1 meter
if (!hasRegisteredSockets) return;
uint32_t currentTime = millis();
for (int i = 0; i < numSockets; i++) {
if (strcmp(sockets[i].hostNameOrIP, defaultIP) == 0 || strlen(sockets[i].hostNameOrIP) < 7) continue; // skip sockets without valid IP
uint32_t updateInterval;
if (sockets[i].useExtendedInterval) updateInterval = extendedRetryInterval; // use 10-minute interval
else if (sockets[i].offline) updateInterval = socketRetryInterval; // 1-minute interval for offline sockets
else updateInterval = socketUpdateInterval; // normal interval for online sockets
if (currentTime - sockets[i].lastUpdate >= updateInterval) {
sockets[i].lastUpdate = currentTime;
uint16_t timeout;
if (sockets[i].offline || sockets[i].useExtendedInterval) timeout = 800; // 800 ms timeout for offline sockets to prevent blocking P1-meter
else timeout = 3000; // normal timeout (3s) for online sockets
bool requestSuccess = processJsonRequest(
sockets[i].hostNameOrIP, [i](JsonDocument& doc) {
if (!doc["active_power_w"].isNull()) sockets[i].power = doc["active_power_w"].as<int>();
},
timeout);
if (requestSuccess) { // socket is back online - reset failure counters & flags
sockets[i].consecutiveFailures = 0;
sockets[i].useExtendedInterval = false;
sockets[i].offline = false;
} else { // socket request failed - increment failure counter
sockets[i].consecutiveFailures++;
sockets[i].offline = true;
if (sockets[i].consecutiveFailures >= maxConsecutiveFailures && !sockets[i].useExtendedInterval) sockets[i].useExtendedInterval = true;
}
sockets[i].lastRequestSuccess = requestSuccess;
break; // only process one socket per loop to avoid blocking
}
}
}
/**
* @brief Controls sockets based on measured consumption and surplus.
*
* - Switches OFF socket currently drawing power when the socket
* draws more power than sockets[i].switchOffWatt.
* - Selects at most one OFF socket as ON candidate if powerWattAverage indicates sufficient surplus
* - Prioritizes the ON candidate with the highest offDelay.
* - Enforces a minimum interval (MIN_SOCKET_SWITCH_INTERVAL_MS) between successive ON activations.
* - Maintains a running average of active power over the last N samples (powerWattAverage).
*/
void controlSocketsBasedOnPowerFlow() { // called by getP1MeterData() ~ every second
static int32_t runningSum = 0;
static unsigned long lastTurnOnMillis = 0;
unsigned long now = millis();
int bestCandidate = -1;
uint32_t bestOffDelay = 0;
runningSum -= powerWattBuffer[powerBufferIndex]; // === Update running average before making decisions ===
runningSum += p1.activePowerW;
powerWattBuffer[powerBufferIndex] = p1.activePowerW;
powerBufferIndex = (powerBufferIndex + 1) % powerBufferSize;
powerWattAverage = runningSum / powerBufferSize;
for (int i = 0; i < numSockets; i++) { // === Check sockets ===
if (autoControlEnabled && sockets[i].autoControlEnabled) {
bool delayElapsed = ((now - sockets[i].lastCheck) >= sockets[i].offDelay);
bool hysteresisPassed = ((now - sockets[i].lastTurnedOffMillis) >= hysteresisDelay);
if (delayElapsed && sockets[i].power > 0 && powerWattAverage > sockets[i].switchOffWatt) {
setSocketPowerState(i, false); // Switch OFF if socket is drawing power and average consumption exceeds threshold
sockets[i].lastCheck = now;
sockets[i].lastTurnedOffMillis = now;
} else if (delayElapsed && sockets[i].power == 0 && // currently off
sockets[i].switchOnWatt > 0 && // valid threshold
hysteresisPassed && powerWattAverage < -sockets[i].switchOnWatt) { // sufficient surplus
if (sockets[i].offDelay > bestOffDelay) { // pick longest off-delay
bestOffDelay = sockets[i].offDelay;
bestCandidate = i;
}
}
if (delayElapsed) sockets[i].lastCheck = now; // update timer when off-delay elapsed
} else sockets[i].lastCheck = now; // reset timer to avoid immediate trigger on re-enable
}
if (bestCandidate >= 0 && (now - lastTurnOnMillis >= MIN_SOCKET_SWITCH_INTERVAL_MS)) {
setSocketPowerState(bestCandidate, true); // Switch ON the socket if sufficient surplus and hysteresis passed
sockets[bestCandidate].lastCheck = now;
lastTurnOnMillis = now;
}
}
void setSocketPowerState(int socketIndex, bool powerOn) { // function for switching the sockets
if (strcmp(sockets[socketIndex].hostNameOrIP, defaultIP) == 0 || strlen(sockets[socketIndex].hostNameOrIP) == 0) return;
char urlBuffer[64];
snprintf(urlBuffer, sizeof(urlBuffer), "http://%s/api/v1/state", sockets[socketIndex].hostNameOrIP);
if (!httpClient.begin(wifiClient, urlBuffer)) return;
httpClient.addHeader("Content-Type", "application/json");
char payloadBuffer[32];
snprintf(payloadBuffer, sizeof(payloadBuffer), "{\"power_on\": %s}", powerOn ? "true" : "false");
httpClient.PUT(payloadBuffer); // int httpResponseCode = httpClient.PUT(payloadBuffer); is not used, result is visible on display
httpClient.end();
}
void handleButtonInput() { // adjust this code if a display other than T-Display or T-Display S3 is used
constexpr uint16_t debounceDelay = 300;
unsigned long now = millis();
if (allowAutoControl && tft.height() < 171) { // handle "other" button (T-Display / S3 only)
bool currentOtherState = digitalRead(buttonOther);
if (currentOtherState == LOW && lastButtonStateOther == HIGH && now - lastButtonPressOther > debounceDelay) { // short press
autoControlEnabled = !autoControlEnabled; // toggle auto control
lastButtonPressOther = now;
}
lastButtonStateOther = currentOtherState;
}
static unsigned long flashPressStart = 0;
if (!digitalRead(0)) { // flash button pressed
if (now - lastButtonPressFlash > debounceDelay) {
if (flashPressStart == 0) {
flashPressStart = now;
lastButtonPressFlash = now;
}
if (now - flashPressStart >= 5000) { // flash button: long press detected
flash.begin("login_data", false); // reset WiFi SSID = force setup html on next startup
flash.putString("ssid", "");
flash.end();
vTaskDelay(500 / portTICK_PERIOD_MS);
ESP.restart();
}
}
} else {
flashPressStart = 0;
lastButtonPressFlash = 0;
}
}
uint16_t mapPowerToColor(int power) {
if (power < 0) return TFT_GREEN;
if (power < 500) return TFT_CYAN;
if (power < 1000) return TFT_YELLOW;
return (power < 2500) ? TFT_ORANGE : TFT_RED;
}
bool shouldUpdateDisplay() { // only update if activePowerW changes or if 1 second has elapsed
static int lastPower = -9999;
static uint32_t lastDisplayUpdate = 0;
bool powerChanged = (lastPower != p1.activePowerW);
bool timeElapsed = (millis() - lastDisplayUpdate > 1000);
if (powerChanged || timeElapsed) {
lastPower = p1.activePowerW;
lastDisplayUpdate = millis();
return true;
}
return false;
}
void updateDisplay() { // visual feedback to user
sp.fillSprite(TFT_BLACK);
static const uint16_t colors[] = { TFT_YELLOW, TFT_GREEN, TFT_RED };
if (p1.monthlyPeak < 2500) sp.setTextColor(TFT_WHITE); // assign colors according to monthly peak
else if (p1.monthlyPeak < 3500) sp.setTextColor(TFT_YELLOW);
else if (p1.monthlyPeak < 4500) sp.setTextColor(TFT_ORANGE);
else sp.setTextColor(TFT_RED);
char peakBuffer[10];
snprintf(peakBuffer, sizeof(peakBuffer), "%u", p1.monthlyPeak);
sp.drawRightString(peakBuffer, spriteWidth - 2, 0, 4); // monthly peak
if (p1.activeTariff > 0 && p1.activeTariff <= 3) { // day/night rate information
static const char* messages[] = { "Watt", "Watt", "Conn ERR" };
sp.setTextColor(colors[p1.activeTariff - 1]); // using arrays is much more compact than if..else or switch..case loops
sp.drawString(messages[p1.activeTariff - 1], spriteWidth / 5, spriteHeight / 5, 4);
}
sp.setTextColor(mapPowerToColor(p1.activePowerW));
char powerBuffer[10];
snprintf(powerBuffer, sizeof(powerBuffer), "%d", p1.activePowerW);
sp.drawRightString(powerBuffer, spriteWidth, spriteHeight * 0.44, 8); // total power usage
#if USER_SETUP_ID != 25 // electricity icon not for T-Display (too small)
sp.fillTriangle(20, 110, 40, 110, 10, 150, colors[p1.activeTariff - 1]);
sp.fillTriangle(20, 120, 0, 120, 30, 80, colors[p1.activeTariff - 1]);
#endif
for (int i = 0; i < numSockets; i++) {
if (strcmp(sockets[i].hostNameOrIP, defaultIP) != 0) { // only for registered sockets
displaySocketInfo(i, sockets[i].hostNameOrIP, sockets[i].power, sockets[i].lastRequestSuccess);
int offDelaySec = sockets[i].offDelay / 1000;
char delayBuffer[5];
snprintf(delayBuffer, sizeof(delayBuffer), "%d", offDelaySec);
if (allowAutoControl) sp.drawRightString(delayBuffer, spriteWidth / 1.7, spriteHeight / 6.4 + (i * textLineSpacing), 2);
}
}
if (autoControlEnabled && hasRegisteredSockets) { // show auto control information
sp.setTextColor(TFT_GREEN);
sp.drawString(F("Auto ctrl"), 2, 0, 4);
sp.drawString(F("Delay"), spriteWidth / 2.1, -3, 2);
sp.drawString(F("seconds"), spriteWidth / 2.1, 8, 2);
}
sp.pushSprite(0, 0);
}
void shortenHostname(const char* hostname, char* result, size_t resultSize) {
size_t startPos = 0;
size_t length;
const char* dashPtr = strchr(hostname, '-'); // extract part between '-' and '.', store result in buffer
const char* dotPtr = strchr(hostname, '.');
if (dashPtr != NULL && dotPtr != NULL && dotPtr > dashPtr) {
startPos = dashPtr - hostname + 1;
length = dotPtr - dashPtr - 1;
if (length > resultSize - 1) length = resultSize - 1;
} else length = strnlen(hostname, resultSize - 1);
strncpy(result, hostname + startPos, length);
result[length] = '\0';
}
void displaySocketInfo(int index, const char* ipAddressOrName, int activePowerW, bool success) {
uint16_t textColor;
if (success) textColor = (activePowerW > 0) ? TFT_CYAN : TFT_DARKGREY;
else if (sockets[index].useExtendedInterval) textColor = TFT_MAGENTA;
else textColor = TFT_RED;
if (autoControlEnabled && sockets[index].autoControlEnabled && textColor == TFT_DARKGREY) textColor = TFT_BROWN;
char shortNameBuffer[MAX_NAME_LENGTH];
shortenHostname(ipAddressOrName, shortNameBuffer, sizeof(shortNameBuffer));
sp.setTextColor(textColor); // next line: use socket name if available, otherwise use shortened hostname
const char* displayName = (strlen(sockets[index].name) > 0) ? sockets[index].name : shortNameBuffer;
sp.drawRightString(displayName, spriteWidth, spriteHeight / 6.4 + (index * textLineSpacing), 2);
char powerBuffer[10];
snprintf(powerBuffer, sizeof(powerBuffer), "%d", activePowerW);
sp.drawRightString(powerBuffer, spriteWidth / 7.5, spriteHeight / 6.4 + (index * textLineSpacing), 2);
if (tft.height() > 170) sp.setTextColor(TFT_BLACK, textColor);
}
void checkHttpFailureAndRestart() { // check every 10 seconds; restart ESP32 if P1 meter JSON-failure for 2 minutes
static unsigned long restartTimestamp = 0, lastCheck = 0;
if (millis() - lastCheck <= 10000) return;
lastCheck = millis();
if (millis() - p1.lastUpdate <= maxHttpFailureDuration) {
restartTimestamp = 0;
return;
}
if (restartTimestamp == 0) {
for (int i = 0; i < numSockets; i++) setSocketPowerState(i, false); // switch all sockets off
restartTimestamp = millis();
}
if (millis() - restartTimestamp > RESTART_DELAY_MS) ESP.restart();
}
void startWiFiAccessPoint() {
showMessageNoConnection();
WiFi.disconnect(true);
WiFi.scanDelete();
WiFi.mode(WIFI_MODE_AP);
WiFi.softAP("Lily", "");
int numNetworks = WiFi.scanNetworks();
availableSSIDs.clear();
for (int i = 0; i < numNetworks; i++) availableSSIDs.push_back(WiFi.SSID(i));
server.on("/", serveWiFiSetupPage);
server.on("/setting", processWiFiSettingsSubmission);
server.begin();
unsigned long startTime = millis();
const unsigned long maxDuration = 1800000; // 30 minutes in milliseconds
for (;;) { // intentional infinite loop
server.handleClient();
if (millis() - startTime > maxDuration) ESP.restart(); // restart ESP32 after 30 minutes in case of e.g. WiFi problems
delay(10); // small delay to avoid watchdog timer problems
}
}
void connectToWiFi() {
constexpr uint8_t wifiMaxAttempts = 50, wifiRetryDelay = 160;
WiFi.mode(WIFI_MODE_STA);
flash.begin("login_data", true);
String wifiSsid = flash.getString("ssid", "");
String wifiPassword = flash.getString("pasw", "");
flash.end();
if (wifiSsid.isEmpty()) { // SSID is empty, immediately launch the web interface
startWiFiAccessPoint();
return;
}
WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("Connecting to", spriteWidth / 2, spriteHeight / 2 - 10, 2);
tft.drawCentreString(wifiSsid.c_str(), spriteWidth / 2, spriteHeight / 2 + 20, 4);
for (uint8_t i = 0; i < wifiMaxAttempts; ++i) {
if (WiFi.isConnected()) {
WiFi.setAutoReconnect(true);
WiFi.persistent(true);
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("Connected", spriteWidth / 2, spriteHeight / 2 - 10, 4);
tft.drawCentreString(p1.hostnameOrIp, spriteWidth / 2, spriteHeight / 2 + 20, 2);
unsigned long messageStartTime = millis();
while (millis() - messageStartTime < 2000) delay(10);
return;
}
delay(wifiRetryDelay);
}
startWiFiAccessPoint(); // if connect fails, launch web interface
}
void serveWiFiSetupPage() {
String webText = F("<!DOCTYPE HTML><html lang='en'><head><meta charset='UTF-8'><title>WiFi Setup</title>");
webText += F("<meta name='viewport' content='width=device-width, initial-scale=1.0'><style>");
webText += F("body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 0; text-align: center; }");
webText += F(".container { max-width: 440px; background: white; padding: 20px; margin: 20px auto; border-radius: 10px;");
webText += F("box-shadow: 0 0 10px rgba(0,0,0,0.1); } h1 { font-size: 24px; color: #333; margin-bottom: 10px; }");
webText += F("p { font-size: 16px; color: #555; } label { text-align: left; display: block; margin-top: 12px; color: #333; }");
webText += F("input, select, button { width: 100%; padding: 10px; margin-top: 4px; border-radius: 5px;");
webText += F("border: 1px solid #ccc; font-size: 16px; box-sizing: border-box; } .socket-fieldset { background: #f5fff5; }");
webText += F("input[type=submit], button { background: #007BFF; color: white; border: none; cursor: pointer; }");
webText += F("input[type=submit]:hover, button:hover { background: #0056b3; }");
webText += F("fieldset { border: 2px solid #007BFF; border-radius: 10px; padding: 16px; margin-top: 20px; background: #f0f8ff;");
webText += F("box-shadow: inset 0 0 5px rgba(0,123,255,0.1); } legend { font-weight: bold; color: white; background: ");
webText += F("#006400; padding: 4px 10px; border-radius: 5px; font-size: 16px; }");
webText += F("#ssid-buttons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 16px; }");
webText += F("#ssid-buttons button { width: auto; padding: 8px 14px; font-size: 14px; }</style></head><body>");
webText += F("<div class='container'><h1>WiFi Setup</h1><p>Select a network or enter details below.</p>");
webText += F("<div id='ssid-buttons'>");
for (const String& ssid : availableSSIDs) {
webText += "<button onclick=\"document.getElementsByName('ssid')[0].value='" + ssid + "'\">" + ssid + "</button>";
}
webText += F("</div><form method='get' action='setting'>");
webText += F("<fieldset><legend>WiFi Settings</legend>");
webText += F("<label for='ssid'><b>SSID:</b></label><input type='text' id='ssid' name='ssid' required>");
webText += F("<label for='pass'><b>Password: (leave empty if unchanged)</b></label>");
webText += F("<input type='password' id='pass' name='pass'></fieldset>");
webText += F("<fieldset><legend>P1 Meter</legend>");
webText += F("<label for='ip_P1'><b>Hostname (or IP Address):</b></label>");
webText += F("<small>Leave empty for automatic scan on restart</small><br>");
webText += "<input type='text' id='ip_P1' name='ip_P1' value='" + String(p1.hostnameOrIp) + "'></fieldset>";
if (p1.hostnameOrIp[0] != '\0') { // optional Socket settings
webText += F("<fieldset><legend>Socket Settings</legend>");
for (int i = 0; i < numSockets; i++) {
webText += "<fieldset class='socket-fieldset'><legend>Socket " + String(i + 1) + "</legend>";
// Hostname/IP
webText += "<label for='ip_s" + String(i + 1) + "'><b>Hostname (or IP Address):</b></label>";
webText += "<input type='text' id='ip_s" + String(i + 1) + "' name='ip_s" + String(i + 1) + "' value='" + String(sockets[i].hostNameOrIP) + "'>";
webText += "<small style='color:red;'>If unused, set to 192.168.0.0 or leave empty</small><br>";
// Custom name
webText += "<label for='name_s" + String(i + 1) + "'><b>Custom Name:</b></label>";
webText += "<input type='text' id='name_s" + String(i + 1) + "' name='name_s" + String(i + 1) + "' value='" + String(sockets[i].name) + "'><br>";
// Autocontrol enable
webText += "<label for='autoControl_s" + String(i + 1) + "'><b>Allow autocontrol for this socket:</b>";
webText += "<input type='checkbox' style='margin-left:10px; width:auto;' id='autoControl_s" + String(i + 1) + "' ";
webText += "name='autoControl_s" + String(i + 1) + "' value='1'";
webText += sockets[i].autoControlEnabled ? " checked>" : ">";
webText += "</label><small>If checked, the module can control this socket</small><br><br>";
// Switch ON threshold
webText += "<label for='switchOn_s" + String(i + 1) + "'><b>Switch ON Threshold (Watt):</b></label>";
webText += "<input type='number' id='switchOn_s" + String(i + 1) + "' name='switchOn_s" + String(i + 1) + "' value='";
webText += String(sockets[i].switchOnWatt) + "' min='0' ";
webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
webText += "<small>Switch ON this socket if power generation exceeds this value</small><br>";
// Switch OFF threshold
webText += "<label for='switchOff_s" + String(i + 1) + "'><b>Switch OFF Threshold (Watt):</b></label>";
webText += "<input type='number' id='switchOff_s" + String(i + 1) + "' name='switchOff_s" + String(i + 1) + "' value='";
webText += String(sockets[i].switchOffWatt) + "' min='0' ";
webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
webText += "<small>Switch OFF this socket if power generation drops below this value</small><br>";
// Off delay
webText += "<label for='offDelay_s" + String(i + 1) + "'><b>Autocontrol Switch OFF Delay (seconds):</b></label>";
webText += "<input type='number' id='offDelay_s" + String(i + 1) + "' name='offDelay_s" + String(i + 1) + "' value='";
webText += String(sockets[i].offDelay / 1000) + "' min='0' ";
webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
webText += "</fieldset>";
}
webText += F("</fieldset>");
}
webText += F("<fieldset><legend>Updates & Settings</legend>");
webText += F("<label for='p1Upd'><b>P1 Meter Update Interval (millisec):</b></label>");
webText += "<input type='number' id='p1Upd' name='p1Upd' value='" + String(p1.updateInterval) + "' min='0' ";
webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
webText += F("<label for='scktUpd'><b>Socket Update Interval (millisec):</b></label>");
webText += "<input type='number' id='scktUpd' name='scktUpd' value='" + String(socketUpdateInterval) + "' min='0' ";
webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
webText += F("<label for='autoControl'><b>Allow automatic socket control:</b>");
webText += "<input type='checkbox' style='margin-left:10px; width:auto;' id='autoControl' name='autoControl' value='1'";
webText += autoControlEnabled ? " checked>" : ">";
webText += "</label><small>If checked, the module can switch sockets</small>";
webText += F("</fieldset>");
webText += F("<br><input type='submit' value='Save'></form></div></body></html>");
server.send(200, "text/html", webText);
}
void processWiFiSettingsSubmission() {
String newSsid = server.arg("ssid");
String newPassword = server.arg("pass");
String ipAddr = server.arg("ip_P1");
uint16_t newP1Interval = server.arg("p1Upd").toInt();
uint16_t newSocketInterval = server.arg("scktUpd").toInt();
autoControlEnabled = server.hasArg("autoControl");
for (int i = 0; i < numSockets; i++) { // === read all socket fields on the form ===
String key;
key = "ip_s" + String(i + 1);
strncpy(sockets[i].hostNameOrIP, server.arg(key).c_str(), sizeof(sockets[i].hostNameOrIP) - 1);
sockets[i].hostNameOrIP[sizeof(sockets[i].hostNameOrIP) - 1] = '\0';
key = "name_s" + String(i + 1);
server.arg(key).toCharArray(sockets[i].name, sizeof(sockets[i].name));
key = "autoControl_s" + String(i + 1);
sockets[i].autoControlEnabled = server.hasArg(key);
key = "offDelay_s" + String(i + 1);
sockets[i].offDelay = server.arg(key).toInt() * 1000; // to millisec
key = "switchOn_s" + String(i + 1);
sockets[i].switchOnWatt = server.arg(key).toInt();
key = "switchOff_s" + String(i + 1);
sockets[i].switchOffWatt = server.arg(key).toInt();
}
if (newSsid.length() > 0) { // === save to flash ===
flash.begin("login_data", false);
flash.putString("ssid", newSsid);
if (newPassword != "") flash.putString("pasw", newPassword);
flash.end();
flash.begin("P1-app", false);
flash.putString("ipM", ipAddr);
if (newP1Interval != 0) flash.putUInt("p1Interval", newP1Interval);
if (newSocketInterval > 0) flash.putUInt("socketInterval", newSocketInterval);
for (int i = 0; i < numSockets; i++) {
String key;
key = "ipS" + String(i + 1);
flash.putString(key.c_str(), sockets[i].hostNameOrIP);
key = "nameS" + String(i + 1);
if (strlen(sockets[i].name) > 0) flash.putString(key.c_str(), sockets[i].name);
key = "autoControlS" + String(i + 1);
flash.putBool(key.c_str(), sockets[i].autoControlEnabled);
key = "offDelayS" + String(i + 1);
flash.putUInt(key.c_str(), sockets[i].offDelay);
key = "switchOnS" + String(i + 1);
flash.putUInt(key.c_str(), sockets[i].switchOnWatt);
key = "switchOffS" + String(i + 1);
flash.putUInt(key.c_str(), sockets[i].switchOffWatt);
}
flash.putBool("autoControl", autoControlEnabled);
flash.end();
}
// === Reboot notification ===
String webText = F(
"<!DOCTYPE HTML><html lang='en'><head><title>Setup</title>"
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
"<style>*{font-family:Arial,Helvetica,sans-serif;font-size:45px;font-weight:600;margin:0;text-align:center;}"
"@keyframes op_en_neer{0%{height:0px;}50%{height:40px;}100%{height:0px;}}"
".opneer{margin:auto;text-align:center;animation:op_en_neer 2s infinite;}</style></head>"
"<body><div class='opneer'></div>ESP will reboot<br>Close this window</body></html>");
server.send(200, "text/html", webText);
delay(500);
ESP.restart();
}
This solution also works with larger displays like the ESP32-2432S028, allowing easier viewing from a distance. Available on AliExpress or Amazon.
The sketch shown above works perfectly without setting sockets. Should you absolutely want a simplified version of the sketch that only reads the P1 meter, you can find it here.