Controlling YL-UV3-2406 with RS485 with ESP32/MAX485 (grid tied micro-inverter 1200W 55-90V 160-260Vac)

Hi—sorry for my English, and I’m not sure this is the right category.
I’ve been experimenting with controlling the YL-UV3-2406 inverter (with the limiter port) (lcd firmware: STC08-2403 / cpu ver-3.1-2403). the blue one from chinese famous website “TINGEN GTN”

Below is an Arduino/ESP32 sketch and notes for simulating the limiter over the RS485 port using an ESP32 + MAX485 transceiver.

Mods: please feel free to move or edit the post if needed.
In my tests the program works from 0 to ~900 W.

/*
===============================================================================
TECHNICAL NOTICE + CODE (ESP32 + MAX485)
Control a YL-UV3-2406 inverter via “Limiter” RS485 — 0..~900 W
===============================================================================

1) PURPOSE
Reverse-engineered, practical framing to set the inverter power setpoint via the
Limiter RS485 port using an ESP32 and a MAX485. Includes wiring, frame format,
checksum rule, command mapping (0x22 / 0x21), ready-to-use sketch, and notes.

2) HARDWARE
- ESP32 (e.g., WROOM-32 DevKitC)
- RS485 transceiver MAX485 (or SN75176 equivalent)
- Twisted pair for RS485 A/B, 120 Ω termination if needed
- Stable power (ESP32 5V USB; MAX485 VCC per module, 3V3/5V)
- Common ground between ESP32/MAX485/inverter

3) WIRING (TX to inverter)
ESP32            ->   MAX485                 ->   YL-UV3-2406 (Limiter RS485)
------------------------------------------------------------------------------
GPIO17 (TX2)     ->   DI
GPIO16 (RX2)     ->   RO (optional; not used here)
GPIO4            ->   DE & RE (bridged together)
3V3 or 5V        ->   VCC (per module)
GND              ->   GND                    ->   GND (common)
                   A  --------------------------->   A (RS485+)
                   B  --------------------------->   B (RS485−)

DE/RE: HIGH=TX, LOW=RX. If no reaction, try swapping A/B.
Line: 4800 baud, 8N1. Console (USB): 115200 baud.

4) FRAME FORMAT (8 bytes)
[0]  [1]  [2]  [3]  [4]  [5]  [6]  [7]
 24   56   00  CMD   G    X    M    CS
- Prefix: 24 56 00
- CMD: 0x22 → direct 0..255 W   |   0x21 → tiered >~255 W
- G:   tier/gain (depends on CMD)
- X:   value (depends on CMD/G)
- M:   0x80 (observed)
- CS:  checksum (see #5)

5) CHECKSUM RULE (MANDATORY)
Sum of all bytes modulo 256 must equal 0x23.
CS = (0x23 − ((byte0 + … + byte6) & 0xFF)) & 0xFF

Example (0x22, 100 W): 24 56 00 22 00 64 80 A3

6) COMMANDS & MAPPING (confirmed by measurements)
A) CMD 0x22 — direct 0..255 W
   24 56 00 22 00 [PW] 80 [CS]
   Examples: 0W→… 00 80 07 | 25W→… 19 80 EE | 50W→… 32 80 D5
             100W→… 64 80 A3 | 150W→… 96 80 71 | 200W→… C8 80 3F | 255W→… FF 80 08

B) CMD 0x21 — tiered ranges for >~255 W
   24 56 00 21 [G] [X] 80 [CS]
   Observed:
   - G=0x01 → ~256..~499 W, linear:      W ≈ 256 + 0.93 * X
   - G=0x02 → ~500..~640 W, base≈500,    slope≈1.00 W/step
   - G=0x03 → ~650..~850 W, base≈750,    slope≈1.20 W/step
   - G=0x04 → ~860..~900 W, base≈880,    slope≈1.00 W/step
   - G=0x05 → saturates ≈900 W
   Example (300 W): X≈(300−256)/0.93≈47 → 24 56 00 21 01 2C 80 DB

Notes: Display varies slightly with mains (e.g., 226–233 V). For 300–499 W keep G=0x01.
Above ~900 W a cap is observed.

7) SAFETY / DISCLAIMER
Use at your own risk. Ensure electrical safety and ratings compliance.
This information is provided for interoperability. Trademarks remain with owners.

===============================================================================
CODE (Arduino / ESP32)
Commands (serial @115200):
  p <watts 0..1000>   -> set target power
  g <gain 1..5> <x>   -> send CMD 0x21 directly (debug)
  raw <hex bytes...>  -> send raw 8-byte frame (auto checksum if length=8)
  @<ms>               -> period
  start | stop | once
===============================================================================
*/

#define TXD2 17
#define MAX485_DE_RE 4
#define BAUD_RS485 4800

// Empirical calibration (based on field measurements)
static const float BASE_G1  = 256.0f;  // CMD 0x21, G=1 — W @ X=0
static const float SLOPE_G1 = 0.93f;   // CMD 0x21, G=1 — W/step (~256..499)

static const float BASE_G2  = 500.0f;  // ~500..~640
static const float SLOPE_G2 = 1.00f;

static const float BASE_G3  = 750.0f;  // ~650..~850
static const float SLOPE_G3 = 1.20f;

static const float BASE_G4  = 880.0f;  // ~860..~900
static const float SLOPE_G4 = 1.00f;

// G=5 ≈ cap ~900 W (not effective)
static const float MAX_G5   = 900.0f;

uint8_t frame[64] = {0x24,0x56,0x00,0x22,0x00,0x00,0x80,0x07};
size_t  frameLen = 8;
unsigned long periodMs = 500;
bool txEnabled = true;

void setDriver(bool tx){ pinMode(MAX485_DE_RE, OUTPUT); digitalWrite(MAX485_DE_RE, tx?HIGH:LOW); }

void printHex(const uint8_t* b, size_t n){
  for(size_t i=0;i<n;i++){ if(b[i]<0x10) Serial.print("0"); Serial.print(b[i],HEX); if(i+1<n) Serial.print(" "); }
}

uint8_t cs23(const uint8_t* b, size_t nNoCS){
  uint16_t s=0; for(size_t i=0;i<nNoCS;i++) s+=b[i];
  return (uint8_t)((0x23 - (s & 0xFF)) & 0xFF);
}

void sendFrame(){
  setDriver(true);
  Serial2.write(frame,frameLen);
  Serial2.flush();
  setDriver(false);
  Serial.print("TX "); printHex(frame,frameLen); Serial.println();
}

// CMD 0x22: direct 0..255 W  -> 24 56 00 22 00 [PW] 80 [CS]
void buildCmd22(uint8_t watts){
  frame[0]=0x24; frame[1]=0x56; frame[2]=0x00; frame[3]=0x22;
  frame[4]=0x00; frame[5]=watts; frame[6]=0x80;
  frame[7]=cs23(frame,7); frameLen=8;
}

// CMD 0x21: tiers (>~255 W) -> 24 56 00 21 [G] [X] 80 [CS]
void buildCmd21(uint8_t g, uint8_t x){
  frame[0]=0x24; frame[1]=0x56; frame[2]=0x00; frame[3]=0x21;
  frame[4]=g;    frame[5]=x;    frame[6]=0x80;
  frame[7]=cs23(frame,7); frameLen=8;
}

// Map W -> frame (0..~900 W)
void buildPower(uint16_t W){
  if (W <= 255) { buildCmd22((uint8_t)W); return; }

  if (W <= 499) { // 0x21 G=1 (linear)
    float xf = (W - BASE_G1) / SLOPE_G1;
    if (xf < 0) xf = 0; if (xf > 255) xf = 255;
    buildCmd21(1, (uint8_t)lroundf(xf));
    return;
  }

  if (W < 650){ // 0x21 G=2
    float xf = (W - BASE_G2) / SLOPE_G2;
    if (xf < 0) xf = 0; if (xf > 255) xf = 255;
    buildCmd21(2, (uint8_t)lroundf(xf));
    return;
  }

  if (W < 860){ // 0x21 G=3
    float xf = (W - BASE_G3) / SLOPE_G3;
    if (xf < 0) xf = 0; if (xf > 255) xf = 255;
    buildCmd21(3, (uint8_t)lroundf(xf));
    return;
  }

  if (W <= 900){ // 0x21 G=4
    float xf = (W - BASE_G4) / SLOPE_G4;
    if (xf < 0) xf = 0; if (xf > 255) xf = 255;
    buildCmd21(4, (uint8_t)lroundf(xf));
    return;
  }

  // >900: observed cap ~900 W (G=5 not very effective)
  (void)W; buildCmd21(5, 0);
}

// ---- Console helpers ----
bool parseHexBytes(const String& line, uint8_t* out, size_t& outLen){
  outLen=0; int i=0, n=line.length();
  while(i<n){
    while(i<n && isspace((int)line[i])) i++;
    if(i>=n) break;
    int j=i; while(j<n && !isspace((int)line[j])) j++;
    String tok=line.substring(i,j); tok.trim();
    if(tok.length()==0) { i=j; continue; }
    if(tok[0]=='#') break;
    char* e=nullptr; long v=strtol(tok.c_str(), &e, 16);
    if(e==tok.c_str() || v<0 || v>255) return false;
    if(outLen>=64) return false;
    out[outLen++]=(uint8_t)v; i=j;
  }
  return outLen>0;
}

void setup(){
  Serial.begin(115200);
  Serial2.begin(BAUD_RS485, SERIAL_8N1, -1, TXD2);
  setDriver(true);
  buildPower(0);
  Serial.println("YL-UV3-2406 RS485 Limiter | p <0..1000> | g <1..5> <x> | raw <hex...> | @<ms> | start | stop | once");
}

void loop(){
  static unsigned long t0=0;
  if(txEnabled && millis()-t0>=periodMs){ t0=millis(); sendFrame(); }

  if(Serial.available()){
    String line=Serial.readStringUntil('\n'); line.trim(); if(!line.length()) return;

    if(line.equalsIgnoreCase("stop")){ txEnabled=false; Serial.println("TX stop."); return; }
    if(line.equalsIgnoreCase("start")){ txEnabled=true;  Serial.println("TX start."); return; }
    if(line.equalsIgnoreCase("once")){ sendFrame(); return; }
    if(line[0]=='@'){ long v=line.substring(1).toInt(); if(v>0){ periodMs=v; Serial.printf("period=%ld ms\n", v);} return; }

    if(line[0]=='p' || line[0]=='P'){
      int sp=line.indexOf(' '); if(sp>0){
        long w=line.substring(sp+1).toInt(); if(w<0) w=0; if(w>1000) w=1000;
        buildPower((uint16_t)w);
        Serial.printf("Target %ld W -> ", w); printHex(frame,frameLen); Serial.println();
        sendFrame();
      }
      return;
    }

    if(line.startsWith("g ") || line.startsWith("G ")){
      int sp1=line.indexOf(' '); if(sp1<0) return;
      int sp2=line.indexOf(' ', sp1+1); if(sp2<0) return;
      int g = line.substring(sp1+1, sp2).toInt();
      int x = line.substring(sp2+1).toInt();
      if(g<1) g=1; if(g>5) g=5; if(x<0) x=0; if(x>255) x=255;
      buildCmd21((uint8_t)g, (uint8_t)x);
      Serial.printf("CMD21 g=%d x=%d -> ", g, x); printHex(frame,frameLen); Serial.println();
      sendFrame(); return;
    }

    if(line.startsWith("raw ") || line.startsWith("RAW ")){
      String hex=line.substring(4); hex.trim();
      uint8_t tmp[64]; size_t n=0;
      if(!parseHexBytes(hex,tmp,n)){ Serial.println("ERR raw parse"); return; }
      if(n==8){ tmp[7]=cs23(tmp,7); }
      memcpy(frame,tmp,n); frameLen=n;
      Serial.print("RAW set -> "); printHex(frame,frameLen); Serial.println();
      sendFrame(); return;
    }

    Serial.println("Unknown command.");
  }
}

Just a quick update for sniffing rs485.
Data are sent by limiter each 500ms
Will update for high power later (>900W) and will compare with a more powerful on grid inverter 2000W (seem to be same constructor, sold by many, will see if the limiter is still on rs485 & same protocol & value) (actual also sold as “y&h 1.2kw limiter”)


/*
===============================================================================
RS485 SNIFFER & SENDER FOR YL-UV3-2406 LIMITER-INVERTER
===============================================================================
Purpose: Passively listen to or send RS485 frames to limiter/inverter.
- Modes: Listen (sniffer), Fixed Power (send power frame periodically), Custom Frame (send hex frame periodically).
- Capture/decode frames with timestamps.
- Log to LittleFS as CSV: timestamp,direction,hex_frame,decoded
- Web interface: mode select, power/hex input, period, download log, clear, format, debug, update (OTA).
- OTA via Arduino IDE and web interface.
- Robust WiFi with reconnection and updated event handling.
- Handle LittleFS corruption with format-on-fail.

Hardware:
- ESP32 WROOM
- MAX485: A/B to bus, RO to GPIO16 (RX), DI to GPIO17 (TX), DE/RE bridged to GPIO4 (control: HIGH=TX, LOW=RX)
- For listen only: Set DE/RE to GND if no sending needed.

Communication:
- RS485: 4800 baud, 8N1
- Frames grouped by inter-byte timeout (>20ms)
- Send periodic in send modes.

WiFi: Configured for ... network.
===============================================================================
*/

#include <WiFi.h>
#include <ESPmDNS.h>
#include <WebServer.h>
#include <ArduinoOTA.h>
#include <LittleFS.h>
#include <HardwareSerial.h>
#include <esp_wifi.h>
#include <Update.h>

// WiFi credentials
#define WIFI_SSID ""
#define WIFI_PASS ""

// Pins and settings
#define RX_PIN 16  // Serial2 RX
#define TX_PIN 17  // Serial2 TX
#define DE_RE_PIN 4  // DE/RE control (HIGH = TX, LOW = RX)
#define BAUD_RS485 4800
#define FRAME_TIMEOUT_MS 20  // Timeout to end a frame
#define LOG_FILE "/capture.csv"

// Modes
enum Mode { MODE_LISTEN, MODE_FIXED_POWER, MODE_CUSTOM_FRAME };
Mode currentMode = MODE_LISTEN;
int fixedPower = 0;  // 0-900 W for fixed mode
String customFrameHex = "24 56 00 22 00 00 80 07";  // Default frame (0W)
unsigned long sendPeriodMs = 500;  // Default period
unsigned long lastSendTime = 0;

// Globals
WebServer server(80);
HardwareSerial SerialRS485(2);  // UART2
String currentFrame = "";
unsigned long lastRxTime = 0;
bool littlefsOK = false;  // Track LittleFS status

// WiFi event handler
void onWiFiEvent(arduino_event_id_t event, arduino_event_info_t info) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("WiFi disconnected. Reconnecting...");
      WiFi.reconnect();
      break;
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("WiFi connected.");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.printf("WiFi IP assigned: %s\n", WiFi.localIP().toString().c_str());
      break;
    default:
      break;
  }
}

// Checksum calculation
uint8_t calculateCS(const uint8_t* bytes, size_t len) {
  uint16_t sum = 0;
  for (size_t i = 0; i < len; i++) sum += bytes[i];
  return (0x23 - (sum & 0xFF)) & 0xFF;
}

// Parse hex string to byte array
bool parseHexToBytes(const String& hexStr, uint8_t* bytes, size_t& len) {
  len = 0;
  String cleaned = hexStr;
  cleaned.replace(" ", "");
  if (cleaned.length() % 2 != 0) return false;
  for (size_t i = 0; i < cleaned.length(); i += 2) {
    if (len >= 64) return false;
    String byteStr = cleaned.substring(i, i + 2);
    bytes[len++] = (uint8_t)strtol(byteStr.c_str(), NULL, 16);
  }
  return true;
}

// Detect and decode frame
String detectAndDecode(const String& hexFrame) {
  uint8_t bytes[64];
  size_t len;
  if (!parseHexToBytes(hexFrame, bytes, len)) return "Invalid hex";

  if (len < 8) return "Short frame (unknown emitter)";

  // Check prefix for known limiter commands
  if (bytes[0] == 0x24 && bytes[1] == 0x56 && bytes[2] == 0x00) {
    uint8_t cmd = bytes[3];
    uint8_t g = bytes[4];
    uint8_t x = bytes[5];
    uint8_t m = bytes[6];
    uint8_t cs = bytes[7];

    // Verify checksum
    uint8_t expectedCS = calculateCS(bytes, 7);
    String csStatus = (cs == expectedCS) ? "Valid CS" : "Invalid CS";

    if (m != 0x80) return "Limiter? Unknown M: " + String(m, HEX) + " (" + csStatus + ")";

    String decoded = "Limiter Command - CMD: " + String(cmd, HEX) + ", G: " + String(g, HEX) + ", X: " + String(x, HEX) + " (" + csStatus + ")";

    // Decode power
    float power = 0.0;
    if (cmd == 0x22) {
      power = x;
      decoded += " | Direct Power: " + String((int)power) + " W";
    } else if (cmd == 0x21) {
      if (g == 0x00 || g == 0x01) {  // Updated for G=0
        power = 256.0 + 0.93 * x;
      } else if (g == 0x02) {
        power = 500.0 + 1.0 * x;
      } else if (g == 0x03) {
        power = 750.0 + 1.2 * x;
      } else if (g == 0x04) {
        power = 880.0 + 1.0 * x;
      } else if (g == 0x05) {
        power = 900.0;
      } else {
        return decoded + " | Unknown G";
      }
      decoded += " | Tiered Power: ~" + String((int)power) + " W";
    } else {
      return decoded + " | Unknown CMD";
    }
    return decoded;
  } else {
    // Unknown prefix: assume from inverter
    if (len == 8) {
      uint8_t expectedCS = calculateCS(bytes, 7);
      String csStatus = (bytes[7] == expectedCS) ? "Valid CS?" : "Invalid CS?";
      return "Inverter? Prefix: " + String(bytes[0], HEX) + " " + String(bytes[1], HEX) + " " + String(bytes[2], HEX) + " (" + csStatus + ")";
    }
    return "Inverter? Unknown format";
  }
}

// Log frame to file
void logFrame(const String& hexFrame, const String& direction) {
  String ts = String(millis() / 1000.0, 3);
  String emitterDecode = detectAndDecode(hexFrame);
  String emitter = emitterDecode.startsWith("Limiter") ? "Limiter" : (emitterDecode.startsWith("Inverter") ? "Inverter" : "Unknown");
  String decoded = emitterDecode.substring(emitter.length() + 1);  // Remove emitter prefix

  if (littlefsOK) {
    File file = LittleFS.open(LOG_FILE, "a");
    if (file) {
      file.printf("%s,%s,%s,%s\n", ts.c_str(), direction.c_str(), hexFrame.c_str(), decoded.c_str());
      file.close();
    } else {
      Serial.println("Failed to open log file for writing");
    }
  } else {
    Serial.println("LittleFS not available, skipping file log");
  }

  // Print to serial
  Serial.printf("%s [%s] %s | Decoded: %s\n", ts.c_str(), direction.c_str(), hexFrame.c_str(), decoded.c_str());
}

// Send frame
void sendFrame(const uint8_t* frameBytes, size_t len) {
  digitalWrite(DE_RE_PIN, HIGH);  // TX mode
  delayMicroseconds(100);
  SerialRS485.write(frameBytes, len);
  SerialRS485.flush();
  delayMicroseconds(100);
  digitalWrite(DE_RE_PIN, LOW);  // RX mode
}

// Build power frame
bool buildPowerFrame(uint16_t W, uint8_t* frame) {
  frame[0] = 0x24; frame[1] = 0x56; frame[2] = 0x00;
  frame[6] = 0x80;
  if (W <= 255) {
    frame[3] = 0x22; frame[4] = 0x00; frame[5] = W;
  } else {
    frame[3] = 0x21;
    float xf;
    if (W <= 499) {
      frame[4] = 0x01; xf = (W - 256.0) / 0.93; 
    } else if (W <= 640) {
      frame[4] = 0x02; xf = (W - 500.0) / 1.0;
    } else if (W <= 850) {
      frame[4] = 0x03; xf = (W - 750.0) / 1.2;
    } else if (W <= 900) {
      frame[4] = 0x04; xf = (W - 880.0) / 1.0;
    } else {
      frame[4] = 0x05; frame[5] = 0x00;
      frame[7] = calculateCS(frame, 7);
      return true;
    }
    if (xf < 0) xf = 0; if (xf > 255) xf = 255;
    frame[5] = (uint8_t)lroundf(xf);
  }
  frame[7] = calculateCS(frame, 7);
  return true;
}

// Web handler for root
void handleRoot() {
  String html = "<html><body><h1>RS485 Sniffer & Sender</h1>";
  html += "<p>LittleFS Status: " + String(littlefsOK ? "OK" : "Failed (serial logging only)") + "</p>";
  html += "<p>Current Mode: " + String(currentMode == MODE_LISTEN ? "Listen" : (currentMode == MODE_FIXED_POWER ? "Fixed Power (" + String(fixedPower) + "W)" : "Custom Frame (" + customFrameHex + ")")) + "</p>";
  html += "<p>Send Period: " + String(sendPeriodMs) + " ms</p>";
  html += "<p><a href='/download'>Download Capture Log (CSV)</a></p>";
  html += "<form action='/clear' method='POST'><input type='submit' value='Clear Log'></form>";
  html += "<form action='/format' method='POST'><input type='submit' value='Format LittleFS' onclick='return confirm(\"This will erase all data. Continue?\")'></form>";
  html += "<p><a href='/update'>Update Firmware</a></p>";
  html += "<p><a href='/debug'>Debug Info</a></p>";
  html += "<h2>Set Mode</h2>";
  html += "<form action='/mode' method='POST'>";
  html += "<select name='mode'>";
  html += "<option value='0'" + String(currentMode == MODE_LISTEN ? " selected" : "") + ">Listen (Sniffer)</option>";
  html += "<option value='1'" + String(currentMode == MODE_FIXED_POWER ? " selected" : "") + ">Fixed Power</option>";
  html += "<option value='2'" + String(currentMode == MODE_CUSTOM_FRAME ? " selected" : "") + ">Custom Frame</option>";
  html += "</select><br>";
  html += "Fixed Power (W): <input type='number' name='power' value='" + String(fixedPower) + "' min='0' max='900'><br>";
  html += "Custom Frame (Hex): <input type='text' name='hex' value='" + customFrameHex + "'><br>";
  html += "Period (ms): <input type='number' name='period' value='" + String(sendPeriodMs) + "' min='100' max='5000'><br>";
  html += "<input type='submit' value='Apply'>";
  html += "</form>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

// Web handler for mode set
void handleMode() {
  if (server.hasArg("mode")) {
    currentMode = (Mode)server.arg("mode").toInt();
  }
  if (server.hasArg("power")) {
    fixedPower = server.arg("power").toInt();
    if (fixedPower < 0) fixedPower = 0;
    if (fixedPower > 900) fixedPower = 900;
  }
  if (server.hasArg("hex")) {
    customFrameHex = server.arg("hex");
  }
  if (server.hasArg("period")) {
    sendPeriodMs = server.arg("period").toInt();
    if (sendPeriodMs < 100) sendPeriodMs = 100;
    if (sendPeriodMs > 5000) sendPeriodMs = 5000;
  }
  server.sendHeader("Location", "/");
  server.send(303);
}

// Web handler for download
void handleDownload() {
  if (littlefsOK && LittleFS.exists(LOG_FILE)) {
    File file = LittleFS.open(LOG_FILE, "r");
    server.streamFile(file, "text/csv");
    file.close();
  } else {
    server.send(404, "text/plain", "File not found or LittleFS not initialized");
  }
}

// Web handler for clear log
void handleClear() {
  if (littlefsOK) {
    LittleFS.remove(LOG_FILE);
    File file = LittleFS.open(LOG_FILE, "w");
    if (file) {
      file.println("timestamp,direction,hex_frame,decoded");
      file.close();
    }
  }
  server.sendHeader("Location", "/");
  server.send(303);
}

// Web handler for format LittleFS
void handleFormat() {
  if (LittleFS.format()) {
    Serial.println("LittleFS formatted successfully");
    littlefsOK = LittleFS.begin();
    if (littlefsOK) {
      File file = LittleFS.open(LOG_FILE, "w");
      if (file) {
        file.println("timestamp,direction,hex_frame,decoded");
        file.close();
        Serial.println("Log file created: " + String(LOG_FILE));
      } else {
        Serial.println("Failed to create log file after format");
        littlefsOK = false;
      }
    } else {
      Serial.println("LittleFS mount failed after format");
    }
  } else {
    Serial.println("LittleFS format failed");
    littlefsOK = false;
  }
  server.sendHeader("Location", "/");
  server.send(303);
}

// Web handler for debug info
void handleDebug() {
  String info = "System Debug Info\n";
  info += "Uptime: " + String(millis() / 1000.0, 3) + " s\n";
  info += "WiFi Status: " + String(WiFi.status() == WL_CONNECTED ? "Connected" : "Disconnected") + "\n";
  info += "IP: " + WiFi.localIP().toString() + "\n";
  info += "MAC: " + WiFi.macAddress() + "\n";
  info += "LittleFS: " + String(littlefsOK ? "OK" : "Failed") + "\n";
  info += "Free Heap: " + String(ESP.getFreeHeap()) + " bytes\n";
  info += "RS485 Mode: " + String(currentMode == MODE_LISTEN ? "Listen" : (currentMode == MODE_FIXED_POWER ? "Fixed Power" : "Custom Frame")) + "\n";
  server.send(200, "text/plain", info);
}

// Web handler for OTA update page
void handleUpdatePage() {
  String html = F("<!doctype html><html><head><meta charset='utf-8'><title>OTA Update</title></head><body>"
                  "<h3>Upload Firmware (.bin)</h3>"
                  "<form method='POST' action='/update' enctype='multipart/form-data'>"
                  "<input type='file' name='firmware' accept='.bin'> "
                  "<input type='submit' value='Upload'>"
                  "</form><p><a href='/'>Back</a></p></body></html>");
  server.send(200, "text/html; charset=utf-8", html);
}

// Web handler for OTA update upload
void handleUpdateUpload() {
  HTTPUpload& up = server.upload();
  static size_t last_progress = 0;

  if (up.status == UPLOAD_FILE_START) {
    Serial.printf("[OTA] Start: %s\n", up.filename.c_str());
    if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
      Update.printError(Serial);
    }
  } else if (up.status == UPLOAD_FILE_WRITE) {
    if (Update.write(up.buf, up.currentSize) != up.currentSize) {
      Update.printError(Serial);
    }
    size_t prog = Update.progress();
    if (prog - last_progress > 64 * 1024) {
      last_progress = prog;
      Serial.printf("[OTA] Progress: %u bytes\n", (unsigned)prog);
    }
  } else if (up.status == UPLOAD_FILE_END) {
    if (Update.end(true)) {
      Serial.printf("[OTA] Success: %u bytes, rebooting...\n", up.totalSize);
    } else {
      Update.printError(Serial);
    }
  } else if (up.status == UPLOAD_FILE_ABORTED) {
    Update.end();
    Serial.println("[OTA] Upload aborted");
  }
}

// Web handler for OTA update completion
void handleUpdateDone() {
  if (Update.hasError()) {
    server.send(500, "text/plain", "Firmware update failed: " + String(Update.errorString()));
  } else {
    server.send(200, "text/plain", "Firmware updated successfully, rebooting...");
    delay(500);
    ESP.restart();
  }
}

// Robust WiFi connection
void connectWiFiOrReboot() {
  WiFi.onEvent(onWiFiEvent);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(true, true);
  delay(200);
  String mac = WiFi.macAddress();
  Serial.printf("MAC=%s\n", mac.c_str());
  WiFi.setHostname("rs485sniffer");
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("WiFi connecting...");
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - t0) < 30000) {
    delay(500);
    Serial.print(".");
  }
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("\nWiFi OK IP=%s MAC=%s\n", WiFi.localIP().toString().c_str(), mac.c_str());
    return;
  }
  Serial.println("\nWiFi failed - restarting");
  delay(1000);
  ESP.restart();
}

void setup() {
  // Serial console
  Serial.begin(115200);
  delay(500);  // Avoid serial noise
  Serial.println("RS485 Sniffer & Sender starting...");

  // WiFi robust
  connectWiFiOrReboot();

  // mDNS
  MDNS.begin("rs485sniffer");

  // OTA (Arduino IDE)
  ArduinoOTA.setHostname("rs485sniffer");
  ArduinoOTA.begin();

  // LittleFS with format-on-fail
  Serial.println("Initializing LittleFS...");
  if (!LittleFS.begin(false)) {  // Try without formatting first
    Serial.println("LittleFS mount failed, attempting format...");
    if (LittleFS.format()) {
      Serial.println("LittleFS formatted successfully");
      if (!LittleFS.begin()) {
        Serial.println("LittleFS mount failed after format");
        littlefsOK = false;
      } else {
        littlefsOK = true;
      }
    } else {
      Serial.println("LittleFS format failed");
      littlefsOK = false;
    }
  } else {
    littlefsOK = true;
  }

  // Create log file if LittleFS is OK
  if (littlefsOK && !LittleFS.exists(LOG_FILE)) {
    File file = LittleFS.open(LOG_FILE, "w");
    if (file) {
      file.println("timestamp,direction,hex_frame,decoded");
      file.close();
      Serial.println("Log file created: " + String(LOG_FILE));
    } else {
      Serial.println("Failed to create log file");
      littlefsOK = false;
    }
  }

  // DE/RE pin
  pinMode(DE_RE_PIN, OUTPUT);
  digitalWrite(DE_RE_PIN, LOW);  // Default RX mode

  // RS485
  SerialRS485.begin(BAUD_RS485, SERIAL_8N1, RX_PIN, TX_PIN);

  // Web server
  server.on("/", handleRoot);
  server.on("/mode", HTTP_POST, handleMode);
  server.on("/download", handleDownload);
  server.on("/clear", HTTP_POST, handleClear);
  server.on("/format", HTTP_POST, handleFormat);
  server.on("/debug", handleDebug);
  server.on("/update", HTTP_GET, handleUpdatePage);
  server.on("/update", HTTP_POST, handleUpdateDone, handleUpdateUpload);
  server.begin();
  Serial.println("HTTP server started. Access at http://" + WiFi.localIP().toString());
}

void loop() {
  ArduinoOTA.handle();
  server.handleClient();

  // WiFi reconnection check
  static unsigned long lastWiFiCheck = 0;
  if (millis() - lastWiFiCheck > 5000) {
    lastWiFiCheck = millis();
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("WiFi lost, reconnecting...");
      WiFi.reconnect();
    }
  }

  // Send logic
  if (currentMode != MODE_LISTEN && millis() - lastSendTime >= sendPeriodMs) {
    uint8_t frameBytes[64];
    size_t len;
    String hexToSend;
    if (currentMode == MODE_FIXED_POWER) {
      buildPowerFrame(fixedPower, frameBytes);
      hexToSend = "";
      for (size_t i = 0; i < 8; i++) {
        if (i > 0) hexToSend += " ";
        if (frameBytes[i] < 0x10) hexToSend += "0";
        hexToSend += String(frameBytes[i], HEX);
      }
      len = 8;
    } else {  // Custom frame
      if (!parseHexToBytes(customFrameHex, frameBytes, len)) {
        Serial.println("Invalid custom frame hex");
        return;
      }
      hexToSend = customFrameHex;
    }
    sendFrame(frameBytes, len);
    logFrame(hexToSend, "Sent");
    lastSendTime = millis();
  }

  // Capture logic (only in listen mode)
  if (currentMode == MODE_LISTEN && SerialRS485.available()) {
    uint8_t b = SerialRS485.read();
    if (currentFrame.length() > 0) currentFrame += " ";
    if (b < 0x10) currentFrame += "0";
    currentFrame += String(b, HEX);
    lastRxTime = millis();
  }

  if (currentFrame.length() > 0 && millis() - lastRxTime >= FRAME_TIMEOUT_MS) {
    logFrame(currentFrame, "Received");
    currentFrame = "";
  }
}

A precision:
A is on the left when you are in front of the panel, B is on the right