📡 Monitor VE.Direct con ESP32 e OLED: il progetto definitivo per il tuo impianto solare ⚡

Realizzare un monitor solare professionale, elegante e completamente personalizzato non è mai stato così semplice. In questo articolo ti mostro come ho costruito un sistema completo basato su ESP32, display OLED e protocollo VE.Direct, capace di leggere in tempo reale tutti i dati del regolatore Victron e mostrarli sia su schermo che su una dashboard web moderna e responsive.

Un progetto che unisce elettronica, programmazione e design… e che porta il tuo impianto solare a un livello superiore.

💙 Firmato: TechConnectHub

🔥 Perché questo progetto è speciale

Questo monitor non è un semplice lettore di dati: è un vero e proprio cruscotto intelligente per il tuo impianto fotovoltaico.

Ecco cosa fa:

  • Legge in tempo reale i dati VE.Direct del regolatore Victron
  • Mostra i valori principali su un display OLED 128×64
  • Offre una dashboard web moderna, scura, elegante e aggiornata ogni secondo
  • Funziona con hostname dedicato: `http://victron-monitor.localMostra:
  • Potenza pannelli (W)
  • Tensione pannelli (V)
  • Tensione batteria (V)
  • Corrente (A)
  • Produzione totale (Wh)
  • Produzione giornaliera (Wh)
  • Stato MPPT (Bulk, Absorption, Float…)
  • Numero di serie del regolatore
  • Versione firmware
  • Include una pagina OLED dedicata con il logo TechConnectHub
  • Sincronizzazione perfetta senza perdere pacchetti VE.Direct

Un progetto pensato per essere affidabile, bello da vedere e semplice da installare.

🧩 Hardware necessario

  • ESP32 DevKit (qualsiasi versione con WiFi)
  • Display OLED 128×64 I2C
  • Cavo VE.Direct → UART (TX → GPIO 32)
  • Alimentazione 5V
  • Qualche cavetto Dupont

🛠️ Collegamenti elettrici

  • Victron TX → ESP32 GPIO 32
  • Victron GND → ESP32 GND
  • OLED SDA → ESP32 GPIO 21
  • OLED SCL → ESP32 GPIO 22
  • OLED VCC → 3.3V
  • OLED GND → GND

💻 Il firmware completo

#include <Arduino.h>
#include <U8g2lib.h>
#include <WiFi.h>
#include <WebServer.h>

// -------------------- DISPLAY --------------------
U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

// -------------------- VARIABILI GLOBALI --------------------
float battery_voltage = 0;
float battery_current = 0;
float pv_power = 0;
float pv_voltage = 0;
float yield_total = 0;
float yield_today_real = 0;
String mppt_state = "Off";

String serial_number = "";
String firmware_ver = "";

unsigned long lastUpdate = 0;
unsigned long lastPageSwitch = 0;
int lcd_page = 0;

// -------------------- VE.DIRECT BUFFER --------------------
String veLine = "";
bool packetReady = false;

float new_voltage = 0;
float new_current = 0;
float new_pv_power = 0;
float new_pv_voltage = 0;
float new_yield_total = 0;
float new_yield_today_real = 0;
String new_state = "Off";

String new_serial = "";
String new_fw = "";

// -------------------- WIFI CONFIG --------------------
const char* ssid     = "BB1";
const char* password = "strangeapartament";

// -------------------- WIFI WATCHDOG --------------------
unsigned long lastWiFiCheck = 0;

void wifiWatchdog() {
  bool wifiDead =
      WiFi.status() != WL_CONNECTED ||          // caso 1: disconnessione reale
      WiFi.localIP().toString() == "0.0.0.0" || // caso 2: perdita IP
      !WiFi.isConnected();                      // caso 3: ghost connected

  if (wifiDead) {
    WiFi.disconnect(true);
    delay(200);
    WiFi.begin(ssid, password);
  }
}

// -------------------- WEB SERVER --------------------
WebServer server(80);

// -------------------- HTML PAGE --------------------
const char* htmlPage = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>VE.Direct Monitor</title>
<style>
  body { font-family: Arial; background:#0d1117; color:#e6edf3; padding:20px; }
  h1 { text-align:center; color:#58a6ff; margin-bottom:25px; }
  .section-title { font-size:22px; margin-top:25px; margin-bottom:10px; color:#f0883e; text-align:center; }
  .card { background:#161b22; padding:18px; border-radius:12px; margin-bottom:15px; border:1px solid #30363d; }
  .label { font-size:18px; color:#8b949e; }
  .value { font-size:26px; font-weight:bold; margin-top:5px; }
  .watt { color:#f0883e; }
  .volt { color:#3fb950; }
  .amp  { color:#d29922; }
  .wh   { color:#a371f7; }
  .state { color:#58a6ff; }
  .footer { text-align:center; margin-top:30px; font-size:18px; color:#58a6ff; font-weight:bold; }
</style>
</head>
<body>

<h1>VE.Direct Monitor</h1>

<div class="section-title">Dati in tempo reale</div>

<div class="card"><div class="label">Pannelli</div><div class="value watt" id="pv_power">-- W</div></div>
<div class="card"><div class="label">Tensione Pannelli</div><div class="value volt" id="pv_voltage">-- V</div></div>
<div class="card"><div class="label">Batteria</div><div class="value volt" id="battery_voltage">-- V</div></div>
<div class="card"><div class="label">Corrente</div><div class="value amp" id="battery_current">-- A</div></div>
<div class="card"><div class="label">Produzione Totale</div><div class="value wh" id="yield_total">-- Wh</div></div>
<div class="card"><div class="label">Produzione Giornaliera</div><div class="value wh" id="yield_today_real">-- Wh</div></div>
<div class="card"><div class="label">Stato MPPT</div><div class="value state" id="mppt_state">--</div></div>

<div class="section-title">Informazioni Regolatore</div>

<div class="card"><div class="label">Seriale</div><div class="value state" id="serial_number">--</div></div>
<div class="card"><div class="label">Firmware</div><div class="value state" id="firmware_ver">--</div></div>

<div class="footer">By TechConnectHub</div>

<script>
function update() {
  fetch('/api')
    .then(r => r.json())
    .then(d => {
      document.getElementById('pv_power').innerText        = d.pv_power + " W";
      document.getElementById('pv_voltage').innerText      = d.pv_voltage.toFixed(2) + " V";
      document.getElementById('battery_voltage').innerText = d.battery_voltage.toFixed(2) + " V";
      document.getElementById('battery_current').innerText = d.battery_current.toFixed(2) + " A";
      document.getElementById('yield_total').innerText     = d.yield_total + " Wh";
      document.getElementById('yield_today_real').innerText= d.yield_today_real + " Wh";
      document.getElementById('mppt_state').innerText      = d.mppt_state;
      document.getElementById('serial_number').innerText   = d.serial_number;
      document.getElementById('firmware_ver').innerText    = d.firmware_ver;
    });
}
setInterval(update, 1000);
update();
</script>

</body>
</html>
)rawliteral";

// -------------------- VE.DIRECT PARSER --------------------
String csToState(int cs) {
  switch (cs) {
    case 0: return "Off";
    case 2: return "Fault";
    case 3: return "Bulk";
    case 4: return "Absorption";
    case 5: return "Float";
    case 7: return "Equalize";
    default: return "Unknown";
  }
}

void parseVEDirectLine(const String &line) {
  int sep = line.indexOf('\t');
  if (sep < 0) return;

  String key = line.substring(0, sep);
  String val = line.substring(sep + 1);
  long iv = val.toInt();

  if (key == "V") new_voltage = iv / 1000.0f;
  else if (key == "I") new_current = iv / 1000.0f;
  else if (key == "PPV") new_pv_power = iv;
  else if (key == "VPV") new_pv_voltage = iv / 1000.0f;
  else if (key == "H19") new_yield_total = iv * 10.0f;
  else if (key == "H20") new_yield_today_real = iv * 10.0f;
  else if (key == "CS") new_state = csToState(iv);
  else if (key == "SER#") new_serial = val;
  else if (key == "FW") {
    float fw = iv / 100.0f;
    new_fw = String(fw, 2);
  }
  else if (key == "Checksum") packetReady = true;
}

void readVEDirect() {
  while (Serial2.available()) {
    char c = Serial2.read();
    if (c == '\n') { parseVEDirectLine(veLine); veLine = ""; }
    else if (c != '\r') veLine += c;
  }

  if (packetReady) {
    battery_voltage = new_voltage;
    battery_current = new_current;
    pv_power        = new_pv_power;
    pv_voltage      = new_pv_voltage;
    yield_total     = new_yield_total;
    yield_today_real= new_yield_today_real;
    mppt_state      = new_state;
    serial_number   = new_serial;
    firmware_ver    = new_fw;
    lastUpdate = millis();
    packetReady = false;
  }
}

// -------------------- SETUP --------------------
void setup() {
  Serial.begin(115200);
  Serial2.begin(19200, SERIAL_8N1, 32, 33);

  u8g2.begin();
  u8g2.enableUTF8Print();

  WiFi.mode(WIFI_STA);
  WiFi.setHostname("victron-monitor");
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) { delay(500); }

  server.on("/", []() { server.send(200, "text/html", htmlPage); });

  server.on("/api", []() {
    String json = "{";
    json += "\"pv_power\":" + String(pv_power) + ",";
    json += "\"pv_voltage\":" + String(pv_voltage) + ",";
    json += "\"battery_voltage\":" + String(battery_voltage) + ",";
    json += "\"battery_current\":" + String(battery_current) + ",";
    json += "\"yield_total\":" + String(yield_total) + ",";
    json += "\"yield_today_real\":" + String(yield_today_real) + ",";
    json += "\"serial_number\":\"" + serial_number + "\",";
    json += "\"firmware_ver\":\"" + firmware_ver + "\",";
    json += "\"mppt_state\":\"" + mppt_state + "\"";
    json += "}";
    server.send(200, "application/json", json);
  });

  server.begin();
}

// -------------------- LOOP --------------------
void loop() {
  server.handleClient();
  readVEDirect();

  // WATCHDOG WIFI DEFINITIVO
  if (millis() - lastWiFiCheck > 5000) {
    wifiWatchdog();
    lastWiFiCheck = millis();
  }

  if (millis() - lastPageSwitch > 4000) {
    lcd_page = (lcd_page + 1) % 10;
    lastPageSwitch = millis();
  }

  u8g2.firstPage();
  do {

    if (millis() - lastUpdate > 15000) {
      u8g2.setFont(u8g2_font_ncenB12_tr);
      u8g2.drawStr(0, 30, "No VE.Direct...");
    } else {

      switch (lcd_page) {

        case 0:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Pannelli");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(pv_power); u8g2.print(" W");
          break;

        case 1:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "V Pannelli");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(pv_voltage, 2); u8g2.print(" V");
          break;

        case 2:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Batteria");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(battery_voltage, 2); u8g2.print(" V");
          break;

        case 3:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Corrente");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(battery_current, 2); u8g2.print(" A");
          break;

        case 4:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Totale");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(yield_total); u8g2.print(" Wh");
          break;

        case 5:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Giorno");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(yield_today_real); u8g2.print(" Wh");
          break;

        case 6:
          u8g2.setFont(u8g2_font_ncenB12_tr);
          u8g2.drawStr(0, 16, "SER#");
          u8g2.setFont(u8g2_font_7x14B_tr);
          u8g2.setCursor(0, 55);
          u8g2.print(serial_number);
          break;

        case 7:
          u8g2.setFont(u8g2_font_ncenB12_tr);
          u8g2.drawStr(0, 16, "FW");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(firmware_ver);
          break;

        case 8:
          u8g2.setFont(u8g2_font_7x14B_tr);
          u8g2.drawStr(0, 40, "TechConnectHub");
          break;

        case 9:
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "Stato MPPT");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          u8g2.print(mppt_state);
          break;
      }
    }
  } while (u8g2.nextPage());
}
  • font migliorati
  • pagina web divisa in sezioni
  • scritta TechConnectHub su OLED e web
  • parsing VE.Direct completo
  • sincronizzazione perfetto

🌐 Dashboard web moderna e responsive

La dashboard web è stata progettata per essere:

  • leggibile anche da smartphone
  • elegante grazie al tema scuro
  • aggiornata ogni secondo
  • divisa in due sezioni:
  • Dati in tempo reale
  • Informazioni del regolatore

Il footer mostra con orgoglio:

By TechConnectHub

🖥️ Display OLED: semplice, chiaro, professionale

Il display OLED mostra i dati in rotazione ogni 4 secondi:

  • Potenza pannelli
  • Tensione pannelli
  • Tensione batteria
  • Corrente
  • Produzione totale
  • Produzione giornaliera
  • Numero di serie
  • Firmware
  • Logo TechConnectHub

I font sono stati calibrati per essere:

  • grandi dove serve (es. 1.68)
  • compatti dove necessario (SER#)
  • sempre leggibili

🚀 Conclusione

Questo progetto trasforma un semplice ESP32 in un monitor solare professionale, elegante e completamente personalizzato.

È perfetto per chi vuole:

  • monitorare il proprio impianto solare in tempo reale
  • avere una dashboard moderna e accessibile da qualsiasi dispositivo
  • integrare estetica e funzionalità
  • costruire un prodotto degno di essere venduto

Un progetto che unisce tecnica, design e passione.

Firmato con orgoglio:

💙 TechConnectHub

Monitorare un Victron SmartSolar con ESP32, ESPHome e Display OLED SSD1306

Integrare un regolatore Victron SmartSolar con un ESP32 permette di ottenere un monitoraggio locale, immediato e completamente personalizzabile dei parametri principali del proprio impianto fotovoltaico. Questo documento descrive un sistema compatto che legge via BLE i dati del Victron, li elabora con ESPHome e li visualizza su un display OLED SSD1306.

Perché usare un ESP32 con ESPHome

L’ESP32 è un microcontrollore economico, potente e dotato di Bluetooth Low Energy. ESPHome semplifica la configurazione e consente di:

  • Leggere i dati via BLE dal regolatore Victron.
  • Inviarli a Home Assistant.
  • Visualizzarli su un display locale.
  • Creare logiche personalizzate.
  • Aggiornare il firmware OTA.

Il display SSD1306

Il display OLED SSD1306 (128×64 pixel) è ideale per visualizzare informazioni essenziali:

  • Consumo ridotto.
  • Ottima leggibilità.
  • Collegamento semplice tramite I2C.
  • Supporto nativo in ESPHome.

Nel progetto vengono visualizzati ciclicamente:

  1. PV Power (W)
  2. Battery Voltage (V)
  3. Battery Current (A)
  4. Stato MPPT

Collegamenti hardware

SSD1306 → ESP32
SDA     → GPIO 21
SCL     → GPIO 22
VCC     → 3.3V o 5V (in base al modulo)
GND     → GND

Codice completo ESPHome

esphome:
  name: esphome-web-660f74
  friendly_name: Victron 35
  min_version: 2025.11.0
  name_add_mac_suffix: false

esp32:
  variant: esp32
  framework:
    type: esp-idf

logger:
  level: INFO

api:

ota:
  - platform: esphome

wifi:
  networks:
    - ssid: xxx
      password: xxxx

esp32_ble_tracker:

external_components:
  - source: github://Fabian-Schmidt/esphome-victron_ble

victron_ble:
  - id: MySmartSolar
    mac_address: "de630e81b151"
    bindkey: "9d9701c7ec7acd40e063725bc6ce5591"

sensor:
  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "PV Power"
    id: pv_power
    type: PV_POWER

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "Battery Voltage"
    id: battery_voltage
    type: BATTERY_VOLTAGE

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "Battery Current"
    id: battery_current
    type: BATTERY_CURRENT

  - platform: internal_temperature
    name: "Temperatura interna"
    id: internal_temp
    update_interval: 60s

text_sensor:
  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "MPPT state"
    id: mppt_state
    type: DEVICE_STATE

  - platform: wifi_info
    ip_address:
      name: "ESP32 IP Address"
    ssid:
      name: "ESP32 WiFi SSID"

  - platform: version
    name: "Firmware ESPHome"

i2c:
  sda: 21
  scl: 22
  scan: true

font:
  - file: "gfonts://Roboto"
    id: my_font
    size: 17

globals:
  - id: lcd_page
    type: int
    restore_value: no
    initial_value: '0'

interval:
  - interval: 5s
    then:
      - lambda: |-
          id(lcd_page)++;
          if (id(lcd_page) > 3) id(lcd_page) = 0;

display:
  - platform: ssd1306_i2c
    model: "SSD1306 128x64"
    address: 0x3C
    lambda: |-
      int page = id(lcd_page);

      if (page == 0) {
        it.printf(0, 0, id(my_font), "PV Power:");
        it.printf(0, 20, id(my_font), "%.1f W", id(pv_power).state);
      }

      if (page == 1) {
        it.printf(0, 0, id(my_font), "Batt Volt:");
        it.printf(0, 20, id(my_font), "%.2f V", id(battery_voltage).state);
      }

      if (page == 2) {
        it.printf(0, 0, id(my_font), "Batt Curr:");
        it.printf(0, 20, id(my_font), "%.2f A", id(battery_current).state);
      }

      if (page == 3) {
        it.printf(0, 0, id(my_font), "MPPT State:");
        it.printf(0, 20, id(my_font), "%s", id(mppt_state).state.c_str());
      }

Risultato finale

Il display mostra ciclicamente:

  • Potenza PV
  • Tensione batteria
  • Corrente batteria
  • Stato MPPT

Il sistema è autonomo, affidabile e perfetto per monitorare un impianto solare senza aprire Home Assistant.

Spiegazione didattica del funzionamento del codice (ID sensori, globals, interval, display)

1. Perché prima servono gli ID dei sensori

Prima di poter usare un sensore nel display o in una lambda, ESPHome deve sapere come si chiama quel sensore. Questo nome è l’id:.

Senza ID, il display non può leggere il valore del sensore e il codice non compila.

Esempio corretto:

id: battery_voltage


Questo permette al display di usare:

id(battery_voltage).state

Gli ID sono quindi etichette obbligatorie che collegano i sensori al codice del display.

2. La variabile globale `lcd_page`

globals:
  - id: lcd_page
    type: int
    restore_value: no
    initial_value: '0'

Questa variabile è un contatore che indica quale pagina del display deve essere mostrata.

  • `type int → è un numero intero
  • `initialvalue: ‘0’ → parte dalla pagina 0
  • `restorevalue: no → al riavvio riparte da 0

È il “segnalibro” del display.

3. Il timer `interval` che cambia pagina ogni 5 secondi

interval:
  - interval: 5s
    then:
      - lambda: |-
          id(lcd_page)++;
          if (id(lcd_page) > 3) id(lcd_page) = 0;

Ogni 5 secondi:

  1. aumenta `cd_page di 1
  2. se supera 3, torna a 0

È un ciclo continuo:

0 → 1 → 2 → 3 → 0 → …

Questo permette al display di cambiare pagina automaticamente senza pulsanti.

4. La sezione `display` che disegna la pagina corretta

int page = id(lcd_page);

l display legge quale pagina deve mostrare.

Ogni blocco if (page == X) rappresenta una pagina:

  • 0 → PV Power
  • 1 → Battery Voltage
  • 2 → Battery Current
  • 3 → MPPT State

Esempio:

if (page == 0) {
  it.printf(0, 0, id(my_font), "PV Power:");
  it.printf(0, 20, id(my_font), "%.1f W", id(pv_power).state);
}


Il display mostra solo la pagina corrispondente al valore di lcd_page.

📌 Riassunto didattico

  • Prima si definiscono gli ID dei sensori, altrimenti il display non può leggerli.
  • `cd_page è la variabile che tiene memoria della pagina corrente.
  • interval cambia pagina ogni 5 secondi.
  • `isplay legge `cd_page e mostra la pagina giusta.

È un sistema semplice, elegante e molto flessibile.