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

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *