Il Motore della Previsione Solare: Un’Analisi Passo-Passo delle Formule dietro i Pannelli Fotovoltaici ☀️🔬


👋 Introduzione: Quando la Tecnologia Prevede il Futuro

Hai mai voluto sapere non solo quanta energia produce oggi il tuo impianto solare, ma quanto potrebbe produrre nel momento esatto? Questo è il potere dei “Template” avanzati che utilizziamo nei sistemi di casa intelligente. Questi Template sono veri e propri mini-calcolatori, scritti in un linguaggio chiamato Jinja2, capaci di trasformare semplici letture meteo (come l’intensità del sole o la temperatura) in una previsione molto dettagliata della potenza reale prodotta dal tuo impianto FV.

In questo articolo, smonteremo questa “ricetta digitale” per capire esattamente quali formule vengono usate e perché ogni piccolo numero conta. Prepariamoci a fare un viaggio didattico tra fisica e tecnologia!

🧱 La Struttura: Ingredienti e Obiettivo

Il template ha due compiti principali:

  1. Controllare la Salute: Verificare che tutti i dati di ingresso siano presenti e validi.
  2. Simulare la Produzione: Usando le leggi della fisica, calcolare quanta energia il pannello riceve e quanta ne riesce a trasformare.

Gli Ingredienti Necessari (I Dati Iniziali)

Il sistema parte da due letture chiave dai sensori esterni:

  • GrawGraw​: L’intensità della luce solare che arriva sull’area di misura (Irradiazione GlobaleIrradiazione Globale).
  • TrawTraw​: La temperatura dell’aria all’esterno.

📐 Analisi Passo-Passo: Le Formule Semplificate (Il Cuore del Template)

Il template non fa un singolo calcolo, ma una catena di trasformazioni. Vediamo i passaggi chiave e le formule che li guidano.

Fase 1: Preparazione della Luce

Prima di tutto, dobbiamo capire quanta luce è davvero utile per il pannello e come questa luce cade su di esso.

  • Aggiustamento dell’Altezza (GG): Il template prende l’intensità grezza (GrawGraw​) e la corregge leggermente con un fattore (sensor_height_factor = 0.98). Cosa significa? È una piccola correzione per tenere conto del fatto che il sensore non è perfettamente allineato o misurato alla stessa altezza dell’impianto reale. G=Graw×0.98G=Graw​×0.98
  • La Distribuzione della Luce (DiffuseDiffuse vs DirectDirect): Il sole non è sempre un fascio dritto e perfetto; a volte la luce viene “sparsa” dall’atmosfera.
    • Il template calcola una frazione diffusa basandosi su quanto forte sta brillando il cielo (clearnessclearness). Questa frazione ci dice quanta luce arriva da tutte le direzioni, non solo dal punto esatto del sole.
    • Successivamente, usa i dati di orientamento (tilt_deg=30tilt_deg=30∘) per capire quanto è efficace la superficie del pannello a catturare quella luce diretta e diffusa.

Fase 2: Calcolare l’Energia Reale sul Pannello (GPOAGPOA​)

Questo è il passaggio fondamentale! Stiamo chiedendo: “Quanta energia colpisce davvero la superficie orientata del pannello?”

Il template mescola le frazioni di luce diretta, diffusa e riflessa dal terreno (l’albedo) con i fattori geometrici legati all’angolo di inclinazione ($30^\circ$). La formula combina questi elementi per ottenere GPOAGPOA​:

GPOA=G×(Frazione Diretta×Fattore Diretto+Frazione Diffusa×Fattore Diffuso+Riflesso Terreno×Fattore Riflesso)GPOA​=G×(Frazione Diretta×Fattore Diretto+Frazione Diffusa×Fattore Diffuso+Riflesso Terreno×Fattore Riflesso)

In parole semplici: Stiamo dando al sistema una mappa precisa di come la luce cade sul pannello, tenendo conto sia dell’orientamento che del tipo di cielo.

Fase 3: La Potenza Iniziale (Dal Calore alla Forza)

Una volta che sappiamo quanta energia colpisce il pannello (GPOAGPOA​), dobbiamo trasformarla in Watt. Usiamo la potenza nominale massima (PSTC_totalPSTC_total​, ovvero quanto produce l’impianto in condizioni perfette di laboratorio) e la confrontiamo con l’irradiazione ricevuta:

Potenza Iniziale=(GPOA1000.0)×PSTC_totalPotenza Iniziale=(1000.0GPOA​​)×PSTC_total​ (Il diviso per 1000 serve perché le nostre misurazioni sono spesso in W/m2W/m2, mentre la potenza nominale è espressa su tutta l’area del modulo).

Fase 4: La Correzione Finale (L’Effetto della Temperatura)

Questo è il tocco di finitura. Anche se tutto va bene, quando i pannelli si scaldano troppo, diventano meno efficienti. Il template simula questo calore e applica una “penalità” basata sul delta termico (ΔT=Temperatura Cella25CΔT=Temperatura Cella−25∘C).

La formula di correzione finale è questa: Potenza Corretta=Potenza Iniziale×(1+γpΔT)Potenza Corretta=Potenza Iniziale×(1+γp​⋅ΔT)

Spiegazione Semplice: Se il pannello si scalda molto (ΔTΔT alto), e γpγp​ è un numero negativo, la parentesi (1+...)(1+…) diventa minore di 1, riducendo artificialmente la potenza calcolata. Questo assicura che la nostra previsione sia realistica.

✅ Conclusione: Un Modello Potente ma Semplificato

Il template è estremamente potente perché automatizza un processo che, se lo facessi a mano, richiederebbe ore di calcoli trigonometrici e termici!

Cosa abbiamo imparato? Abbiamo visto che il valore finale in Watt non dipende solo da “quanto c’è sole”, ma da: come cade la luce sul mio pannello (GPOAGPOA​), e quanto è caldo quel pannello (Correzione Termica).

È un esempio brillante di come i dati, quando vengono incartati con una buona dose di logica scolastica e fisica, possano trasformarsi in strumenti di previsione super diretti per la nostra vita quotidiana.


Codice calcolo potenza presunta pannelli

template:
  - sensor:
      - name: "FV Potenza Teorica Reale"
        unique_id: fv_potenza_teorica_reale
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >
          {% set G_raw = states('sensor.gw1100a_solar_radiation') %}
          {% set T_raw = states('sensor.gw1100a_outdoor_temperature') %}
          {% if G_raw in ['unknown','unavailable','none'] or T_raw in ['unknown','unavailable','none'] %}
            unavailable
          {% else %}
            {% set modules_count = 4 %}
            {% set P_stc_module = 585.0 %}
            {% set area_module = 2.582 %}
            {% set eta = 0.2266 %}
            {% set noct = 45 %}
            {% set gamma_p = -0.003 %}
            {% set tilt_deg = 30 %}
            {% set albedo = 0.20 %}
            {% set sensor_height_factor = 0.98 %}
            {% set G = (G_raw | float(0)) * sensor_height_factor %}
            {% set T_amb = (T_raw | float(20)) %}
            {% set _ = states('sensor.time') %}
            {% set area_total = area_module * modules_count %}
            {% set clearness = G / 1000.0 %}
            {% set df_raw = 0.45 + (1 - clearness) * 0.35 %}
            {% set diffuse_frac = [df_raw, 0.9] | min %}
            {% set diffuse_frac = [diffuse_frac, 0.1] | max %}
            {% set direct_frac = 1.0 - diffuse_frac %}
            {% set tilt_rad = (tilt_deg * pi / 180.0) %}
            {% set direct_factor = [ (cos(tilt_rad)), 0.0 ] | max %}
            {% set diffuse_factor = (1.0 + cos(tilt_rad)) / 2.0 %}
            {% set ground_reflect_factor = (1.0 - cos(tilt_rad)) / 2.0 %}
            {% set G_poa = G * ( direct_frac * direct_factor + diffuse_frac * diffuse_factor + albedo * ground_reflect_factor ) %}
            {% set P_stc_total = P_stc_module * modules_count %}
            {% set P_from_irradiance = (G_poa / 1000.0) * P_stc_total %}
            {% set T_cell = T_amb + (G_poa / 800.0) * (noct - 20.0) %}
            {% set deltaT = T_cell - 25.0 %}
            {% set P_temp_corr = P_from_irradiance * (1.0 + gamma_p * deltaT) %}
            {{ [ (P_temp_corr) | round(0), 0 ] | max }}
          {% endif %}

Come ho stimato il SOC di una batteria LiFePO₄ con ESP32, Victron e ESPHome

In questo articolo ti porto dentro, passo passo, quello che abbiamo fatto insieme: stimare lo stato di carica (SOC) di una batteria LiFePO₄ da 51,2 V usando solo:

  • un ESP32
  • un Victron SmartSolar (via BLE)
  • ESPHome
  • e… un po’ di logica nel codice YAML 😄

Niente BMS con BLE, niente dati “magici” dalla batteria: 👉 il SOC lo ricaviamo noi, a partire dalla tensione.

1. Cos’è il SOC e perché non lo leggiamo dalla batteria

Lo stato di carica (SOC) è, in pratica, “quanta energia c’è ancora dentro la batteria”, espresso in percentuale:

  • 100% → batteria piena
  • 0% → batteria scarica

Nel nostro caso:

  • la batteria è una LiFePO₄ DCHOUSE 51,2 V
  • non abbiamo un collegamento diretto al suo BMS
  • quindi non possiamo leggere il SOC via BLE o CAN

Però abbiamo due cose fondamentali:

  1. La tensione della batteria (letta dal Victron via BLE)
  2. Lo stato del regolatore MPPT (Bulk, Absorption, Float, ecc.)

Da qui nasce l’idea:

“Se conosco la tensione e so in che stato è il caricatore, posso stimare il SOC.”

2. L’idea di base: SOC da tensione

Le batterie LiFePO₄ hanno una curva tensione ↔ SOC abbastanza nota. In pratica, a batteria a riposo:

  • sopra una certa tensione → sei vicino al 100%
  • sotto una certa tensione → sei vicino allo 0%
  • in mezzo → c’è una curva abbastanza piatta ma comunque mappabile

Quello che abbiamo fatto è:

  • definire una serie di punti di riferimento:textv_points = {54.4, 53.6, 53.1, 52.8, 52.3, 52.0, 51.5, 51.2, 48.0, 40.0} soc_points = {100, 90, 80, 70, 60, 45, 30, 20, 10, 0}
  • prendere la tensione letta dal Victron
  • trovare in quale “tratto” della curva ci troviamo
  • interpolare tra i due punti più vicini per ottenere un SOC “continuo”

3. Il cuore del sistema: il sensore template in ESPHome

Tutto il calcolo del SOC avviene in un sensore template di ESPHome.

Codice YAML del sensore SOC

yaml

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: xx
      password:xx

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: "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: victron_ble
    victron_ble_id: MySmartSolar
    name: "Yield Today"
    type: YIELD_TODAY

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

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "Load Current"
    type: LOAD_CURRENT

  - platform: wifi_signal
    name: "ESP32 WiFi Strength"
    update_interval: 60s

  - platform: integration
    name: "Solar Energy Produced"
    sensor: pv_power
    unit_of_measurement: "Wh"
    time_unit: h
    accuracy_decimals: 2
    state_class: total_increasing
    device_class: energy

  - platform: uptime
    name: Uptime Sensor
    filters:
      - lambda: return x / 3600.0;
    unit_of_measurement: "h"
    accuracy_decimals: 2

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

  # SOC intelligente
  - platform: template
    name: "SOC stimato da tensione"
    id: soc_voltage
    unit_of_measurement: "%"
    accuracy_decimals: 0
    lambda: |-
      float v = id(battery_voltage).state;
      std::string state = id(mppt_state).state;

      // Normalizza in minuscolo
      std::transform(state.begin(), state.end(), state.begin(), ::tolower);

      // 100% SOLO in float
      if (state.find("float") != std::string::npos) {
        if (v >= 54.40) return 100;
      }

      const int N = 10;
      float v_points[N]   = {54.4, 53.6, 53.1, 52.8, 52.3, 52.0, 51.5, 51.2, 48.0, 40.0};
      float soc_points[N] = {100,  90,   80,   70,   60,   40,   30,   20,   10,   0};

      // EVITA 100% fuori dal FLOAT
      if (v >= 54.4) return 99;

      if (v <= 40.0) return 0;

      for (int i = 0; i < N - 1; i++) {
        if (v <= v_points[i] && v > v_points[i + 1]) {
          float t = (v - v_points[i + 1]) / (v_points[i] - v_points[i + 1]);
          return soc_points[i + 1] + t * (soc_points[i] - soc_points[i + 1]);
        }
      }

      return 0;





binary_sensor:
  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "MPPT is in Fault state"
    type: DEVICE_STATE_FAULT

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "MPPT has Error"
    type: CHARGER_ERROR

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "MPPT in FLOAT"
    id: mppt_in_float
    type: DEVICE_STATE_FLOAT

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

  - platform: victron_ble
    victron_ble_id: MySmartSolar
    name: "MPPT Error reason"
    type: CHARGER_ERROR

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

  - platform: version
    name: "Firmware ESPHome"

output:
  - platform: gpio
    pin: 2
    id: led_ident

switch:
  - platform: output
    name: "LED Identificazione ESP32"
    output: led_ident

button:
  - platform: restart
    id: riavvia_esp
    name: "Riavvia V35 ESP32"

i2c:
  sda: 21
  scl: 22
  scan: true

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

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

  # FLAG: SOC pronto
  - id: soc_ready
    type: bool
    initial_value: 'false'

interval:
  - interval: 1s
    then:
      - lambda: |-
          if (!id(soc_ready) &&
              id(soc_voltage).state > 0 &&
              id(battery_voltage).state > 0) {
            id(soc_ready) = true;
          }

  - interval: 6s
    then:
      - lambda: |-
          id(lcd_page)++;
          if (id(lcd_page) > 4) 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());
      }

      if (page == 4) {

        if (!id(soc_ready)) {
          it.printf(0, 0, id(my_font), "SOC in attesa...");
          return;
        }

        float soc = id(soc_voltage).state;
        float vbat = id(battery_voltage).state;

        it.printf(0, 0, id(my_font), "SOC Batteria:");
        it.printf(0, 20, id(my_font), "%.0f %%", soc);
        it.printf(0, 40, id(my_font), "%.2f V", vbat);
      }

Cosa fa, in parole semplici

  • Legge la tensione: v = id(battery_voltage).state;
  • Legge lo stato del Victron: state = id(mppt_state).state;
  • Se il Victron è in Float e la tensione è alta → forza 100%
  • Altrimenti:
    • confronta v con i valori in v_points
    • trova il segmento giusto
    • calcola il SOC interpolando tra i due punti più vicini

4. Mostrare il SOC sul display OLED

Una volta calcolato il SOC, lo usiamo nel blocco display: per mostrarlo sul piccolo OLED SSD1306.

Estratto del display (pagina SOC)

yaml

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

      // ... altre pagine ...

      if (page == 4) {

        if (!id(soc_ready)) {
          it.printf(0, 2, id(my_font), "SOC in attesa...");
          return;
        }

        float soc = id(soc_voltage).state;
        float vbat = id(battery_voltage).state;

        it.printf(0, 2,  id(my_font), "SOC Batteria:");
        it.printf(0, 22, id(my_font), "%.0f %%", soc);
        it.printf(0, 42, id(my_font), "%.2f V", vbat);
      }

Qui succedono tre cose:

  1. Controllo soc_ready: non mostriamo il SOC finché i valori non sono sensati (tensione e SOC > 0).
  2. Lettura del SOC calcolato:cppfloat soc = id(soc_voltage).state;
  3. Stampa su display:
    • prima riga: testo “SOC Batteria:”
    • seconda riga: valore in %
    • terza riga: tensione in V

5. Il ruolo del flag soc_ready

Per evitare di mostrare numeri “a caso” all’avvio, abbiamo introdotto un flag globale:

yaml

globals:
  - id: soc_ready
    type: bool
    initial_value: 'false'

E un interval che lo attiva solo quando i dati sono validi:

yaml

interval:
  - interval: 1s
    then:
      - lambda: |-
          if (!id(soc_ready) &&
              id(soc_voltage).state > 0 &&
              id(battery_voltage).state > 0) {
            id(soc_ready) = true;
          }

Finché soc_ready è false, sul display appare:

cpp

it.printf(0, 2, id(my_font), "SOC in attesa...");

Così l’utente capisce che il sistema sta ancora “stabilizzando” i dati.

6. Limiti e onestà del metodo

È importante essere chiari: questo SOC è una stima, non un valore assoluto da BMS professionale.

Punti di forza

  • Non richiede BMS con BLE
  • Usa solo ciò che già hai: Victron + ESP32
  • È comprensibile, modificabile, documentabile
  • Per uso domestico/fotovoltaico è più che sufficiente

Limiti strutturali

  • Le LiFePO₄ hanno una curva molto piatta: piccole variazioni di tensione possono spostare molto il SOC stimato
  • Sotto carico o in carica, la tensione è “sporcata” da cadute interne
  • Temperatura, invecchiamento, cavi, ecc. possono spostare la curva reale
  • Non c’è conteggio degli Ah (coulomb counting), quindi non è un SOC “da laboratorio”

7. Perché è comunque una soluzione intelligente

Non stiamo fingendo che la batteria “parli”: 👉 siamo noi a ricavare il SOC, usando:

  • la tensione reale del banco
  • lo stato del Victron (Float)
  • una curva tensione ↔ SOC ragionata
  • un po’ di logica in ESPHome

È una soluzione:

  • trasparente (il codice è leggibile)
  • didattica (capisci cosa succede)
  • pratica (funziona davvero sul campo)

Ed è perfetta per chi vuole:

  • monitorare il proprio impianto fotovoltaico
  • capire come si comporta la batteria
  • avere un’indicazione di SOC senza comprare hardware aggiuntivo

📡 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";
float soc_voltage = 0;  // Aggiunto SOC

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

unsigned long lastUpdate = 0;
unsigned long lastPageSwitch = 0;
int lcd_page = 0;
bool soc_ready = false;  // Flag SOC pronto

// -------------------- 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";
float new_soc_voltage = 0;  // Aggiunto SOC

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

// -------------------- WIFI CONFIG --------------------
const char* ssid     = "Bxxxx";
const char* password = "xx";

// -------------------- WIFI WATCHDOG --------------------
unsigned long lastWiFiCheck = 0;
unsigned long wifiReconnectAttempts = 0;
const unsigned long WIFI_CHECK_INTERVAL = 10000; // 10 secondi
const unsigned long MAX_RECONNECT_ATTEMPTS = 5;
bool wifiConnected = false;

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

// -------------------- FUNZIONE CALCOLO SOC --------------------
float calculateSOC(float v, String state) {

  // Normalizza lo stato in minuscolo
  state.toLowerCase();

  // 100% SOLO in float e sopra 54.40 V
  if (state.indexOf("float") != -1) {
    if (v > 54.40) return 100;
  }

  const int N = 10;
  float v_points[N]   = {54.4, 53.6, 53.1, 52.8, 52.3, 52.0, 51.5, 51.2, 48.0, 40.0};
  float soc_points[N] = {100,  90,   80,   70,   60,   40,   30,   20,   10,   0};

  // EVITA 100% fuori dal float
  if (v > 54.40) return 99;

  // Sotto il minimo → 0%
  if (v <= 40.0) return 0;

  // Interpolazione lineare
  for (int i = 0; i < N - 1; i++) {
    if (v <= v_points[i] && v > v_points[i + 1]) {
      float t = (v - v_points[i + 1]) / (v_points[i] - v_points[i + 1]);
      return soc_points[i + 1] + t * (soc_points[i] - soc_points[i + 1]);
    }
  }

  return 0;
}


// -------------------- 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; }
  .soc   { color:#f0883e; }
  .footer { text-align:center; margin-top:30px; font-size:18px; color:#58a6ff; font-weight:bold; }
  .wifi-status { position:fixed; top:10px; right:10px; padding:5px 10px; border-radius:15px; font-size:12px; }
  .wifi-connected { background:#3fb950; color:white; }
  .wifi-disconnected { background:#f85149; color:white; }
</style>
</head>
<body>

<div id="wifiStatus" class="wifi-status wifi-disconnected">WiFi: Disconnesso</div>

<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">SOC Batteria</div><div class="value soc" id="soc_voltage">-- %</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 updateWifiStatus(connected) {
  const element = document.getElementById('wifiStatus');
  element.className = connected ? 'wifi-status wifi-connected' : 'wifi-status wifi-disconnected';
  element.innerText = 'WiFi: ' + (connected ? 'Connesso' : 'Disconnesso');
}

function updateData() {
  fetch('/api')
    .then(response => {
      updateWifiStatus(true);
      return response.json();
    })
    .then(data => {
      document.getElementById('pv_power').innerText        = data.pv_power + " W";
      document.getElementById('pv_voltage').innerText      = data.pv_voltage.toFixed(2) + " V";
      document.getElementById('battery_voltage').innerText = data.battery_voltage.toFixed(2) + " V";
      document.getElementById('battery_current').innerText = data.battery_current.toFixed(2) + " A";
      document.getElementById('soc_voltage').innerText     = data.soc_voltage.toFixed(0) + " %";
      document.getElementById('yield_total').innerText     = data.yield_total + " Wh";
      document.getElementById('yield_today_real').innerText= data.yield_today_real + " Wh";
      document.getElementById('mppt_state').innerText      = data.mppt_state;
      document.getElementById('serial_number').innerText   = data.serial_number;
      document.getElementById('firmware_ver').innerText    = data.firmware_ver;
    })
    .catch(error => {
      updateWifiStatus(false);
      console.error('Errore di connessione:', error);
    });
}

setInterval(updateData, 1000);
updateData();
</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;
    
    // Calcola il SOC basato sulla tensione
    new_soc_voltage = calculateSOC(new_voltage, new_state);
    soc_voltage = new_soc_voltage;
    
    serial_number   = new_serial;
    firmware_ver    = new_fw;
    lastUpdate = millis();
    packetReady = false;
    
    // Imposta SOC come pronto se abbiamo dati validi
    if (!soc_ready && soc_voltage > 0 && battery_voltage > 0) {
      soc_ready = true;
    }
  }
}

// -------------------- WIFI MANAGEMENT --------------------
void WiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connesso all'AP");
      wifiConnected = true;
      wifiReconnectAttempts = 0;
      break;
      
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnesso dall'AP");
      wifiConnected = false;
      break;
      
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("IP ottenuto: ");
      Serial.println(WiFi.localIP());
      break;
      
    default:
      break;
  }
}

void wifiWatchdog() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi disconnesso, tentativo di riconnessione...");
    
    if (wifiReconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
      WiFi.disconnect();
      delay(100);
      WiFi.reconnect();
      wifiReconnectAttempts++;
      Serial.print("Tentativo ");
      Serial.println(wifiReconnectAttempts);
    } else {
      Serial.println("Troppi tentativi falliti, reset completo WiFi...");
      WiFi.disconnect(true);
      delay(1000);
      WiFi.begin(ssid, password);
      wifiReconnectAttempts = 0;
    }
  } else {
    wifiReconnectAttempts = 0;
  }
}

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

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

  // Configurazione WiFi
  WiFi.onEvent(WiFiEvent);
  WiFi.mode(WIFI_STA);
  WiFi.setHostname("victron-monitor");
  WiFi.setAutoReconnect(true);
  WiFi.persistent(true);

  Serial.println("Connessione al WiFi...");
  WiFi.begin(ssid, password);

  // Timeout connessione iniziale (30 secondi)
  unsigned long startTime = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startTime < 30000) {
    delay(500);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\nConnesso!");
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("\nFallito connessione WiFi iniziale");
  }

  // Configurazione server web
  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 += "\"soc_voltage\":" + String(soc_voltage) + ",";
    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();
  Serial.println("Server HTTP avviato");
}

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

  // Gestione WiFi watchdog ogni 10 secondi
  if (millis() - lastWiFiCheck > WIFI_CHECK_INTERVAL) {
    wifiWatchdog();
    lastWiFiCheck = millis();
  }

  // Cambio pagina display ogni 4 secondi
  if (millis() - lastPageSwitch > 4000) {
    lcd_page = (lcd_page + 1) % 11;  // Modificato per 11 pagine (0-10)
    lastPageSwitch = millis();
  }

  // Aggiornamento display
  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;

        case 10:  // PAGINA 10: SOC BATTERIA
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "SOC Batteria");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          
          if (!soc_ready) {
            u8g2.print("Attesa...");
          } else {
            u8g2.print(soc_voltage, 0); u8g2.print(" %");
          }
          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:

  1. grandi dove serve (es. 1.68)
  2. compatti dove necessario (SER#)
  3. sempre leggibili

Versione v2 con integrazione SOC

#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";
float soc_voltage = 0;  // Aggiunto SOC

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

unsigned long lastUpdate = 0;
unsigned long lastPageSwitch = 0;
int lcd_page = 0;
bool soc_ready = false;  // Flag SOC pronto

// -------------------- 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";
float new_soc_voltage = 0;  // Aggiunto SOC

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

// -------------------- WIFI CONFIG --------------------
const char* ssid     = "xx";
const char* password = "sxxx";

// -------------------- WIFI WATCHDOG --------------------
unsigned long lastWiFiCheck = 0;
unsigned long wifiReconnectAttempts = 0;
const unsigned long WIFI_CHECK_INTERVAL = 10000; // 10 secondi
const unsigned long MAX_RECONNECT_ATTEMPTS = 5;
bool wifiConnected = false;

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

// -------------------- FUNZIONE CALCOLO SOC --------------------
float calculateSOC(float v, const String& state) {
  // 100% SOLO in FLOAT
  if (state == "Float" || state == "FLOAT" || state == "float") {
    if (v > 53.3) return 100;
  }

  const int N = 10;
  float v_points[N]   = {54.4, 53.6, 53.1, 52.8, 52.3, 52.0, 51.5, 51.2, 48.0, 40.0};
  float soc_points[N] = {100,  90,   80,   70,   60,   45,   30,   20,   10,   0};

  // EVITA 100% fuori dal FLOAT
  if (v >= v_points[0]) {
    return 99;   // massimo SOC fuori dal Float
  }

  if (v <= v_points[N-1]) return soc_points[N-1];

  for (int i = 0; i < N - 1; i++) {
    if (v <= v_points[i] && v > v_points[i + 1]) {
      float t = (v - v_points[i + 1]) / (v_points[i] - v_points[i + 1]);
      return soc_points[i + 1] + t * (soc_points[i] - soc_points[i + 1]);
    }
  }

  return 0;
}

// -------------------- 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; }
  .soc   { color:#f0883e; }
  .footer { text-align:center; margin-top:30px; font-size:18px; color:#58a6ff; font-weight:bold; }
  .wifi-status { position:fixed; top:10px; right:10px; padding:5px 10px; border-radius:15px; font-size:12px; }
  .wifi-connected { background:#3fb950; color:white; }
  .wifi-disconnected { background:#f85149; color:white; }
</style>
</head>
<body>

<div id="wifiStatus" class="wifi-status wifi-disconnected">WiFi: Disconnesso</div>

<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">SOC Batteria</div><div class="value soc" id="soc_voltage">-- %</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 updateWifiStatus(connected) {
  const element = document.getElementById('wifiStatus');
  element.className = connected ? 'wifi-status wifi-connected' : 'wifi-status wifi-disconnected';
  element.innerText = 'WiFi: ' + (connected ? 'Connesso' : 'Disconnesso');
}

function updateData() {
  fetch('/api')
    .then(response => {
      updateWifiStatus(true);
      return response.json();
    })
    .then(data => {
      document.getElementById('pv_power').innerText        = data.pv_power + " W";
      document.getElementById('pv_voltage').innerText      = data.pv_voltage.toFixed(2) + " V";
      document.getElementById('battery_voltage').innerText = data.battery_voltage.toFixed(2) + " V";
      document.getElementById('battery_current').innerText = data.battery_current.toFixed(2) + " A";
      document.getElementById('soc_voltage').innerText     = data.soc_voltage.toFixed(0) + " %";
      document.getElementById('yield_total').innerText     = data.yield_total + " Wh";
      document.getElementById('yield_today_real').innerText= data.yield_today_real + " Wh";
      document.getElementById('mppt_state').innerText      = data.mppt_state;
      document.getElementById('serial_number').innerText   = data.serial_number;
      document.getElementById('firmware_ver').innerText    = data.firmware_ver;
    })
    .catch(error => {
      updateWifiStatus(false);
      console.error('Errore di connessione:', error);
    });
}

setInterval(updateData, 1000);
updateData();
</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;
    
    // Calcola il SOC basato sulla tensione
    new_soc_voltage = calculateSOC(new_voltage, new_state);
    soc_voltage = new_soc_voltage;
    
    serial_number   = new_serial;
    firmware_ver    = new_fw;
    lastUpdate = millis();
    packetReady = false;
    
    // Imposta SOC come pronto se abbiamo dati validi
    if (!soc_ready && soc_voltage > 0 && battery_voltage > 0) {
      soc_ready = true;
    }
  }
}

// -------------------- WIFI MANAGEMENT --------------------
void WiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connesso all'AP");
      wifiConnected = true;
      wifiReconnectAttempts = 0;
      break;
      
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnesso dall'AP");
      wifiConnected = false;
      break;
      
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("IP ottenuto: ");
      Serial.println(WiFi.localIP());
      break;
      
    default:
      break;
  }
}

void wifiWatchdog() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi disconnesso, tentativo di riconnessione...");
    
    if (wifiReconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
      WiFi.disconnect();
      delay(100);
      WiFi.reconnect();
      wifiReconnectAttempts++;
      Serial.print("Tentativo ");
      Serial.println(wifiReconnectAttempts);
    } else {
      Serial.println("Troppi tentativi falliti, reset completo WiFi...");
      WiFi.disconnect(true);
      delay(1000);
      WiFi.begin(ssid, password);
      wifiReconnectAttempts = 0;
    }
  } else {
    wifiReconnectAttempts = 0;
  }
}

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

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

  // Configurazione WiFi
  WiFi.onEvent(WiFiEvent);
  WiFi.mode(WIFI_STA);
  WiFi.setHostname("victron-monitor");
  WiFi.setAutoReconnect(true);
  WiFi.persistent(true);

  Serial.println("Connessione al WiFi...");
  WiFi.begin(ssid, password);

  // Timeout connessione iniziale (30 secondi)
  unsigned long startTime = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startTime < 30000) {
    delay(500);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\nConnesso!");
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("\nFallito connessione WiFi iniziale");
  }

  // Configurazione server web
  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 += "\"soc_voltage\":" + String(soc_voltage) + ",";
    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();
  Serial.println("Server HTTP avviato");
}

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

  // Gestione WiFi watchdog ogni 10 secondi
  if (millis() - lastWiFiCheck > WIFI_CHECK_INTERVAL) {
    wifiWatchdog();
    lastWiFiCheck = millis();
  }

  // Cambio pagina display ogni 4 secondi
  if (millis() - lastPageSwitch > 4000) {
    lcd_page = (lcd_page + 1) % 11;  // Modificato per 11 pagine (0-10)
    lastPageSwitch = millis();
  }

  // Aggiornamento display
  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;

        case 10:  // PAGINA 10: SOC BATTERIA
          u8g2.setFont(u8g2_font_ncenB14_tr);
          u8g2.drawStr(0, 16, "SOC Batteria");
          u8g2.setFont(u8g2_font_fub17_tr);
          u8g2.setCursor(0, 60);
          
          if (!soc_ready) {
            u8g2.print("Attesa...");
          } else {
            u8g2.print(soc_voltage, 0); u8g2.print(" %");
          }
          break;
      }
    }
  } while (u8g2.nextPage());
}

🚀 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

🔋 Guida Pratica al SOC di Batterie in Parallelo con BMS

Se usi batterie LiFePO4 con BMS dedicati, sapere come calcolare il SOC (State of Charge) è fondamentale per monitorare quanta energia hai a disposizione. In questa guida, vediamo come funziona il SOC, come si calcola in parallelo, e come si distribuisce la corrente sotto carico.

📌 Cos’è il SOC?

Il SOC indica quanta energia è presente in una batteria rispetto alla sua capacità massima. È come il livello del serbatoio di carburante:

  • 100% SOC = batteria completamente carica
  • 50% SOC = metà carica
  • 0% SOC = batteria scarica (da evitare!)

✅ Formula semplificata:

SOC (%) = energia disponibile ÷ capacità totale × 100

🧮 Esempio 1: Batterie con cariche diverse

Hai due batterie in parallelo:

  • Batteria A: 100Ah al 75% → 75Ah disponibili
  • Batteria B: 50Ah al 50% → 25Ah disponibili
  • Capacità totale: 100 + 50 = 150Ah
  • Energia disponibile: 75 + 25 = 100Ah

👉 SOC totale = 100 ÷ 150 × 100 = 66,7%

🔋 Esempio 2: Batterie completamente cariche

  • Batteria A: 100Ah al 100%
  • Batteria B: 50Ah al 100%
  • Energia disponibile: 150Ah
  • Capacità totale: 150Ah

👉 SOC totale = 150 ÷ 150 × 100 = 100%

⚡ Esempio 3: Carico da 45A

Quando applichi un carico, la corrente si distribuisce in proporzione alla capacità:

  • Capacità totale = 100Ah + 50Ah = 150Ah
  • Carico totale = 45A

✅ Formula semplificata:

Corrente per ogni batteria = (capacità batteria ÷ capacità totale) × corrente totale

👉 Batteria A: 100 ÷ 150 × 45 = 30A 👉 Batteria B: 50 ÷ 150 × 45 = 15A

⚡ Esempio 4: Carico da 80A

Con un carico maggiore, la distribuzione resta proporzionale:

  • Capacità totale = 150Ah
  • Carico totale = 80A

👉 Batteria A: 100 ÷ 150 × 80 = 53,3A 👉 Batteria B: 50 ÷ 150 × 80 = 26,7A

📊 Entrambe si scaricano del 53,3% della loro capacità.

✅ Conclusione: Consigli pratici

  • Usa BMS compatibili per monitorare SOC e bilanciare le celle.
  • Non scaricare mai sotto il 20% per preservare la vita utile.
  • Il SOC totale si calcola come media proporzionale, non come valore minimo.
  • Distribuisci i carichi in base alla capacità per evitare squilibri.