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:
- La tensione della batteria (letta dal Victron via BLE)
- 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:text
v_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
vcon i valori inv_points - trova il segmento giusto
- calcola il SOC interpolando tra i due punti più vicini
- confronta
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:
- Controllo
soc_ready: non mostriamo il SOC finché i valori non sono sensati (tensione e SOC > 0). - Lettura del SOC calcolato:cpp
float soc = id(soc_voltage).state; - 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