ESP32 with HomeWizard P1 Meter and Energy Sockets

Live energy monitoring on a compact display via WiFi

T-Display S3

This guide explains how to use a Lilygo T-Display 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. If you modify the sketch for the T-Display S3 you must set GPIO 15 (PWD) to HIGH in setup() to ensure stable WiFi.

These modules from Lilygo are based on the ESP32 chip. It has everything you need for wireless WiFi, so it works with a standard 5-volt connection to the USB-C port. This means that the module does not need to be connected to a PC for normal operation.

Short description of how it works

What is shown on the display

Modify sketch for the T-Display S3: set GPIO 15 to HIGH in setup() for a stable WiFi connection.

1. What you need

Overview P1 meter
Tip: Make sure your WiFi signal is strong near your digital meter. A repeater can help.

2. Features of this setup

Lilygo display met P1-gegevens

Lilygo T-Display running the current code

3. Enable local API

Open the HomeWizard app and enable the local API for your P1 meter and sockets under Settings > Meters.

4. Automatically find devices on your network

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.

Scan in prigress Scan Results

5. Setup and settings

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.

No WiFi - Setup page
Use hostnamesInserting 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 instead of IP addresses (e.g. p1meter-123abc.local) to avoid problems with changing IPs.
Setup page part 1 Setup page part 2
Faster than the HomeWizard app: the paid version of the HomeWizard app also switches sockets based on a solar task if you set it. The shutdown delay you can set there is a minimum of 1 minute. This module switches much faster, you can set this per second. Useful to avoid unnecessary consumption on days when sun and clouds alternate quickly.

6. Button functions

7. Uploading the sketch

Use the Arduino IDE to upload the sketch below to your Lilygo T-Display. Download this sketch

View this sketch
/**
 * Copyright (c) 2025 johanok@gmail.com - https://espgo.be/
 *
 * This project is released under the Creative Commons Attribution 4.0 International License (CC BY 4.0).
 *
 * You are free to:
 * - Copy and redistribute the material in any medium or format
 * - Remix, transform, and build upon the material for any purpose, even commercially
 *
 * Under the following terms:
 * - You must give appropriate credit to the original author (johanok@gmail.com - espgo.be)
 * - Include a link to https://espgo.be/
 * - Indicate if changes were made
 *
 * Full license text: https://creativecommons.org/licenses/by/4.0/
 * 
 *  
 * @file Hw_Lovyan_T.ino
 *
 * @brief ESP32 sketch for Lilygo T-Display: HomeWizard Device Scanner and AutoSockets Controller.
 *
 * @author johanok@gmail.com
 *
 * @modes
 *   - Scanner Mode: Scans network for HomeWizard P1 meter and Energy Sockets
 *   - P1 Meter Mode: Real-time P1 meter monitoring with optional Automatic Socket Controller
 * 
 * @features
 *   - Automatic network scanning for P1 meter and Energy Sockets
 *   - Multi-core FreeRTOS implementation for parallel polling
 *   - Dedicated WiFiClient per device for optimal parallel performance
 *   - Dedicated FreeRTOS task for P1 meter (priority 2 [highest], core 0)
 *   - Individual FreeRTOS tasks per socket for parallel polling (priority 1, core 0)
 *   - WiFi configuration via web interface (192.168.4.1)
 *   - Adaptive switchOnWatt threshold calibration based on actual power consumption
 *   - TFT display output for real-time data visualization
 *   - Persistent storage of settings in flash memory
 *   - Intelligent socket management based on power flow
 *   - Advanced network reliability with adaptive retry mechanisms
 *   - Thread-safe operations with separate httpClient, WiFiClient and mutex protection per device
 * 
 * @hardware
 *   - ESP32 microcontroller: LilyGo TTGO T-Display
 *   - HomeWizard P1 meter (required)
 *   - HomeWizard Energy Sockets (optional, up to 3)
 * 
 * @note
 *   - Automatic restart after 2 minutes of persistent connection failures
 *   - All HTTP requests are thread-safe via mutex protection per client
 *
 * @section usage Usage
 * - Normal operation: Displays current power usage and socket states
 * - Long press flash button (5 sec): Reset WiFi settings and restart
 * - Short press other button: Toggle autocontrol on/off (if enabled)
 * - Long press other button (5 sec): Reset polling intervals to defaults and restart
 *
 * @section BootBehavior Boot Behavior
 * - **No P1 meter configured:** Automatically starts in Scanner Mode
 * - **P1 meter configured:** Starts in P1 Meter Mode with AutoSockets Controller
 * - **Scanner Mode:** Multi-core network scan (16 parallel tasks on core 0)
 * - **P1 Meter Mode:** FreeRTOS tasks for continuous monitoring
 *
 * @section Architecture FreeRTOS Architecture
 * **Core 0 (Network I/O):**
 * - P1 meter polling task (priority 2 [highest], every second)
 * - Socket polling tasks (priority 1, every 3 seconds per socket)
 * - Button handler task (priority 1)
 * - Scanner tasks (16 parallel tasks during network scan)
 * 
 * **Core 1 (UI):**
 * - Main loop: Display updates, Automatic socket switchOnWatt calibration, failure monitoring
 * - Non-blocking UI updates only when data changes
 *
 * **Board Settings (Arduino IDE):**
 * - Board: "ESP32 Dev Module" or "LyliGo T_Display"
 * - Partition Scheme: "Default 4MB with spiffs"
 * - Flash Size: 4MB (or larger)
 *
 * @section API HomeWizard API Endpoints
 * **P1 Meter:**
 * - GET http://[hostname]/api/v1/data
 *   Returns: active_tariff, active_power_w, montly_power_peak_w
 *
 * **Energy Socket:**
 * - GET http://[hostname]/api/v1/data
 *   Returns: power_on, active_power_w
 * - PUT http://[hostname]/api/v1/state
 *   Body: {"power_on": true/false}
 *
 * @version 2.3.1
 * @date December 2025
 */

#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>  // store data in EEPROM memory
#include <LovyanGFX.hpp>  // display
#include <vector>

#define HTTP_PORT 80

WebServer server(HTTP_PORT);
Preferences flash;

class LGFX : public lgfx::LGFX_Device {  // display settings for LovyanGFX library
  lgfx::Bus_SPI bus;
  lgfx::Panel_ST7789 panel;
  lgfx::Light_PWM backlight;
public:
  LGFX(void) {
    {  // SPI bus setup
      auto cfg = bus.config();
      cfg.spi_host = SPI2_HOST;
      cfg.spi_mode = 0;
      cfg.freq_write = 40000000;
      cfg.freq_read = 16000000;
      cfg.pin_sclk = 18;
      cfg.pin_mosi = 19;
      cfg.pin_miso = -1;
      cfg.pin_dc = 16;
      bus.config(cfg);
      panel.setBus(&bus);
    }
    {  // Display panel settings
      auto cfg = panel.config();
      cfg.pin_cs = 5;
      cfg.pin_rst = 23;
      cfg.pin_busy = -1;
      cfg.memory_width = 135;
      cfg.memory_height = 240;
      cfg.panel_width = 135;
      cfg.panel_height = 240;
      cfg.offset_x = 52;
      cfg.offset_y = -40;  // MINUS 40
      cfg.offset_rotation = 0;
      cfg.invert = true;
      cfg.rgb_order = false;
      panel.config(cfg);
    }
    {  // Backlight
      auto cfg = backlight.config();
      cfg.pin_bl = 4;
      cfg.invert = false;
      cfg.freq = 44100;
      cfg.pwm_channel = 7;
      backlight.config(cfg);
      panel.setLight(&backlight);
    }
    setPanel(&panel);
  }
};

LGFX tft;
LGFX_Sprite sp(&tft);

#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;
WiFiClient wifiClientScanners[NUM_TASKS];
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* UNCONFIGURED_IP = "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 = 3000;
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;
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;

// Separate HTTP clients per device
HTTPClient httpClientP1;
HTTPClient httpClientSocket1;
HTTPClient httpClientSocket2;
HTTPClient httpClientSocket3;

// Dedicated WiFi clients for each device
WiFiClient clientP1;
WiFiClient clientSocket1;
WiFiClient clientSocket2;
WiFiClient clientSocket3;

// FreeRTOS mutexes - one per client for parallel access
SemaphoreHandle_t httpMutexP1;
SemaphoreHandle_t httpMutexSocket1;
SemaphoreHandle_t httpMutexSocket2;
SemaphoreHandle_t httpMutexSocket3;

TaskHandle_t socketTaskHandles[numSockets] = { NULL };
TaskHandle_t p1TaskHandle = NULL;

struct Socket {
  bool isOn = false;
  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;
  bool needsSwitchOnUpdate = false;
  uint16_t pendingNewThreshold = 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() {
  tft.begin();
  tft.setRotation(SCREEN_ROTATION_USB_LEFT);
  tft.setBrightness(224);  // 0..255
  tft.fillScreen(TFT_BLACK);
  flash.begin("P1-app", true);
  String savedP1Host = flash.getString("ipM", "");
  flash.end();
  if (savedP1Host.isEmpty()) {
    scannerMode = true;
    tft.setTextDatum(middle_center);
    tft.setFont(&fonts::Font4);
    tft.drawString("Scanner Mode", tft.width() / 2, 12);
    tft.setFont(&fonts::Font2);
    tft.drawString("No P1 meter found", tft.width() / 2, 36);
    delay(1000);
    setupScanner();
  } else {
    tft.setTextDatum(middle_center);
    tft.setFont(&fonts::Font4);
    tft.drawString("P1 meter Mode", tft.width() / 2, 12);
    tft.setFont(&fonts::Font2);
    tft.drawString("Normal startup", tft.width() / 2, 36);
    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);
  tft.setFont(&fonts::Font4);
  tft.setTextDatum(middle_center);
  for (uint8_t i = 0; i < 5; i++) tft.drawString(noC[i], 120, 12 + i * 27);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
}

// ========== SCANNER MODE FUNCTIONS ==========

void setupScanner() {
  tft.fillScreen(TFT_BLACK);
  tft.setFont(&fonts::Font4);
  tft.setTextDatum(middle_center);
  tft.drawString("HomeWizard Scan", tft.width() / 2, 12);
  flash.begin("login_data", true);
  String wifiSsid = flash.getString("ssid", "");
  String wifiPassword = flash.getString("pasw", "");
  flash.end();
  if (wifiSsid != "") {
    tft.setFont(&fonts::Font4);
    tft.setTextDatum(middle_center);
    tft.drawString("Connecting to WiFi...", tft.width() / 2, 36);
    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.setFont(&fonts::Font4);
  tft.setTextDatum(middle_center);
  tft.drawString("WiFi connected!", tft.width() / 2, 36);
  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.setFont(&fonts::Font4);
  tft.setTextDatum(middle_center);
  tft.drawString("Scanning network", tft.width() / 2, 12);
  tft.setFont(&fonts::Font2);
  tft.drawString(ipPrefix + ".1  >>  " + ipPrefix + ".255", tft.width() / 2, 38);
  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);
    vTaskDelay(pdMS_TO_TICKS(50));  // TOEVOEGEN: 50ms delay tussen tasks
  }
}

void loopScanner() {
  EventBits_t bits = xEventGroupWaitBits(scanEventGroup, (1 << NUM_TASKS) - 1, true, true, portMAX_DELAY);
  if (bits == ((1 << NUM_TASKS) - 1)) displayScanResults();
}

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.setFont(&fonts::Font4);
      tft.setTextDatum(middle_center);
      tft.drawString(buffer, tft.width() / 2, PROGRESS_BAR_Y + 36);
    }
    lastPercentage = percentage;
    lastProgressUpdate = millis();
  }
}

void displayScanResults() {
  Serial.println("\n--- Scan complete ---");
  tft.fillScreen(TFT_BLACK);
  tft.setFont(&fonts::Font4);
  tft.setTextDatum(middle_center);
  tft.setTextColor(TFT_GREEN, TFT_BLACK);
  tft.drawString("---Scan Complete---", tft.width() / 2, 14);
  tft.setTextColor(TFT_CYAN, TFT_BLACK);
  tft.setFont(&fonts::Font2);
  tft.drawString("Results (found hostnames)", tft.width() / 2, 34);
  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.setFont(&fonts::Font2);
    tft.setTextColor(TFT_YELLOW, TFT_BLACK);
    tft.drawString(p1Hostname, tft.width() / 2, 52);
  }
  tft.setTextDatum(middle_center);
  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.setFont(&fonts::Font2);
    tft.drawString(socketHostnames[i], tft.width() / 2, 72 + i * 20);
  }
  flash.end();
  tft.setTextDatum(middle_center);
  if (!p1Stored && socketCount == 0) {
    tft.setFont(&fonts::Font2);
    tft.drawString("No HomeWizard devices found", tft.width() / 2, tft.height() - 20);
    delay(2000);
    startWiFiAccessPoint();
  } else {
    // tft.setTextColor(TFT_CYAN, TFT_BLACK);
    // tft.setFont(&fonts::Font2);
    // tft.drawString("Restarting for P1 reading mode", tft.width() / 2, tft.height());
    delay(2000);
    ESP.restart();
  }
}

void scannerTask(void* parameter) {
  int taskNum = (int)parameter;
  WiFiClient& client = wifiClientScanners[taskNum];
  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));
      vTaskDelete(NULL);
    }
    IPAddress targetIP(baseIP[0], baseIP[1], baseIP[2], ipLastOctet);
    String url = "http://" + targetIP.toString() + "/api";
    HTTPClient http;
    http.setTimeout(5000);
    http.begin(client, url);
    int httpCode = http.GET();
    if (httpCode != 200) {
      http.end();
      vTaskDelay(pdMS_TO_TICKS(100));  // kort wachten
      http.begin(client, url);
      httpCode = http.GET();  // 2de poging
    }
    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.setFont(&fonts::Font4);
      tft.setTextDatum(middle_center);
      tft.drawString(buffer2, tft.width() / 2, PROGRESS_BAR_Y + 62);
    }
    xSemaphoreGive(ipMutex);
  }
}

// ========== AUTOSOCKETS MODE FUNCTIONS ==========

void setupAutoSockets() {
  initializeDisplay();
  loadSavedSettings();
  configureButtons();
  connectToWiFi();
  WiFi.setSleep(false);
  httpMutexP1 = xSemaphoreCreateMutex();
  httpMutexSocket1 = xSemaphoreCreateMutex();
  httpMutexSocket2 = xSemaphoreCreateMutex();
  httpMutexSocket3 = xSemaphoreCreateMutex();
  // FreeRTOS tasks
  startP1MeterPollingTask();
  startSocketPollingTasks();
}

void loopAutoSockets() {
  if (shouldUpdateDisplay()) updateDisplay();
  checkHttpFailureAndRestart();
  processPendingSwitchOnUpdates();
}

void initializeDisplay() {
  sp.setColorDepth(8);
  sp.createSprite(tft.width(), tft.height());
  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);
  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, UNCONFIGURED_IP);
    if (strcmp(sockets[i].hostNameOrIP, UNCONFIGURED_IP) != 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);
    if (sockets[i].offDelay < 2000) sockets[i].offDelay = 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() {
  if (tft.height() == 135) buttonOther = 35;  // LilyGo T-Display
  if (buttonOther != 0) pinMode(buttonOther, INPUT_PULLUP);
  xTaskCreatePinnedToCore(handleButtonPress, "ButtonPressed", 4096, NULL, 1, NULL, 0);
}

void getSocketClientAndMutex(int socketIndex, WiFiClient*& client, HTTPClient*& httpClient, SemaphoreHandle_t& mutex) {
  HTTPClient* httpClients[] = { &httpClientSocket1, &httpClientSocket2, &httpClientSocket3 };
  WiFiClient* clientSockets[] = { &clientSocket1, &clientSocket2, &clientSocket3 };
  SemaphoreHandle_t httpMutexes[] = { httpMutexSocket1, httpMutexSocket2, httpMutexSocket3 };
  if (socketIndex >= 0 && socketIndex < 3) {
    client = clientSockets[socketIndex];
    httpClient = httpClients[socketIndex];
    mutex = httpMutexes[socketIndex];
  } else {
    client = nullptr;
    httpClient = nullptr;
    mutex = nullptr;
  }
}

/**
 * @brief Processes pending switchOnWatt updates in main loop (thread-safe)
 * Adaptive switchOnWatt threshold calibration based on actual power consumption
 */
void processPendingSwitchOnUpdates() {
  static uint32_t lastCheck = 0;
  if (millis() - lastCheck < 500) return;
  lastCheck = millis();
  for (int i = 0; i < numSockets; i++) {
    if (sockets[i].needsSwitchOnUpdate) {
      uint16_t oldValue = sockets[i].switchOnWatt;
      uint16_t newValue = sockets[i].pendingNewThreshold;
      sockets[i].switchOnWatt = newValue;  // Update RAM
      flash.begin("P1-app", false);        // Update flash
      char keyBuffer[24];
      snprintf(keyBuffer, sizeof(keyBuffer), "switchOnS%d", i + 1);
      flash.putUInt(keyBuffer, newValue);
      flash.end();
      sockets[i].needsSwitchOnUpdate = false;  // Reset flag
    }
  }
}

bool processJsonRequestP1(const char* ipAddressOrName, std::function<void(JsonDocument&)> onSuccess, uint16_t customTimeout = 3000) {
  uint32_t mutexTimeout = max(customTimeout + 2000, 5000);                               // wait time to prevent false failures during
  if (xSemaphoreTake(httpMutexP1, pdMS_TO_TICKS(mutexTimeout)) != pdTRUE) return false;  // take mutex
  char apiUrl[64];
  snprintf(apiUrl, sizeof(apiUrl), "http://%s/api/v1/data", ipAddressOrName);
  httpClientP1.setTimeout(customTimeout);
  httpClientP1.useHTTP10(true);  // avoid chunked encoding overhead
  httpClientP1.begin(clientP1, apiUrl);
  int httpResponseCode = httpClientP1.GET();
  bool success = false;
  if (httpResponseCode == 200) {
    JsonDocument doc;
    DeserializationError error = deserializeJson(doc, httpClientP1.getStream());
    if (!error) {
      onSuccess(doc);
      success = true;
    }
  }
  httpClientP1.end();
  xSemaphoreGive(httpMutexP1);
  return success;
}

bool processJsonRequestSocket(int socketIndex, const char* ipAddressOrName,
                              std::function<void(JsonDocument&)> onSuccess, uint16_t customTimeout = 3000) {
  WiFiClient* client;
  HTTPClient* httpClient;
  SemaphoreHandle_t mutex;
  getSocketClientAndMutex(socketIndex, client, httpClient, mutex);
  if (!client || !httpClient || !mutex) return false;
  uint32_t mutexTimeout = max(customTimeout + 2000, 5000);
  if (xSemaphoreTake(mutex, pdMS_TO_TICKS(mutexTimeout)) != pdTRUE) return false;
  char apiUrl[64];
  snprintf(apiUrl, sizeof(apiUrl), "http://%s/api/v1/data", ipAddressOrName);
  httpClient->setTimeout(customTimeout);
  httpClient->useHTTP10(true);
  httpClient->begin(*client, apiUrl);
  int httpResponseCode = httpClient->GET();
  bool success = false;
  if (httpResponseCode == 200) {
    JsonDocument doc;
    DeserializationError error = deserializeJson(doc, httpClient->getStream());
    if (!error) {
      onSuccess(doc);
      success = true;
    }
  }
  httpClient->end();
  xSemaphoreGive(mutex);
  return success;
}

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>();
  p1.lastUpdate = millis();
}

/**
 * @brief FreeRTOS task voor P1 meter polling
 * Polls P1 meter every second and handles autocontrol
 */
void p1PollingTask(void* parameter) {
  for (;;) {                            // infinite loop
    if (strlen(p1.hostnameOrIp) < 7) {  // skip if no valid P1 meter configured
      vTaskDelay(pdMS_TO_TICKS(5000));  // wait 5 seconds before checking again
      continue;
    }
    if (hasRegisteredSockets) controlSocketsBasedOnPowerFlow();
    bool p1RequestSucceeded = processJsonRequestP1(
      p1.hostnameOrIp,
      [](JsonDocument& doc) {
        parseP1MeterData(doc);
      },
      4000);
    if (p1RequestSucceeded) {
      p1.lastUpdate = millis();
    } else {                   // fallback: set error indicators
      p1.activeTariff = 3;     // connection error indicator
      p1.activePowerW = 4040;  // error value
    }
    vTaskDelay(pdMS_TO_TICKS(p1.updateInterval));
  }
}

void startP1MeterPollingTask() {  // Start P1 meter polling task (FreeRTOS)
  xTaskCreatePinnedToCore(
    p1PollingTask,    // Task function
    "P1PollingTask",  // Task name
    10240,            // Stack size (10KB)
    NULL,             // Parameter
    2,                // Priority (higher than sockets)
    &p1TaskHandle,    // Task handle
    0);               // Core 0
}

/**
 * @brief Checks if switchOnWatt needs to be updated
 */
void autoCalibrateSwitchOnThreshold(int socketIndex) {
  if (sockets[socketIndex].power <= 0) return;                                  // only calibrate if socket is consuming power
  if (sockets[socketIndex].power <= sockets[socketIndex].switchOnWatt) return;  // only calibrate if measured power exceeds current threshold
  uint16_t newThreshold = sockets[socketIndex].power * 1.05;                    // calculate new threshold with 5% safety margin
  if (newThreshold < 100) newThreshold = 100;
  sockets[socketIndex].pendingNewThreshold = newThreshold;  // set flag for update in main loop
  sockets[socketIndex].needsSwitchOnUpdate = true;
}

void startSocketPollingTasks() {  // start individual FreeRTOS tasks for each socket: parallel polling
  if (!hasRegisteredSockets) return;
  for (int i = 0; i < numSockets; i++) {
    if (strcmp(sockets[i].hostNameOrIP, UNCONFIGURED_IP) != 0 && strlen(sockets[i].hostNameOrIP) >= 7) {
      char taskName[24];
      snprintf(taskName, sizeof(taskName), "SocketTask%d", i);
      xTaskCreatePinnedToCore(
        socketPollingTask,
        taskName,
        10240,
        (void*)i,
        1,
        &socketTaskHandles[i],
        0);
    }
  }
}

/**
 * @brief FreeRTOS task for polling a single socket
 * Runs independently for each socket with its own timing
 */
void socketPollingTask(void* parameter) {
  int socketIndex = (int)parameter;
  for (;;) {
    if (strcmp(sockets[socketIndex].hostNameOrIP, UNCONFIGURED_IP) == 0 || strlen(sockets[socketIndex].hostNameOrIP) < 7) {
      vTaskDelay(pdMS_TO_TICKS(1000));
      continue;
    }
    uint32_t currentTime = millis();
    uint32_t updateInterval;
    if (sockets[socketIndex].useExtendedInterval) {
      updateInterval = extendedRetryInterval;
    } else if (sockets[socketIndex].offline) {
      updateInterval = socketRetryInterval;
    } else {
      updateInterval = socketUpdateInterval;
    }
    if (currentTime - sockets[socketIndex].lastUpdate >= updateInterval) {
      sockets[socketIndex].lastUpdate = currentTime;
      uint16_t timeout;
      if (sockets[socketIndex].offline || sockets[socketIndex].useExtendedInterval) timeout = 800;
      else timeout = 3000;
      // Make HTTP request (thread-safe via mutex)
      int previousPower = sockets[socketIndex].power;
      bool requestSuccess = processJsonRequestSocket(
        socketIndex,
        sockets[socketIndex].hostNameOrIP,
        [socketIndex](JsonDocument& doc) {
          if (!doc["active_power_w"].isNull()) {
            sockets[socketIndex].power = doc["active_power_w"].as<int>();
          }
          if (!doc["power_on"].isNull()) {
            sockets[socketIndex].isOn = doc["power_on"].as<bool>();
          }
        },
        timeout);
      if (!requestSuccess) {  // don't mark as failed if mutex timeout (only if actual HTTP failure)
        // Check if it was a mutex timeout vs real network failure
        // by attempting a very short retry after brief delay
        vTaskDelay(pdMS_TO_TICKS(100));
        bool retrySuccess = processJsonRequestSocket(
          socketIndex,
          sockets[socketIndex].hostNameOrIP,
          [socketIndex](JsonDocument& doc) {
            if (!doc["active_power_w"].isNull()) sockets[socketIndex].power = doc["active_power_w"].as<int>();
            if (!doc["power_on"].isNull()) sockets[socketIndex].isOn = doc["power_on"].as<bool>();
          },
          500);  // short timeout for retry
        requestSuccess = retrySuccess;
      }
      // Reset lastCheck timer when socket starts drawing power (transition from 0 to >0)
      if (requestSuccess && previousPower == 0 && sockets[socketIndex].power > 0) sockets[socketIndex].lastCheck = millis();
      if (requestSuccess) {  // update socket state based on request result
        // Socket is back online - reset failure counters
        sockets[socketIndex].consecutiveFailures = 0;
        sockets[socketIndex].useExtendedInterval = false;
        sockets[socketIndex].offline = false;
        autoCalibrateSwitchOnThreshold(socketIndex);  // Adaptive switchOnWatt threshold calibration based on actual power consumption
      } else {
        // Request failed - increment failure counter (but be lenient)
        sockets[socketIndex].consecutiveFailures++;
        // Only mark as offline after 3 consecutive real failures
        if (sockets[socketIndex].consecutiveFailures >= 3) sockets[socketIndex].offline = true;
        // Switch to extended interval after max consecutive failures
        if (sockets[socketIndex].consecutiveFailures >= maxConsecutiveFailures && !sockets[socketIndex].useExtendedInterval) {
          sockets[socketIndex].useExtendedInterval = true;
        }
      }
      sockets[socketIndex].lastRequestSuccess = requestSuccess;
    }
    vTaskDelay(pdMS_TO_TICKS(100));  // small delay to prevent task from hogging CPU
  }
}

/**
 * @brief Controls sockets based on measured consumption and surplus.
 * - Switches OFF sockets when total power consumption exceeds their switchOffWatt threshold.
 * - Switches ON sockets when sufficient power surplus is available.
 * - Prioritizes by offDelay: shortest delay → least important (OFF first), longest → most important (ON first).
 * - Round-robin rotation when multiple sockets have equal priority
 * - Enforces a minimum interval between ON activations.
 * - Maintains a running average (powerWattAverage) for stable decisions.
 */
void controlSocketsBasedOnPowerFlow() {  // called by p1PollingTask() ~ every second
  static int32_t runningSum = 0;
  static unsigned long lastTurnOnMillis = 0;
  static int lastSocketTurnedOn = -1;   // Track last socket turned ON for round-robin
  static int lastSocketTurnedOff = -1;  // Track last socket turned OFF for round-robin
  unsigned long now = millis();
  constexpr uint32_t minDelayForOn = 2000;  // 2 seconds before socket can be turned ON again
  // === 1. Update running average ===
  runningSum -= powerWattBuffer[powerBufferIndex];
  runningSum += p1.activePowerW;
  powerWattBuffer[powerBufferIndex] = p1.activePowerW;
  powerBufferIndex = (powerBufferIndex + 1) % powerBufferSize;
  powerWattAverage = runningSum / powerBufferSize;
  // === 2. Find best candidate to turn OFF (with round-robin for equal priority) ===
  int bestOff = -1;
  uint32_t shortestOffDelay = UINT32_MAX;
  for (int i = 0; i < numSockets; i++) {
    if (!autoControlEnabled || !sockets[i].autoControlEnabled) {
      sockets[i].lastCheck = now;  // reset when disabled
      continue;
    }
    unsigned long sinceLast = now - sockets[i].lastCheck;
    bool offDelayElapsed = (sinceLast >= sockets[i].offDelay);
    if (offDelayElapsed && sockets[i].power > 0
        && powerWattAverage > sockets[i].switchOffWatt
        && p1.activePowerW > sockets[i].switchOffWatt) {
      // Priority selection with round-robin for equal priority
      if (sockets[i].offDelay < shortestOffDelay) {
        // This socket has higher priority (shorter delay = less important)
        shortestOffDelay = sockets[i].offDelay;
        bestOff = i;
      } else if (sockets[i].offDelay == shortestOffDelay && bestOff != -1) {
        // Equal priority: use round-robin to distribute wear evenly
        // Prefer the next socket after the last one we turned off
        if ((lastSocketTurnedOff >= 0 && i > lastSocketTurnedOff && bestOff <= lastSocketTurnedOff)
            || (lastSocketTurnedOff >= 0 && i > bestOff && bestOff <= lastSocketTurnedOff)) {
          bestOff = i;
        }
      }
    }
  }
  // === 3. Find best candidate to turn ON (only if nothing needs to turn OFF) ===
  int bestOn = -1;
  uint32_t longestOffDelay = 0;
  if (bestOff < 0) {
    for (int i = 0; i < numSockets; i++) {
      if (!autoControlEnabled || !sockets[i].autoControlEnabled) continue;
      unsigned long sinceLast = now - sockets[i].lastCheck;
      bool minDelayElapsed = (sinceLast >= minDelayForOn);
      bool hysteresisPassed = ((now - sockets[i].lastTurnedOffMillis) >= hysteresisDelay);
      if (minDelayElapsed && !sockets[i].isOn
          && sockets[i].switchOnWatt > 0
          && hysteresisPassed
          && powerWattAverage < -sockets[i].switchOnWatt) {
        // Priority selection with round-robin for equal priority
        if (sockets[i].offDelay > longestOffDelay) {
          // This socket has higher priority (longer delay = more important)
          longestOffDelay = sockets[i].offDelay;
          bestOn = i;
        } else if (sockets[i].offDelay == longestOffDelay && bestOn != -1) {
          // Equal priority: use round-robin to distribute wear evenly
          // Prefer the next socket after the last one we turned on
          if ((lastSocketTurnedOn >= 0 && i > lastSocketTurnedOn && bestOn <= lastSocketTurnedOn)
              || (lastSocketTurnedOn >= 0 && i > bestOn && bestOn <= lastSocketTurnedOn)) {
            bestOn = i;
          }
        }
      }
    }
  }
  // === 4. Execute best action ===
  if (bestOff >= 0) {
    vTaskDelay(pdMS_TO_TICKS(50));
    setSocketPowerState(bestOff, false);
    sockets[bestOff].lastTurnedOffMillis = now;
    sockets[bestOff].lastCheck = now;
    lastSocketTurnedOff = bestOff;  // Remember for round-robin
  } else if (bestOn >= 0 && (now - lastTurnOnMillis >= MIN_SOCKET_SWITCH_INTERVAL_MS)) {
    vTaskDelay(pdMS_TO_TICKS(50));
    setSocketPowerState(bestOn, true);
    lastTurnOnMillis = now;
    sockets[bestOn].lastCheck = now;
    lastSocketTurnedOn = bestOn;  // Remember for round-robin
  }
}

void setSocketPowerState(int socketIndex, bool powerOn) {
  if (strcmp(sockets[socketIndex].hostNameOrIP, UNCONFIGURED_IP) == 0 || strlen(sockets[socketIndex].hostNameOrIP) == 0) return;
  WiFiClient* client;
  HTTPClient* httpClient;
  SemaphoreHandle_t mutex;
  getSocketClientAndMutex(socketIndex, client, httpClient, mutex);
  if (!client || !httpClient || !mutex) return;
  if (xSemaphoreTake(mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return;
  char urlBuffer[64];
  snprintf(urlBuffer, sizeof(urlBuffer), "http://%s/api/v1/state", sockets[socketIndex].hostNameOrIP);
  if (!httpClient->begin(*client, urlBuffer)) {
    xSemaphoreGive(mutex);
    return;
  }
  httpClient->addHeader("Content-Type", "application/json");
  char payloadBuffer[32];
  snprintf(payloadBuffer, sizeof(payloadBuffer), "{\"power_on\": %s}", powerOn ? "true" : "false");
  httpClient->PUT(payloadBuffer);  // no confirmation is requested, result is visible on display
  httpClient->end();
  sockets[socketIndex].isOn = powerOn;  // record socket state
  xSemaphoreGive(mutex);
}

void handleButtonInput() {  // adjust this code if a module other than T-Display is used
  constexpr uint16_t debounceDelay = 50;
  constexpr uint16_t longPressDuration = 5000;  // 5 seconds for long press
  unsigned long now = millis();
  // Other button handling (if configured)
  if (buttonOther != 0) {
    bool currentOtherState = digitalRead(buttonOther);
    static unsigned long otherPressStart = 0;
    static bool longPressHandled = false;
    if (currentOtherState == LOW && lastButtonStateOther == HIGH) {  // button just pressed
      if (now - lastButtonPressOther > debounceDelay) {
        otherPressStart = now;
        longPressHandled = false;
      }
    }
    if (currentOtherState == LOW && lastButtonStateOther == LOW) {  // button held down
      if (!longPressHandled && otherPressStart > 0 && (now - otherPressStart >= longPressDuration)) {
        // Long press: reset intervals and switchOnWatt thresholds (always works)
        flash.begin("P1-app", false);
        flash.remove("p1Interval");             // p1Interval then becomes defaultP1MeterUpdateInterval
        flash.remove("socketInterval");         // socketInterval then becomes defaultSocketUpdateInterval
        for (int i = 0; i < numSockets; i++) {  // reset switchOnWatt thresholds to 150W for all sockets
          char keyBuffer[24];
          snprintf(keyBuffer, sizeof(keyBuffer), "switchOnS%d", i + 1);
          flash.putUInt(keyBuffer, 150);
        }
        flash.end();
        longPressHandled = true;
        vTaskDelay(500 / portTICK_PERIOD_MS);
        ESP.restart();  // restart ESP32 to apply new defaults
      }
    }
    if (currentOtherState == HIGH && lastButtonStateOther == LOW) {
      if (!longPressHandled && otherPressStart > 0 && (now - otherPressStart < longPressDuration) && (now - otherPressStart > debounceDelay)) {
        // Short press: toggle auto control (only if autoControl is allowed)
        if (allowAutoControl) {
          autoControlEnabled = !autoControlEnabled;
          lastButtonPressOther = now;
        }
      }
      otherPressStart = 0;
      longPressHandled = false;
    }
    lastButtonStateOther = currentOtherState;
  }
  // Flash button handling
  static unsigned long flashPressStart = 0;
  if (!digitalRead(0)) {
    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.setFont(&fonts::Font4);
  sp.drawRightString(peakBuffer, spriteWidth - 2, 0);  // monthly peak
  if (p1.activeTariff > 0 && p1.activeTariff <= 3) {   // day/night rate information
    static const char* messages[] = { "Watt", "Watt", "Conn ERR" };
    sp.setFont(&fonts::Font4);
    sp.setTextColor(colors[p1.activeTariff - 1]);  // using arrays is much more compact than if..else or switch..case loops
    sp.setFont(&fonts::Font4);
    sp.drawString(messages[p1.activeTariff - 1], spriteWidth / 5, spriteHeight / 5);
  }
  sp.setTextColor(mapPowerToColor(p1.activePowerW));
  char powerBuffer[10];
  snprintf(powerBuffer, sizeof(powerBuffer), "%d", p1.activePowerW);
  sp.setFont(&fonts::Font8);
  sp.drawRightString(powerBuffer, spriteWidth, spriteHeight * 0.44);  // total power usage
  for (int i = 0; i < numSockets; i++) {
    if (strcmp(sockets[i].hostNameOrIP, UNCONFIGURED_IP) != 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.setFont(&fonts::Font2);
        sp.drawRightString(delayBuffer, spriteWidth / 1.7, spriteHeight / 6.4 + (i * textLineSpacing));
      }
    }
  }
  if (autoControlEnabled && hasRegisteredSockets) {  // show auto control information
    sp.setTextColor(TFT_GREEN);
    sp.setFont(&fonts::Font4);
    sp.drawString(F("Auto ctrl"), 2, 0);
    sp.setFont(&fonts::Font2);
    sp.drawString(F("Delay"), spriteWidth / 2.1, -3);
    sp.drawString(F("seconds"), spriteWidth / 2.1, 8);
  }
  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) {                                         // socket is online
    if (sockets[index].power > 0) textColor = TFT_CYAN;  // socket supplies power: cyan
    else if (sockets[index].isOn) textColor = TFT_BLUE;  // socket on but no power consumption: blue
    else if (autoControlEnabled && sockets[index].autoControlEnabled) {
      textColor = TFT_BROWN;          // socket off, autocontrol enabled: brown
    } else textColor = TFT_DARKGREY;  // socket off, autocontrol disabled: dark grey
  } else if (sockets[index].useExtendedInterval) {
    textColor = TFT_MAGENTA;   // socket is offline - extended interval
  } else textColor = TFT_RED;  // socket is offline - normal interval

  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.setFont(&fonts::Font2);
  sp.drawRightString(displayName, spriteWidth, spriteHeight / 6.4 + (index * textLineSpacing));
  char powerBuffer[10];
  snprintf(powerBuffer, sizeof(powerBuffer), "%d", activePowerW);
  sp.setFont(&fonts::Font2);
  sp.drawRightString(powerBuffer, spriteWidth / 7.5, spriteHeight / 6.4 + (index * textLineSpacing));
  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.setFont(&fonts::Font2);
  tft.setTextDatum(middle_center);
  tft.drawString("Connecting to", spriteWidth / 2, spriteHeight / 2 - 10);
  tft.setFont(&fonts::Font4);
  tft.drawString(wifiSsid.c_str(), spriteWidth / 2, spriteHeight / 2 + 20);
  for (uint8_t i = 0; i < wifiMaxAttempts; ++i) {
    if (WiFi.isConnected()) {
      WiFi.setAutoReconnect(true);
      WiFi.persistent(true);
      tft.fillScreen(TFT_BLACK);
      tft.setTextDatum(middle_center);
      tft.setFont(&fonts::Font4);
      tft.drawString("Connected", spriteWidth / 2, spriteHeight / 2 - 10);
      tft.setFont(&fonts::Font2);
      tft.drawString(p1.hostnameOrIp, spriteWidth / 2, spriteHeight / 2 + 20);
      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: </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') {
    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>";
      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>";
      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>";
      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>";
      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>";
      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>";
      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='2' ";
      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);
    int offDelaySeconds = server.arg(key).toInt();
    if (offDelaySeconds < 2) offDelaySeconds = 2;
    sockets[i].offDelay = offDelaySeconds * 1000;  // convert 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) {
    flash.begin("login_data", false);
    flash.putString("ssid", newSsid);
    flash.putString("pasw", newPassword);
    flash.end();
    flash.begin("P1-app", false);
    flash.putString("ipM", ipAddr);
    if (newP1Interval != 0 && newP1Interval != defaultP1MeterUpdateInterval) flash.putUInt("p1Interval", newP1Interval);
    if (newSocketInterval > 0 && newSocketInterval != defaultSocketUpdateInterval) 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();
  }
  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();
}

8. Extras and extensions

This solution also works with larger displays like the ESP32-2432S028, allowing easier viewing from a distance. Available on AliExpress or Amazon.

9. Technical

The sketch uses FreeRTOS to perform parallel tasks. This is much faster than sequential execution. It is used, among other things, when scanning for devices on the network and for polling the sockets.

↩ More projects and sketches

© johanok@gmail.com