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 of T-Display S3 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.

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.

Korte beschrijving van de werking

Wat wordt er getoond op het display

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 heeft een stabielere WiFi-verbinding dan T-Display S3

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. 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 toegangspunt (Lily). Verbind hiermee (zet eerst je mobiele data uit), open je browser op 192.168.4.1 en vul het formulier in.

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.
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

Gebruik de Arduino IDE om de onderstaande sketch op je Lilygo T-Display te laden. De sketch werkt op beide versies (T-Display & T-Display S3). Download deze sketch

Bekijk deze sketch
/**
 * @file AutoSockets_T.ino
 *
 * @brief ESP32 sketch for Lilygo T-Display / T-Display S3: HomeWizard Device Scanner and AutoSockets Controller.
 *
 * @author johanok@gmail.com
 *
 * @modes
 *   - Scanner Mode: Scans network for HomeWizard P1 meter
 *   - P1 Meter Mode: P1 meter monitoring (polled every second) - Optional Automatic Socket Controller
* 
 * @features
 *   - Automatic network scanning for P1 meter
 *   - Multi-core scanning implementation
 *   - WiFi configuration via web interface
 *   - TFT display output for real-time data
 *   - Persistent storage of settings
 *   - Manages HomeWizard Energy Sockets based on P1 meter data
 * 
 * @hardware
 *   - ESP32 microcontroller LilyGo TTGO T-Display or LilyGo TTGO T-Display S3
 *   - HomeWizard P1 meter
 * 
 * @note
 *   - Automatic restart after 2 minutes of connection failures
 * 
 * @section setup Setup
 * Web-based configuration at 192.168.4.1:
 * - Mandatory: WiFi network settings
 * - Optional:  Predefined P1 meter hostname or address
 * - Auto-scan: Finds P1 meter and Energy Sockets if address unknown
 * - Device will automatically find and connect to P1 meter
 * 
 * @section usage Usage
 * - Normal operation: Displays current power usage
 * - Long press flash button: Reset WiFi settings
 *
 * @section BootBehavior Boot Behavior
 * -   ** No P1 meter found: Automatically starts scanner mode
 * -   ** Normal startup:** The sketch starts in P1 meter reading and "AutoSockets Controller" mode.
 *
 * @section SocketManagement Socket Management Details (AutoSockets Mode - to handle any network problems)
 * -   **Normal polling interval:** Sockets are polled every 6 seconds.
 * -   **Normal retry interval (on failure):** After a failure, attempts to reconnect every 60 seconds (socketRetryInterval).
 * -   **Extended retry interval (on persistent failure):** After 10 consecutive failures, the socket switches to a 10-minute 
 * -     retry interval (extendedRetryInterval).
 * -   **Automatic recovery:** The socket automatically reverts to the normal interval once the connection is restored. 
 */

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

#define HTTP_PORT 80

HTTPClient httpClient;
WiFiClient wifiClient;
WebServer server(HTTP_PORT);
Preferences flash;
TFT_eSPI tft = TFT_eSPI();           // LilyGo TTGO T-Display: User_Setup_Select.h: Setup25_TTGO_T_Display.h
TFT_eSprite sp = TFT_eSprite(&tft);  // LilyGo T-Display S3 :  User_Setup_Select.h: Setup206_Lilygo_T_Display_S3.h

#define SCREEN_ROTATION_USB_LEFT 3

// scanner defines & variables

#define PROGRESS_BAR_HEIGHT 15
#define PROGRESS_BAR_WIDTH 200
#define PROGRESS_BAR_X (tft.width() / 2 - PROGRESS_BAR_WIDTH / 2)
#define PROGRESS_BAR_Y 50
#define TASK_COMPLETE_BIT(num) (1 << (num))

const int IP_RANGE_START = 1, IP_RANGE_END = 254, NUM_TASKS = 16;
int socketCount = 0, totalScanned = 0, currentIP = IP_RANGE_START;
String p1Hostname = "", socketHostnames[3];
SemaphoreHandle_t ipMutex;
EventGroupHandle_t scanEventGroup;
IPAddress baseIP;
bool p1Stored = false;
static uint32_t lastProgressUpdate = 0;
const uint32_t PROGRESS_UPDATE_INTERVAL = 200;
constexpr const char* defaultIP = "192.168.0.0";

// p1 meter & socket handling variables & structures

constexpr uint8_t numSockets = 3, textLineSpacing = 12, MAX_IP_LENGTH = 64, MAX_NAME_LENGTH = 32;
constexpr uint8_t powerBufferSize = 5, maxConsecutiveFailures = 10;
constexpr uint16_t defaultP1MeterUpdateInterval = 1000, defaultSocketUpdateInterval = 6000;
constexpr uint32_t maxHttpFailureDuration = 2 * 60 * 1000, socketRetryInterval = 60 * 1000, extendedRetryInterval = 10 * 60 * 1000;
bool hasRegisteredSockets = false, scannerMode = false, allowAutoControl = false, autoControlEnabled = false, lastButtonStateOther = HIGH;
const unsigned long hysteresisDelay = 1000, RESTART_DELAY_MS = 5000, MIN_SOCKET_SWITCH_INTERVAL_MS = 10000;
uint8_t powerBufferIndex = 0, buttonOther = 0;  // "other" = opposite flash button
uint16_t spriteWidth, spriteHeight;
uint16_t socketUpdateInterval = defaultSocketUpdateInterval;
int16_t powerWattBuffer[powerBufferSize] = { 0 }, powerWattAverage = 0;
uint32_t lastButtonPressFlash = 0, lastButtonPressOther = 0;
std::vector<String> availableSSIDs;

struct Socket {
  char hostNameOrIP[MAX_IP_LENGTH];
  char name[MAX_NAME_LENGTH];  // (e.g. "boiler", "bathroom")
  int power;                   // current power usage in Watts
  uint8_t consecutiveFailures = 0;
  bool useExtendedInterval = false;
  bool lastRequestSuccess = false;
  bool offline = false;
  bool autoControlEnabled = false;
  unsigned long lastTurnedOffMillis = 0;
  uint16_t switchOnWatt = 10000;  // threshold (in Watts) to automatically turn on the socket
  uint16_t switchOffWatt = 0;     // threshold (in Watts) to automatically switch off the socket
  uint32_t offDelay = 2000;       // delay (in ms) before auto turning off the socket
  uint32_t lastUpdate = 0;
  uint64_t lastCheck = 0;
};

Socket sockets[numSockets];

struct P1Data {
  char hostnameOrIp[MAX_IP_LENGTH];
  int activeTariff;      // (e.g., day or night tariff)
  int activePowerW;      // measured power flow in Watts
  uint16_t monthlyPeak;  // also in Watts
  unsigned long lastUpdate = 0;
  uint16_t updateInterval = defaultP1MeterUpdateInterval;
};

P1Data p1;

void setup() {
  Serial.begin(115200);
  tft.init();
  tft.setRotation(SCREEN_ROTATION_USB_LEFT);
  tft.fillScreen(TFT_BLACK);
  flash.begin("P1-app", true);
  String savedP1Host = flash.getString("ipM", "");
  flash.end();
  if (savedP1Host.isEmpty()) {
    scannerMode = true;
    tft.drawCentreString("Scanner Mode", tft.width() / 2, 0, 4);
    tft.drawCentreString("No P1 meter found", tft.width() / 2, 24, 2);
    delay(1000);
    setupScanner();
  } else {
    tft.drawCentreString("P1 meter Mode", tft.width() / 2, 0, 4);
    tft.drawCentreString("Normal startup", tft.width() / 2, 24, 2);
    delay(1000);
    setupAutoSockets();
  }
}

void loop() {
  scannerMode ? loopScanner() : loopAutoSockets();
}

void showMessageNoConnection() {  // message on display: no WiFi connection
  const char* noC[] = { "WiFi: no connection.", "Connect to hotspot", "'Lily', open browser", "address 192.168.4.1", "for login + password." };
  tft.fillScreen(TFT_NAVY);
  tft.setTextColor(TFT_YELLOW);
  for (uint8_t i = 0; i < 5; i++) tft.drawCentreString(noC[i], 120, i * 27, 4);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
}

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

void setupScanner() {
  tft.fillScreen(TFT_BLACK);
  tft.drawCentreString("HomeWizard Scan", tft.width() / 2, 0, 4);
  flash.begin("login_data", true);
  String wifiSsid = flash.getString("ssid", "");  // we obviously need the WiFi credentials to scan the local network
  String wifiPassword = flash.getString("pasw", "");
  flash.end();
  if (wifiSsid != "") {
    tft.drawCentreString("Connecting to WiFi...", tft.width() / 2, 24, 4);
    WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
    unsigned long startTime = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - startTime < 10000) delay(250);
  }
  if (WiFi.status() != WL_CONNECTED) {
    showMessageNoConnection();
    startWiFiAccessPoint();
    return;
  }
  Serial.println("\nWiFi connected.");
  tft.fillScreen(TFT_BLACK);
  tft.drawCentreString("WiFi connected!", tft.width() / 2, 24, 4);
  delay(1000);
  baseIP = WiFi.localIP();
  String ipPrefix = String(baseIP[0]) + "." + String(baseIP[1]) + "." + String(baseIP[2]);
  Serial.println("Scan IPs " + ipPrefix + ".1 --> " + ipPrefix + ".255");
  tft.fillScreen(TFT_BLACK);
  tft.drawCentreString("Scanning network", tft.width() / 2, 0, 4);
  tft.drawCentreString(ipPrefix + ".1  >>  " + ipPrefix + ".255", tft.width() / 2, 26, 2);
  tft.drawRect(PROGRESS_BAR_X - 2, PROGRESS_BAR_Y - 2, PROGRESS_BAR_WIDTH + 4, PROGRESS_BAR_HEIGHT + 4, TFT_WHITE);
  updateProgressBar(0);
  ipMutex = xSemaphoreCreateMutex();
  scanEventGroup = xEventGroupCreate();
  for (int i = 0; i < NUM_TASKS; i++) {
    char taskName[16];
    snprintf(taskName, sizeof(taskName), "ScannerTask%d", i);
    xTaskCreatePinnedToCore(scannerTask, taskName, 8192, (void*)i, 1, NULL, 0);
  }
}

void loopScanner() {
  EventBits_t bits = xEventGroupWaitBits(scanEventGroup, (1 << NUM_TASKS) - 1, true, true, portMAX_DELAY);
  if (bits == ((1 << NUM_TASKS) - 1)) displayScanResults();  // call displayScanResults() if NUM_TASKS bits are set to "1".
}

void updateProgressBar(int percentage) {
  static int lastPercentage = -1;
  if (percentage != lastPercentage || millis() - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL) {
    int progressWidth = (percentage * PROGRESS_BAR_WIDTH) / 100;
    if (percentage > lastPercentage)
      tft.fillRect(PROGRESS_BAR_X + (lastPercentage * PROGRESS_BAR_WIDTH / 100), PROGRESS_BAR_Y,
                   progressWidth - (lastPercentage * PROGRESS_BAR_WIDTH / 100), PROGRESS_BAR_HEIGHT, TFT_CYAN);
    if (percentage != lastPercentage) {
      char buffer[8];
      snprintf(buffer, sizeof(buffer), "%3d%%", percentage);
      tft.setTextColor(TFT_WHITE, TFT_BLACK);
      tft.drawCentreString(buffer, tft.width() / 2, PROGRESS_BAR_Y + 24, 4);
    }
    lastPercentage = percentage;
    lastProgressUpdate = millis();
  }
}

void displayScanResults() {
  Serial.println("\n--- Scan complete ---");
  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_GREEN, TFT_BLACK);
  tft.drawCentreString("---Scan Complete---", tft.width() / 2, 2, 4);
  tft.setTextColor(TFT_CYAN, TFT_BLACK);
  tft.drawCentreString("Results (found hostnames)", tft.width() / 2, 22, 2);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setCursor(0, 24);
  flash.begin("P1-app", false);
  if (p1Stored) {
    Serial.println("Found P1 meter: " + p1Hostname);
    flash.putString("ipM", p1Hostname);
    tft.setTextColor(TFT_YELLOW, TFT_BLACK);
    tft.drawCentreString(p1Hostname, tft.width() / 2, 40, 2);
  }
  for (int i = 0; i < socketCount; i++) {
    Serial.printf("Found socket %d: %s\n", i + 1, socketHostnames[i].c_str());
    String key = "ipS" + String(i + 1);
    flash.putString(key.c_str(), socketHostnames[i]);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.drawCentreString(socketHostnames[i], tft.width() / 2, 60 + i * 20, 2);
  }
  flash.end();
  if (!p1Stored && socketCount == 0) {
    tft.drawCentreString("No HomeWizard devices found", tft.width() / 2, tft.height() - 20, 2);
    delay(2000);
    startWiFiAccessPoint();  // this allows the user to manually enter the hostname of the p1 meter / sockets
  } else {
    tft.setTextColor(TFT_CYAN, TFT_BLACK);
    tft.drawCentreString("Restarting for P1 reading mode", tft.width() / 2, tft.height() - 20, 2);
    delay(2000);
    ESP.restart();
  }
}

void scannerTask(void* parameter) {
  int taskNum = (int)parameter;
  while (true) {
    int ipLastOctet = -1;
    xSemaphoreTake(ipMutex, portMAX_DELAY);
    if (currentIP <= IP_RANGE_END) ipLastOctet = currentIP++;
    xSemaphoreGive(ipMutex);
    if (ipLastOctet == -1) {
      xEventGroupSetBits(scanEventGroup, TASK_COMPLETE_BIT(taskNum));  // mark task as complete
      vTaskDelete(NULL);
    }
    IPAddress targetIP(baseIP[0], baseIP[1], baseIP[2], ipLastOctet);
    String url = "http://" + targetIP.toString() + "/api";
    HTTPClient http;
    http.setTimeout(400);
    http.begin(url);
    int httpCode = http.GET();
    if (httpCode == 200) {
      String payload = http.getString();
      JsonDocument doc;
      DeserializationError error = deserializeJson(doc, payload);
      if (!error) {
        const char* product_type = doc["product_type"];
        const char* serial = doc["serial"];
        String hostname;
        if (String(product_type) == "HWE-P1") hostname = "p1meter-";
        else if (String(product_type) == "HWE-SKT") hostname = "energysocket-";
        else hostname = "unknown-";
        hostname += String(serial).substring(strlen(serial) - 6);
        hostname += ".local";
        xSemaphoreTake(ipMutex, portMAX_DELAY);
        if (String(product_type) == "HWE-P1" && !p1Stored) {
          p1Hostname = hostname;
          p1Stored = true;
        } else if (String(product_type) == "HWE-SKT" && socketCount < 3) {
          socketHostnames[socketCount++] = hostname;
        }
        xSemaphoreGive(ipMutex);
      }
    }
    http.end();
    xSemaphoreTake(ipMutex, portMAX_DELAY);
    totalScanned++;
    int progressPercentage = (totalScanned * 100) / (IP_RANGE_END - IP_RANGE_START + 1);
    updateProgressBar(progressPercentage);
    if (totalScanned % 16 == 0) {
      Serial.printf("Scanned IP %s.%d\n", baseIP.toString().substring(0, baseIP.toString().lastIndexOf('.')).c_str(), totalScanned);
      char buffer2[50];
      snprintf(buffer2, sizeof(buffer2), "Scanned: %d/%d", totalScanned, IP_RANGE_END - IP_RANGE_START + 1);
      tft.drawCentreString(buffer2, tft.width() / 2, PROGRESS_BAR_Y + 50, 4);
    }
    xSemaphoreGive(ipMutex);
  }
}

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

void setupAutoSockets() {
  initializeDisplay();
  loadSavedSettings();
  configureButtons();
  connectToWiFi();
}

void loopAutoSockets() {
  getP1MeterData();
  getSocketData();
  if (shouldUpdateDisplay()) updateDisplay();
  checkHttpFailureAndRestart();  // restart only if HTTP failures persist
}

void initializeDisplay() {
  sp.setColorDepth(8);
  sp.createSprite(tft.width(), min(tft.height(), (int16_t)170));
  spriteWidth = sp.width();
  spriteHeight = sp.height();
}

void loadSavedSettings() {  // retrieve data from flash memory and assign it to variables
  flash.begin("P1-app", true);
  allowAutoControl = flash.getBool("autoControl", false);
  autoControlEnabled = allowAutoControl;  // autoControlEnabled can be toggled, allowAutoControl not
  flash.getString("ipM", p1.hostnameOrIp, MAX_IP_LENGTH);
  p1.updateInterval = flash.getUInt("p1Interval", defaultP1MeterUpdateInterval);
  socketUpdateInterval = flash.getUInt("socketInterval", defaultSocketUpdateInterval);
  hasRegisteredSockets = false;           // reset before scanning
  for (int i = 0; i < numSockets; i++) {  // socket settings
    char keyBuffer[24];
    snprintf(keyBuffer, sizeof(keyBuffer), "ipS%d", i + 1);
    flash.getString(keyBuffer, sockets[i].hostNameOrIP, MAX_IP_LENGTH);
    if (strlen(sockets[i].hostNameOrIP) < 7) strcpy(sockets[i].hostNameOrIP, defaultIP);
    if (strcmp(sockets[i].hostNameOrIP, defaultIP) != 0) hasRegisteredSockets = true;
    snprintf(keyBuffer, sizeof(keyBuffer), "nameS%d", i + 1);
    flash.getString(keyBuffer, sockets[i].name, MAX_NAME_LENGTH);
    snprintf(keyBuffer, sizeof(keyBuffer), "autoControlS%d", i + 1);
    sockets[i].autoControlEnabled = flash.getBool(keyBuffer, false);
    snprintf(keyBuffer, sizeof(keyBuffer), "offDelayS%d", i + 1);
    sockets[i].offDelay = flash.getUInt(keyBuffer, 2000);
    snprintf(keyBuffer, sizeof(keyBuffer), "switchOnS%d", i + 1);
    sockets[i].switchOnWatt = flash.getUInt(keyBuffer, 10000);
    snprintf(keyBuffer, sizeof(keyBuffer), "switchOffS%d", i + 1);
    sockets[i].switchOffWatt = flash.getUInt(keyBuffer, 0);
  }

  flash.end();
}


void handleButtonPress(void* parameter) {  // function for FreeRTOS task
  for (;;) {                               // intentional infinite loop
    handleButtonInput();                   // function handleButtonInput() is not in loop() because FreeRTOS does the handling
    vTaskDelay(20 / portTICK_PERIOD_MS);   // every 20 milliseconds
  }
}

void configureButtons() {  // GPIO of "other button" depending on board type
#ifdef USER_SETUP_ID       // adjust this code if a display / board other than T-Display or T-Display S3 is used
#if USER_SETUP_ID == 25    // T-Display
  buttonOther = 35;
#elif USER_SETUP_ID == 206  // T-Display S3
  buttonOther = 14;
#endif
#endif
  if (autoControlEnabled && buttonOther != 0) pinMode(buttonOther, INPUT_PULLUP);
  xTaskCreatePinnedToCore(handleButtonPress, "ButtonPressed", 4096, NULL, 1, NULL, 0);  // core 0 because JSON runs on core 1
}

bool processJsonRequest(const char* ipAddressOrName, std::function<void(JsonDocument&)> onSuccess, uint16_t customTimeout = 3000) {
  char apiUrl[64];
  snprintf(apiUrl, sizeof(apiUrl), "http://%s/api/v1/data", ipAddressOrName);  // format apiUrl by inserting ipAddressOrName into "%s"
  httpClient.setTimeout(customTimeout);
  httpClient.begin(wifiClient, apiUrl);  // start HTTP request
  int httpResponseCode = httpClient.GET();
  if (httpResponseCode == 200) {
    JsonDocument doc;
    DeserializationError error = deserializeJson(doc, httpClient.getStream());
    if (!error) {
      onSuccess(doc);
      httpClient.end();
      return true;
    }
  }
  httpClient.end();  // close connection in case of failure
  return false;      // return failure
}

void parseP1MeterData(const JsonDocument& doc) {
  if (!doc["active_tariff"].isNull()) p1.activeTariff = doc["active_tariff"].as<int>();
  if (!doc["active_power_w"].isNull()) p1.activePowerW = doc["active_power_w"].as<int>();
  if (!doc["montly_power_peak_w"].isNull()) p1.monthlyPeak = doc["montly_power_peak_w"].as<int>();  // misspelled but valid key
  p1.lastUpdate = millis();
}

void getP1MeterData() {
  uint32_t currentTime = millis();
  if (currentTime - p1.lastUpdate < p1.updateInterval) return;  // do not check if the interval has not expired
  if (hasRegisteredSockets) controlSocketsBasedOnPowerFlow();
  bool p1RequestSucceeded = processJsonRequest(p1.hostnameOrIp, [](JsonDocument& doc) {
    parseP1MeterData(doc);
  });
  if (p1RequestSucceeded) {
    p1.lastUpdate = millis();
    return;
  }
  p1.activeTariff = 3;  // fallback: set active tariff to 3 to indicate a connection error with the P1 meter
  p1.activePowerW = 4040;
}

void getSocketData() {  // function to handle socket updates independently of P1 meter
  if (!hasRegisteredSockets) return;
  uint32_t currentTime = millis();
  for (int i = 0; i < numSockets; i++) {
    if (strcmp(sockets[i].hostNameOrIP, defaultIP) == 0 || strlen(sockets[i].hostNameOrIP) < 7) continue;  // skip sockets without valid IP
    uint32_t updateInterval;
    if (sockets[i].useExtendedInterval) updateInterval = extendedRetryInterval;  // use 10-minute interval
    else if (sockets[i].offline) updateInterval = socketRetryInterval;           // 1-minute interval for offline sockets
    else updateInterval = socketUpdateInterval;                                  // normal interval for online sockets
    if (currentTime - sockets[i].lastUpdate >= updateInterval) {
      sockets[i].lastUpdate = currentTime;
      uint16_t timeout;
      if (sockets[i].offline || sockets[i].useExtendedInterval) timeout = 800;  // 800 ms timeout for offline sockets to prevent blocking P1-meter
      else timeout = 3000;                                                      // normal timeout (3s) for online sockets
      bool requestSuccess = processJsonRequest(
        sockets[i].hostNameOrIP, [i](JsonDocument& doc) {
          if (!doc["active_power_w"].isNull()) sockets[i].power = doc["active_power_w"].as<int>();
        },
        timeout);
      if (requestSuccess) {  // socket is back online - reset failure counters & flags
        sockets[i].consecutiveFailures = 0;
        sockets[i].useExtendedInterval = false;
        sockets[i].offline = false;
      } else {  // socket request failed - increment failure counter
        sockets[i].consecutiveFailures++;
        sockets[i].offline = true;
        if (sockets[i].consecutiveFailures >= maxConsecutiveFailures && !sockets[i].useExtendedInterval) sockets[i].useExtendedInterval = true;
      }
      sockets[i].lastRequestSuccess = requestSuccess;
      break;  // only process one socket per loop to avoid blocking
    }
  }
}

/**
 * @brief Controls sockets based on measured consumption and surplus.
 *
 * - Switches OFF socket currently drawing power when the socket 
 *   draws more power than sockets[i].switchOffWatt.
 * - Selects at most one OFF socket as ON candidate if powerWattAverage indicates sufficient surplus
 * - Prioritizes the ON candidate with the highest offDelay.
 * - Enforces a minimum interval (MIN_SOCKET_SWITCH_INTERVAL_MS) between successive ON activations.
 * - Maintains a running average of active power over the last N samples (powerWattAverage).
 */
void controlSocketsBasedOnPowerFlow() {  // called by getP1MeterData() ~ every second
  static int32_t runningSum = 0;
  static unsigned long lastTurnOnMillis = 0;
  unsigned long now = millis();
  int bestCandidate = -1;
  uint32_t bestOffDelay = 0;
  runningSum -= powerWattBuffer[powerBufferIndex];  // === Update running average before making decisions ===
  runningSum += p1.activePowerW;
  powerWattBuffer[powerBufferIndex] = p1.activePowerW;
  powerBufferIndex = (powerBufferIndex + 1) % powerBufferSize;
  powerWattAverage = runningSum / powerBufferSize;
  for (int i = 0; i < numSockets; i++) {  // === Check sockets ===
    if (autoControlEnabled && sockets[i].autoControlEnabled) {
      bool delayElapsed = ((now - sockets[i].lastCheck) >= sockets[i].offDelay);
      bool hysteresisPassed = ((now - sockets[i].lastTurnedOffMillis) >= hysteresisDelay);
      if (delayElapsed && sockets[i].power > 0 && powerWattAverage > sockets[i].switchOffWatt) {
        setSocketPowerState(i, false);  // Switch OFF if socket is drawing power and average consumption exceeds threshold
        sockets[i].lastCheck = now;
        sockets[i].lastTurnedOffMillis = now;
      } else if (delayElapsed && sockets[i].power == 0 &&                            // currently off
                 sockets[i].switchOnWatt > 0 &&                                      // valid threshold
                 hysteresisPassed && powerWattAverage < -sockets[i].switchOnWatt) {  // sufficient surplus
        if (sockets[i].offDelay > bestOffDelay) {                                    // pick longest off-delay
          bestOffDelay = sockets[i].offDelay;
          bestCandidate = i;
        }
      }
      if (delayElapsed) sockets[i].lastCheck = now;  // update timer when off-delay elapsed
    } else sockets[i].lastCheck = now;               // reset timer to avoid immediate trigger on re-enable
  }
  if (bestCandidate >= 0 && (now - lastTurnOnMillis >= MIN_SOCKET_SWITCH_INTERVAL_MS)) {
    setSocketPowerState(bestCandidate, true);  // Switch ON the socket if sufficient surplus and hysteresis passed
    sockets[bestCandidate].lastCheck = now;
    lastTurnOnMillis = now;
  }
}

void setSocketPowerState(int socketIndex, bool powerOn) {  // function for switching the sockets
  if (strcmp(sockets[socketIndex].hostNameOrIP, defaultIP) == 0 || strlen(sockets[socketIndex].hostNameOrIP) == 0) return;
  char urlBuffer[64];
  snprintf(urlBuffer, sizeof(urlBuffer), "http://%s/api/v1/state", sockets[socketIndex].hostNameOrIP);
  if (!httpClient.begin(wifiClient, urlBuffer)) return;
  httpClient.addHeader("Content-Type", "application/json");
  char payloadBuffer[32];
  snprintf(payloadBuffer, sizeof(payloadBuffer), "{\"power_on\": %s}", powerOn ? "true" : "false");
  httpClient.PUT(payloadBuffer);  // int httpResponseCode = httpClient.PUT(payloadBuffer); is not used, result is visible on display
  httpClient.end();
}

void handleButtonInput() {  // adjust this code if a display other than T-Display or T-Display S3 is used
  constexpr uint16_t debounceDelay = 300;
  unsigned long now = millis();
  if (allowAutoControl && tft.height() < 171) {  // handle "other" button (T-Display / S3 only)
    bool currentOtherState = digitalRead(buttonOther);
    if (currentOtherState == LOW && lastButtonStateOther == HIGH && now - lastButtonPressOther > debounceDelay) {  // short press
      autoControlEnabled = !autoControlEnabled;                                                                    // toggle auto control
      lastButtonPressOther = now;
    }
    lastButtonStateOther = currentOtherState;
  }
  static unsigned long flashPressStart = 0;
  if (!digitalRead(0)) {  // flash button pressed
    if (now - lastButtonPressFlash > debounceDelay) {
      if (flashPressStart == 0) {
        flashPressStart = now;
        lastButtonPressFlash = now;
      }
      if (now - flashPressStart >= 5000) {  // flash button: long press detected
        flash.begin("login_data", false);   // reset WiFi SSID = force setup html on next startup
        flash.putString("ssid", "");
        flash.end();
        vTaskDelay(500 / portTICK_PERIOD_MS);
        ESP.restart();
      }
    }
  } else {
    flashPressStart = 0;
    lastButtonPressFlash = 0;
  }
}

uint16_t mapPowerToColor(int power) {
  if (power < 0) return TFT_GREEN;
  if (power < 500) return TFT_CYAN;
  if (power < 1000) return TFT_YELLOW;
  return (power < 2500) ? TFT_ORANGE : TFT_RED;
}

bool shouldUpdateDisplay() {  // only update if activePowerW changes or if 1 second has elapsed
  static int lastPower = -9999;
  static uint32_t lastDisplayUpdate = 0;
  bool powerChanged = (lastPower != p1.activePowerW);
  bool timeElapsed = (millis() - lastDisplayUpdate > 1000);
  if (powerChanged || timeElapsed) {
    lastPower = p1.activePowerW;
    lastDisplayUpdate = millis();
    return true;
  }
  return false;
}

void updateDisplay() {  // visual feedback to user
  sp.fillSprite(TFT_BLACK);
  static const uint16_t colors[] = { TFT_YELLOW, TFT_GREEN, TFT_RED };
  if (p1.monthlyPeak < 2500) sp.setTextColor(TFT_WHITE);  // assign colors according to monthly peak
  else if (p1.monthlyPeak < 3500) sp.setTextColor(TFT_YELLOW);
  else if (p1.monthlyPeak < 4500) sp.setTextColor(TFT_ORANGE);
  else sp.setTextColor(TFT_RED);
  char peakBuffer[10];
  snprintf(peakBuffer, sizeof(peakBuffer), "%u", p1.monthlyPeak);
  sp.drawRightString(peakBuffer, spriteWidth - 2, 0, 4);  // monthly peak
  if (p1.activeTariff > 0 && p1.activeTariff <= 3) {      // day/night rate information
    static const char* messages[] = { "Watt", "Watt", "Conn ERR" };
    sp.setTextColor(colors[p1.activeTariff - 1]);  // using arrays is much more compact than if..else or switch..case loops
    sp.drawString(messages[p1.activeTariff - 1], spriteWidth / 5, spriteHeight / 5, 4);
  }
  sp.setTextColor(mapPowerToColor(p1.activePowerW));
  char powerBuffer[10];
  snprintf(powerBuffer, sizeof(powerBuffer), "%d", p1.activePowerW);
  sp.drawRightString(powerBuffer, spriteWidth, spriteHeight * 0.44, 8);  // total power usage
#if USER_SETUP_ID != 25                                                  // electricity icon not for T-Display (too small)
  sp.fillTriangle(20, 110, 40, 110, 10, 150, colors[p1.activeTariff - 1]);
  sp.fillTriangle(20, 120, 0, 120, 30, 80, colors[p1.activeTariff - 1]);
#endif
  for (int i = 0; i < numSockets; i++) {
    if (strcmp(sockets[i].hostNameOrIP, defaultIP) != 0) {  // only for registered sockets
      displaySocketInfo(i, sockets[i].hostNameOrIP, sockets[i].power, sockets[i].lastRequestSuccess);
      int offDelaySec = sockets[i].offDelay / 1000;
      char delayBuffer[5];
      snprintf(delayBuffer, sizeof(delayBuffer), "%d", offDelaySec);
      if (allowAutoControl) sp.drawRightString(delayBuffer, spriteWidth / 1.7, spriteHeight / 6.4 + (i * textLineSpacing), 2);
    }
  }
  if (autoControlEnabled && hasRegisteredSockets) {  // show auto control information
    sp.setTextColor(TFT_GREEN);
    sp.drawString(F("Auto ctrl"), 2, 0, 4);
    sp.drawString(F("Delay"), spriteWidth / 2.1, -3, 2);
    sp.drawString(F("seconds"), spriteWidth / 2.1, 8, 2);
  }
  sp.pushSprite(0, 0);
}

void shortenHostname(const char* hostname, char* result, size_t resultSize) {
  size_t startPos = 0;
  size_t length;
  const char* dashPtr = strchr(hostname, '-');  // extract part between '-' and '.', store result in buffer
  const char* dotPtr = strchr(hostname, '.');
  if (dashPtr != NULL && dotPtr != NULL && dotPtr > dashPtr) {
    startPos = dashPtr - hostname + 1;
    length = dotPtr - dashPtr - 1;
    if (length > resultSize - 1) length = resultSize - 1;
  } else length = strnlen(hostname, resultSize - 1);
  strncpy(result, hostname + startPos, length);
  result[length] = '\0';
}

void displaySocketInfo(int index, const char* ipAddressOrName, int activePowerW, bool success) {
  uint16_t textColor;
  if (success) textColor = (activePowerW > 0) ? TFT_CYAN : TFT_DARKGREY;
  else if (sockets[index].useExtendedInterval) textColor = TFT_MAGENTA;
  else textColor = TFT_RED;
  if (autoControlEnabled && sockets[index].autoControlEnabled && textColor == TFT_DARKGREY) textColor = TFT_BROWN;
  char shortNameBuffer[MAX_NAME_LENGTH];
  shortenHostname(ipAddressOrName, shortNameBuffer, sizeof(shortNameBuffer));
  sp.setTextColor(textColor);  // next line: use socket name if available, otherwise use shortened hostname
  const char* displayName = (strlen(sockets[index].name) > 0) ? sockets[index].name : shortNameBuffer;
  sp.drawRightString(displayName, spriteWidth, spriteHeight / 6.4 + (index * textLineSpacing), 2);
  char powerBuffer[10];
  snprintf(powerBuffer, sizeof(powerBuffer), "%d", activePowerW);
  sp.drawRightString(powerBuffer, spriteWidth / 7.5, spriteHeight / 6.4 + (index * textLineSpacing), 2);
  if (tft.height() > 170) sp.setTextColor(TFT_BLACK, textColor);
}

void checkHttpFailureAndRestart() {  // check every 10 seconds; restart ESP32 if P1 meter JSON-failure for 2 minutes
  static unsigned long restartTimestamp = 0, lastCheck = 0;
  if (millis() - lastCheck <= 10000) return;
  lastCheck = millis();
  if (millis() - p1.lastUpdate <= maxHttpFailureDuration) {
    restartTimestamp = 0;
    return;
  }
  if (restartTimestamp == 0) {
    for (int i = 0; i < numSockets; i++) setSocketPowerState(i, false);  // switch all sockets off
    restartTimestamp = millis();
  }
  if (millis() - restartTimestamp > RESTART_DELAY_MS) ESP.restart();
}

void startWiFiAccessPoint() {
  showMessageNoConnection();
  WiFi.disconnect(true);
  WiFi.scanDelete();
  WiFi.mode(WIFI_MODE_AP);
  WiFi.softAP("Lily", "");
  int numNetworks = WiFi.scanNetworks();
  availableSSIDs.clear();
  for (int i = 0; i < numNetworks; i++) availableSSIDs.push_back(WiFi.SSID(i));
  server.on("/", serveWiFiSetupPage);
  server.on("/setting", processWiFiSettingsSubmission);
  server.begin();
  unsigned long startTime = millis();
  const unsigned long maxDuration = 1800000;  // 30 minutes in milliseconds
  for (;;) {                                  // intentional infinite loop
    server.handleClient();
    if (millis() - startTime > maxDuration) ESP.restart();  // restart ESP32 after 30 minutes in case of e.g. WiFi problems
    delay(10);                                              // small delay to avoid watchdog timer problems
  }
}

void connectToWiFi() {
  constexpr uint8_t wifiMaxAttempts = 50, wifiRetryDelay = 160;
  WiFi.mode(WIFI_MODE_STA);
  flash.begin("login_data", true);
  String wifiSsid = flash.getString("ssid", "");
  String wifiPassword = flash.getString("pasw", "");
  flash.end();
  if (wifiSsid.isEmpty()) {  // SSID is empty, immediately launch the web interface
    startWiFiAccessPoint();
    return;
  }
  WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
  tft.fillScreen(TFT_BLACK);
  tft.drawCentreString("Connecting to", spriteWidth / 2, spriteHeight / 2 - 10, 2);
  tft.drawCentreString(wifiSsid.c_str(), spriteWidth / 2, spriteHeight / 2 + 20, 4);
  for (uint8_t i = 0; i < wifiMaxAttempts; ++i) {
    if (WiFi.isConnected()) {
      WiFi.setAutoReconnect(true);
      WiFi.persistent(true);
      tft.fillScreen(TFT_BLACK);
      tft.drawCentreString("Connected", spriteWidth / 2, spriteHeight / 2 - 10, 4);
      tft.drawCentreString(p1.hostnameOrIp, spriteWidth / 2, spriteHeight / 2 + 20, 2);
      unsigned long messageStartTime = millis();
      while (millis() - messageStartTime < 2000) delay(10);
      return;
    }
    delay(wifiRetryDelay);
  }
  startWiFiAccessPoint();  // if connect fails, launch web interface
}

void serveWiFiSetupPage() {
  String webText = F("<!DOCTYPE HTML><html lang='en'><head><meta charset='UTF-8'><title>WiFi Setup</title>");
  webText += F("<meta name='viewport' content='width=device-width, initial-scale=1.0'><style>");
  webText += F("body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 0; text-align: center; }");
  webText += F(".container { max-width: 440px; background: white; padding: 20px; margin: 20px auto; border-radius: 10px;");
  webText += F("box-shadow: 0 0 10px rgba(0,0,0,0.1); } h1 { font-size: 24px; color: #333; margin-bottom: 10px; }");
  webText += F("p { font-size: 16px; color: #555; } label { text-align: left; display: block; margin-top: 12px; color: #333; }");
  webText += F("input, select, button { width: 100%; padding: 10px; margin-top: 4px; border-radius: 5px;");
  webText += F("border: 1px solid #ccc; font-size: 16px; box-sizing: border-box; } .socket-fieldset { background: #f5fff5; }");
  webText += F("input[type=submit], button { background: #007BFF; color: white; border: none; cursor: pointer; }");
  webText += F("input[type=submit]:hover, button:hover { background: #0056b3; }");
  webText += F("fieldset { border: 2px solid #007BFF; border-radius: 10px; padding: 16px; margin-top: 20px; background: #f0f8ff;");
  webText += F("box-shadow: inset 0 0 5px rgba(0,123,255,0.1); } legend { font-weight: bold; color: white; background: ");
  webText += F("#006400; padding: 4px 10px; border-radius: 5px; font-size: 16px; }");
  webText += F("#ssid-buttons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 16px; }");
  webText += F("#ssid-buttons button { width: auto; padding: 8px 14px; font-size: 14px; }</style></head><body>");
  webText += F("<div class='container'><h1>WiFi Setup</h1><p>Select a network or enter details below.</p>");
  webText += F("<div id='ssid-buttons'>");
  for (const String& ssid : availableSSIDs) {
    webText += "<button onclick=\"document.getElementsByName('ssid')[0].value='" + ssid + "'\">" + ssid + "</button>";
  }
  webText += F("</div><form method='get' action='setting'>");
  webText += F("<fieldset><legend>WiFi Settings</legend>");
  webText += F("<label for='ssid'><b>SSID:</b></label><input type='text' id='ssid' name='ssid' required>");
  webText += F("<label for='pass'><b>Password: (leave empty if unchanged)</b></label>");
  webText += F("<input type='password' id='pass' name='pass'></fieldset>");
  webText += F("<fieldset><legend>P1 Meter</legend>");
  webText += F("<label for='ip_P1'><b>Hostname (or IP Address):</b></label>");
  webText += F("<small>Leave empty for automatic scan on restart</small><br>");
  webText += "<input type='text' id='ip_P1' name='ip_P1' value='" + String(p1.hostnameOrIp) + "'></fieldset>";
  if (p1.hostnameOrIp[0] != '\0') {  // optional Socket settings
    webText += F("<fieldset><legend>Socket Settings</legend>");
    for (int i = 0; i < numSockets; i++) {
      webText += "<fieldset class='socket-fieldset'><legend>Socket " + String(i + 1) + "</legend>";
      // Hostname/IP
      webText += "<label for='ip_s" + String(i + 1) + "'><b>Hostname (or IP Address):</b></label>";
      webText += "<input type='text' id='ip_s" + String(i + 1) + "' name='ip_s" + String(i + 1) + "' value='" + String(sockets[i].hostNameOrIP) + "'>";
      webText += "<small style='color:red;'>If unused, set to 192.168.0.0 or leave empty</small><br>";
      // Custom name
      webText += "<label for='name_s" + String(i + 1) + "'><b>Custom Name:</b></label>";
      webText += "<input type='text' id='name_s" + String(i + 1) + "' name='name_s" + String(i + 1) + "' value='" + String(sockets[i].name) + "'><br>";
      // Autocontrol enable
      webText += "<label for='autoControl_s" + String(i + 1) + "'><b>Allow autocontrol for this socket:</b>";
      webText += "<input type='checkbox' style='margin-left:10px; width:auto;' id='autoControl_s" + String(i + 1) + "' ";
      webText += "name='autoControl_s" + String(i + 1) + "' value='1'";
      webText += sockets[i].autoControlEnabled ? " checked>" : ">";
      webText += "</label><small>If checked, the module can control this socket</small><br><br>";
      // Switch ON threshold
      webText += "<label for='switchOn_s" + String(i + 1) + "'><b>Switch ON Threshold (Watt):</b></label>";
      webText += "<input type='number' id='switchOn_s" + String(i + 1) + "' name='switchOn_s" + String(i + 1) + "' value='";
      webText += String(sockets[i].switchOnWatt) + "' min='0' ";
      webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
      webText += "<small>Switch ON this socket if power generation exceeds this value</small><br>";
      // Switch OFF threshold
      webText += "<label for='switchOff_s" + String(i + 1) + "'><b>Switch OFF Threshold (Watt):</b></label>";
      webText += "<input type='number' id='switchOff_s" + String(i + 1) + "' name='switchOff_s" + String(i + 1) + "' value='";
      webText += String(sockets[i].switchOffWatt) + "' min='0' ";
      webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
      webText += "<small>Switch OFF this socket if power generation drops below this value</small><br>";
      // Off delay
      webText += "<label for='offDelay_s" + String(i + 1) + "'><b>Autocontrol Switch OFF Delay (seconds):</b></label>";
      webText += "<input type='number' id='offDelay_s" + String(i + 1) + "' name='offDelay_s" + String(i + 1) + "' value='";
      webText += String(sockets[i].offDelay / 1000) + "' min='0' ";
      webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
      webText += "</fieldset>";
    }
    webText += F("</fieldset>");
  }
  webText += F("<fieldset><legend>Updates & Settings</legend>");
  webText += F("<label for='p1Upd'><b>P1 Meter Update Interval (millisec):</b></label>");
  webText += "<input type='number' id='p1Upd' name='p1Upd' value='" + String(p1.updateInterval) + "' min='0' ";
  webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
  webText += F("<label for='scktUpd'><b>Socket Update Interval (millisec):</b></label>");
  webText += "<input type='number' id='scktUpd' name='scktUpd' value='" + String(socketUpdateInterval) + "' min='0' ";
  webText += "onkeypress='return (event.charCode !=8 && event.charCode ==0 || (event.charCode >= 48 && event.charCode <= 57))'>";
  webText += F("<label for='autoControl'><b>Allow automatic socket control:</b>");
  webText += "<input type='checkbox' style='margin-left:10px; width:auto;' id='autoControl' name='autoControl' value='1'";
  webText += autoControlEnabled ? " checked>" : ">";
  webText += "</label><small>If checked, the module can switch sockets</small>";
  webText += F("</fieldset>");
  webText += F("<br><input type='submit' value='Save'></form></div></body></html>");
  server.send(200, "text/html", webText);
}

void processWiFiSettingsSubmission() {
  String newSsid = server.arg("ssid");
  String newPassword = server.arg("pass");
  String ipAddr = server.arg("ip_P1");
  uint16_t newP1Interval = server.arg("p1Upd").toInt();
  uint16_t newSocketInterval = server.arg("scktUpd").toInt();
  autoControlEnabled = server.hasArg("autoControl");
  for (int i = 0; i < numSockets; i++) {  // === read all socket fields on the form ===
    String key;
    key = "ip_s" + String(i + 1);
    strncpy(sockets[i].hostNameOrIP, server.arg(key).c_str(), sizeof(sockets[i].hostNameOrIP) - 1);
    sockets[i].hostNameOrIP[sizeof(sockets[i].hostNameOrIP) - 1] = '\0';
    key = "name_s" + String(i + 1);
    server.arg(key).toCharArray(sockets[i].name, sizeof(sockets[i].name));
    key = "autoControl_s" + String(i + 1);
    sockets[i].autoControlEnabled = server.hasArg(key);
    key = "offDelay_s" + String(i + 1);
    sockets[i].offDelay = server.arg(key).toInt() * 1000;  // to millisec
    key = "switchOn_s" + String(i + 1);
    sockets[i].switchOnWatt = server.arg(key).toInt();
    key = "switchOff_s" + String(i + 1);
    sockets[i].switchOffWatt = server.arg(key).toInt();
  }
  if (newSsid.length() > 0) {  // === save to flash ===
    flash.begin("login_data", false);
    flash.putString("ssid", newSsid);
    if (newPassword != "") flash.putString("pasw", newPassword);
    flash.end();
    flash.begin("P1-app", false);
    flash.putString("ipM", ipAddr);
    if (newP1Interval != 0) flash.putUInt("p1Interval", newP1Interval);
    if (newSocketInterval > 0) flash.putUInt("socketInterval", newSocketInterval);
    for (int i = 0; i < numSockets; i++) {
      String key;
      key = "ipS" + String(i + 1);
      flash.putString(key.c_str(), sockets[i].hostNameOrIP);
      key = "nameS" + String(i + 1);
      if (strlen(sockets[i].name) > 0) flash.putString(key.c_str(), sockets[i].name);
      key = "autoControlS" + String(i + 1);
      flash.putBool(key.c_str(), sockets[i].autoControlEnabled);
      key = "offDelayS" + String(i + 1);
      flash.putUInt(key.c_str(), sockets[i].offDelay);
      key = "switchOnS" + String(i + 1);
      flash.putUInt(key.c_str(), sockets[i].switchOnWatt);
      key = "switchOffS" + String(i + 1);
      flash.putUInt(key.c_str(), sockets[i].switchOffWatt);
    }
    flash.putBool("autoControl", autoControlEnabled);
    flash.end();
  }
  // === Reboot notification ===
  String webText = F(
    "<!DOCTYPE HTML><html lang='en'><head><title>Setup</title>"
    "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
    "<style>*{font-family:Arial,Helvetica,sans-serif;font-size:45px;font-weight:600;margin:0;text-align:center;}"
    "@keyframes op_en_neer{0%{height:0px;}50%{height:40px;}100%{height:0px;}}"
    ".opneer{margin:auto;text-align:center;animation:op_en_neer 2s infinite;}</style></head>"
    "<body><div class='opneer'></div>ESP will reboot<br>Close this window</body></html>");
  server.send(200, "text/html", webText);
  delay(500);
  ESP.restart();
}

8. Extra's en uitbreidingen

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

9. Vereenvoudigde versie

De hierboven weergegeven sketch werkt perfect zonder dat je sockets instelt. Moest je absoluut een vereenvoudigde versie van de sketch wensen die enkel de P1-meter uitleest, je vindt die hier.

↩ Meer projecten en sketches

© johanok@gmail.com