ESP32 met HomeWizard P1-meter en Energy Sockets

Live energieverbruik op een compact display via WiFi

T-Display S3

In deze handleiding tonen we hoe je een Lilygo T-Display gebruikt om je stroomverbruik van je digitale teller live weer te geven via een HomeWizard P1-meter. Optioneel kun je ook Energy Sockets aansturen afhankelijk van zonneproductie.

Korte beschrijving van de werking

Wat wordt er getoond op het display

Sketch aanpassen voor de T-Display S3: zet GPIO 15 op HIGH in setup() voor een stabiele WiFi-verbinding.

1. Wat heb je nodig?

Overzicht P1-meter
Tip: Controleer of je WiFi goed bereikbaar is bij je digitale meter. Een repeater kan helpen.

2. Functies van deze oplossing

Lilygo display met P1-gegevens

Lilygo T-Display met de hier beschreven code

3. Lokale API activeren

Open de HomeWizard app en activeer de lokale API voor de P1-meter en eventuele sockets via Instellingen > Meters.

4. Apparaten automatisch vinden op je netwerk

Je hebt de IP-adressen nodig, liefst zelfs de namenHet inbrengen van de gegevens op naam in plaats van op IP-nummer heeft het voordeel dat je niets meer moet wijzigen, moest nadien het IP-adres wijzigen na bvb. een netwerkstoring, van je P1-meter en sockets voor de verdere instellingen.

Als er nog geen P1-meter was ingebracht (of als je die in de setup-html verwijderd hebt), zal het bordje je netwerk automatisch scannen en P1-meter en sockets zelf detecteren. Na de scan worden de hostnames van de gevonden toestellen naar het flashgeheugen weggeschreven. Dat gebeurt op naam, niet op IP-adres. Daarna herstart het bordje.

Als je alles graag zelf controleert kan je handmatig op je router inloggen om IP-adressen en netwerknamen te weten te komen. Er zijn ook handige apps zoals Net Scan voor Android of Fing. De P1-meter zendt zijn naam uit op het netwerk en heeft poort 80 open.

Scan in progress Scan Results

5. Setup en instellingen

Na het opstarten zal het display proberen verbinding te maken met je WiFi. Lukt dit niet, dan start het een draadloos toegangspunt (Lily). Verbind hiermee draadloos (zet eerst je mobiele data uit), open je browser op 192.168.4.1 en vul het formulier in. De eerste keer is het zelfs aangeraden om enkel de WiFi-gegevens in te vullen. Dan scant het bordje naar apparaten van HomeWizard (mits hun lokale API werd opengezet op de app van HomeWizard).

Geen WiFi - Setup pagina
Gebruik apparaatnamenHet inbrengen van de gegevens op naam in plaats van op IP-nummer heeft het voordeel dat je niets meer moet wijzigen, moest nadien het IP-adres wijzigen na bvb. een netwerkstoring in plaats van IP-adressen (bv. p1meter-123abc.local) om problemen bij IP-wijzigingen te voorkomen. Als het bordje zelf je netwerk scant, wordt alles op naam opgeslagen.
Setup page part 1 Setup page part 2
Sneller dan de HomeWizard app: de betalende versie van de HomeWizard app schakelt ook sockets op basis van een zonnetaak als je dat instelt. De uitschakelvertraging die je daar kan instellen is minimum 1 minuut. Deze module schakelt veel sneller, je kan dit per seconde instellen. Handig om nodeloos verbruik te vermijden op dagen dat zon en wolken snel afwisselen.

6. Knoppenbediening

7. Sketch laden: de code op dit bordje zetten

Eenvoudige methode: Via de browser flashen (aanbevolen)

Voor de meeste gebruikers is dit de gemakkelijkste manier om de software op je bordje te installeren. Je hebt geen Arduino IDE of andere software nodig - alles gebeurt direct in je webbrowser!

Vereisten:
  1. Open de flash tool

    Ga naar https://esptool.spacehuhn.com/ in je webbrowser (Chrome, Edge of Opera).
    Verbind je Lilygo T-Display via USB-datakabel met je computer.
    Klik vervolgens op de knop "Connect" in de browser.

    Open de ESPTool website

    De webpagina van ESPTool - hier kun je direct vanuit je browser flashen zonder extra software te installeren.

  2. Selecteer de juiste poort

    Er verschijnt een pop-up waarin je de juiste COM-poort of USB-poort moet selecteren waar je ESP32 op is aangesloten. Selecteer de juiste poort en klik op "Verbinden".

    Selecteer de COM poort

    Selecteer de USB-poort waarop je ESP32 is aangesloten. Dit is meestal iets als "USB Serial" of "CP210x".

  3. Kies het firmware bestand

    Zet het adres op nul (0) en klik dan op "select".

    Kies het firmware bestand

    Klik op "SELECT" en selecteer het .bin bestand (firmware) dat je zonet gedownload hebt.

    Kies het firmware bestand

    Selecteer het .bin firmware bestand van je computer.

  4. Start het flashen

    Klik op de knop "Program" om het flash-proces te starten. Je ziet in een voortgangsvak onderaan statusmeldingen verschijnen. Dit duurt enkele minuten - wacht rustig af en verbreek de USB-verbinding niet!

    Flash proces bezig

    Het flash-proces is bezig. Je ziet de voortgang en eventuele statusmeldingen. Wacht tot het proces volledig is afgerond.

  5. Flash compleet!

    Wanneer het flashen succesvol is afgerond, zie je de melding "Done!" of "Flash successful".

    Flash succesvol

    Gelukt! De firmware is succesvol geflasht. Als je het bordje herstart (even de USB-kabel los en terug vastkoppelen), dan werkt het met de nieuwe software. Je mag het bordje nu aansluiten op een gewone voeding (zoals een oude oplader van een smartphone), het heeft geen PC meer nodig. Het bordje werkt nu op zichzelf. De eerste keer verbind je draadloos met het bordje (instructies staan op het display), en breng je liefst enkel de gegevens in van je lokale WiFi-netwerk. Uiteraard moet dat hetzelfde netwerk zijn waar de P1-meter mee verbonden is. Als je enkel de WiFi-credentials inbrengt, dan zal het script zelf je netwerk scannen naar HomeWizard-apparaten, en alles opslaan.

Probleemoplossing:

🔧 Voor gevorderde gebruikers: Broncode en Arduino IDE

Let op: Deze methode is alleen bedoeld voor mensen die vertrouwd zijn met Arduino programmeren en het instellen van bibliotheken. Voor de meeste gebruikers is de browser-methode hierboven veel eenvoudiger!

Als je de code zelf wilt aanpassen of compileren, kun je de Arduino IDE gebruiken. Download dan de onderstaande sketch en zorg dat je alle benodigde bibliotheken geïnstalleerd hebt:

📥 Download de sketch (Hw_Lovyan_T.ino)

📥 Download de gebruiksaanwijzing (EspGo.be-gebruiksaanwijzing.pdf)

Klik hier om de volledige broncode te bekijken
/**
 * 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. Extra's en uitbreidingen

Deze oplossing (mits je de sketch.ino aanpast) werkt ook op grotere schermen zoals de ESP32-2432S028. Zo kun je de gegevens van grotere afstand aflezen. Beschikbaar via AliExpress of Amazon.

9. Technisch

Bij testen in meerdere thuisnetwerken blijkt dat de Lilygo T-Display een veel betere netwerkverbinding heeft dan de T-Display S3. Die vindt soms apparaten niet of heeft soms een slechte verbinding, terwijl de Lilygo T-Display geen problemen heeft.

Moest je toch een T-Display S3 hebben en deze code er op wil zetten: gebruik dan het bestand AutoSockets_S3.ino.merged.bin (enkel voor LilyGo T-Display S3) en doe hiermee de stappen van punt 7 hierboven. Deze (gecompileerde) code is aangepast voor een zo stabiel mogelijke WiFi-verbinding.

Deze modules van Lilygo zijn gebaseerd op de ESP32-chip. Die heeft alles aan boord voor draadloze WiFi, en werkt dus gewoon met een standaard 5 volt-aansluiting op de USB-C poort. De module moet dus niet op een PC aangesloten zijn voor de reguliere werking.

De sketch gebruikt FreeRTOS om parallelle taken uit te voeren. Dat is veel sneller dan de sequentiële uitvoering. Dit wordt onder andere gebruikt bij het scannen naar apparaten op het netwerk, en voor het pollen van de sockets.

Ingebouwde veiligheidsmechanismen

  • Verlies van verbinding met de P1-meter: alle gekoppelde Energy Sockets worden automatisch uitgeschakeld om ongecontroleerd verbruik te voorkomen.
  • Ongeldige of ontbrekende JSON-data: als de P1-meter of een socket foutieve gegevens doorstuurt, wordt de betreffende socket niet ingeschakeld totdat opnieuw geldige data beschikbaar zijn.
  • Te hoge gemeten belasting: wanneer het totale verbruik boven de ingestelde drempel(s) komt, schakelt het systeem sockets in volgorde van laagste prioriteit eerst uit.
  • Te weinig beschikbare zonne-energie: sockets die afhankelijk zijn van surplus blijven uitgeschakeld totdat het gemeten overschot weer boven hun ingestelde marge komt.
↩ Meer projecten en sketches

© johanok@gmail.com