Pollice Verde: gestione dell'orto e del frutteto con irrigazione calcolata

App PWA che dice cosa fare nell'orto e, soprattutto, quanta acqua dare a ogni
coltura, calcolandolo sul meteo reale invece che su un calendario fisso.

Motore idrico (metodo FAO-56)
- il terreno come serbatoio: capacità dal tipo di suolo per la profondità
  radicale, prelievo giornaliero pari a ET0 × Kc, ricarica da pioggia efficace e
  irrigazioni al netto dell'efficienza del metodo
- bilancio ricostruito su 21 giorni reali e proiettato su 10 di previsione, così
  si sa anche fra quanti giorni servirà il prossimo turno e quando saltarlo
- soglia di intervento dipendente dal metodo: con la goccia si bagna al 55%
  della riserva utile, con l'aspersione all'85%, a mano si sfrutta tutto
- correzioni per pacciamatura, serra e coltura in vaso

Dati
- 58 schede fra ortaggi, frutta e aromatiche con coefficienti colturali,
  profondità radicali e fattori di deplezione da FAO-56, più calendario, sesti,
  consociazioni, cure e problemi tipici
- ricerca che capisce i nomi dialettali: cocomero, popone, petonciano, ramerino
- meteo ed evapotraspirazione da Open-Meteo, senza chiave API, con cache offline

Automazione
- generatore di programma per centraline a settori: ritmo di regime da impostare
  e giorni esatti con le piogge già scontate
- agente Node che custodisce lo stato, esegue i turni un settore alla volta e
  registra nel diario quello che ha davvero erogato
- driver per ESP32, Shelly Gen1/Gen2, Tasmota, più modalità simulata predefinita
- sonde Watermark: veto sui turni, riancoraggio del bilancio su misura reale,
  confronto fra sonda e calcolo
- sicurezze: tetti su minuti e litri, blocchi gelo/vento/pioggia, chiusura
  all'avvio e all'uscita, doppia chiusura a tempo lato dispositivo, un settore
  per volta, sospensione dei settori con sonda muta o guasta

Firmware ESP32 incluso, con schema di collegamento e istruzioni di posa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFnqupGt8T5HNNhPPHCjKs
This commit is contained in:
lucianoandClaude Opus 5 committed 2026-09-07 14:40:49 +02:00
commit b412d51658
46 files changed
+10608

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
# Firmware ESP32 per Pollice Verde
Trasforma un ESP32 e una scheda a relè nella centralina comandata dall'app.
## Materiale
| Pezzo | Indicativo |
|---|---|
| ESP32 DevKit v1 | 8 € |
| Scheda a 4 relè con optoisolatore | 7 € |
| Trasformatore 24 V AC per elettrovalvole (se non ne hai già uno) | 15 € |
| Alimentatore 5 V per l'ESP32 | 6 € |
| Scatola stagna IP65 | 10 € |
| Sonda Watermark 200SS (una per settore) | 35 € |
| Resistenza 10 kΩ 1% (una per sonda) | pochi centesimi |
Le elettrovalvole da giardino sono quasi sempre a **24 V AC**. Il relè commuta
quella tensione, non la 230 V: il comune del trasformatore va a tutte le valvole,
e ogni relè chiude il ritorno di una valvola. Se il tuo impianto usa tensione di
rete, fallo vedere a un elettricista prima di metterci le mani.
## Collegamenti
```
ESP32 GPIO 26 → IN1 della scheda relè (canale 0)
ESP32 GPIO 27 → IN2 (canale 1)
ESP32 GPIO 14 → IN3 (canale 2)
ESP32 GPIO 12 → IN4 (canale 3)
ESP32 5V → VCC scheda relè
ESP32 GND → GND scheda relè
Trasformatore 24V AC ─┬─ comune di tutte le elettrovalvole
└─ COM di ogni relè
Ogni NO del relè ─────── filo della rispettiva elettrovalvola
```
Il numero di canale che scrivi nella scheda dell'aiuola, dentro l'app, è la
posizione nell'array `PIN_RELE`: il primo pin è il canale 0.
### Sonde Watermark
Ogni sonda va in serie a una resistenza da 10 kΩ, fra due pin di eccitazione, e
l'ADC legge il punto di mezzo:
```
GPIO 32 ──┬── filo 1 della sonda 0
│
sonda 0 (Watermark 200SS)
│
├── GPIO 34 (lettura ADC)
│
resistenza 10 kΩ
│
GPIO 33 ──┘
GPIO 25 / 35 / GPIO 4 → stessa cosa per la sonda 1
```
I fili della Watermark non hanno polarità. GPIO 34 e 35 sono di **solo ingresso**
sull'ESP32 ed è il motivo per cui sono stati scelti per la lettura.
**La cosa da non sbagliare: i Watermark si leggono in corrente alternata.** Una
tensione continua elettrolizza il gesso e in poche settimane la sonda è da
buttare. Il firmware lo fa già: ogni misura è due letture a polarità invertita da
50 microsecondi l'una, e fra una misura e l'altra i pin restano entrambi a zero.
Non aumentare `MICROSECONDI_ECCITAZIONE` e non leggere più spesso di ogni
quarto d'ora.
### Dove infilare la sonda
A **metà della profondità delle radici** — 15-20 cm per l'orto, 30-40 cm per un
albero — e a 15-20 cm di lato dal gocciolatore, mai sotto: sotto il gocciolatore
misureresti il bulbo bagnato, non il terreno che la pianta esplora davvero.
Fai un foro con una punta di legno del diametro della sonda, bagna la sonda in
acqua per qualche minuto prima di inserirla, e **fai fango**: versa un po' di
terra stemperata nel foro prima di calare la sonda, così il gesso resta a
contatto pieno col terreno. Una sonda con aria attorno legge sempre secco.
A fine stagione, sui terreni che gelano, tirale fuori.
## Caricamento
1. Arduino IDE → *Preferenze* → URL aggiuntive:
`https://espressif.github.io/arduino-esp32/package_esp32_index.json`
2. *Gestore schede* → installa **esp32** by Espressif.
3. Apri `pollice-verde-esp32.ino`, scrivi rete Wi-Fi e password in cima.
4. Scheda: *ESP32 Dev Module*. Carica.
5. Apri il monitor seriale a 115200: stampa l'indirizzo IP da mettere in
*Impostazioni → Centralina → Indirizzo del dispositivo*.
Assegna all'ESP32 un **IP fisso** dal router, altrimenti prima o poi cambierà e
l'agente non lo troverà più.
## Sicurezze già nel firmware
- **Chiusura a tempo autonoma.** Ogni apertura porta con sé la sua durata: la
scheda chiude allo scadere anche se l'agente è morto o la rete è caduta.
- **Tetto di 3 ore** per singola apertura, non aggirabile dall'esterno.
- **Un solo canale alla volta**, perché la pressione di un impianto domestico
non basta per due settori insieme (disattivabile con `UNO_ALLA_VOLTA`).
- **Relè chiusi prima di configurare i pin**, così un riavvio dell'ESP32 non
apre le valvole per un istante.
- **Riconnessione Wi-Fi** ogni 30 secondi senza interrompere un turno in corso.
## Prova prima di collegare l'acqua
Carica il firmware, alimenta solo l'ESP32 e la scheda relè, e da un browser:
```
http://<ip-esp32>/stato
http://<ip-esp32>/valvola?canale=0&secondi=10
http://<ip-esp32>/sensori
```
Devi sentire il relè scattare e, dopo dieci secondi, scattare di nuovo in
chiusura. Solo quando tutti i canali fanno così, collega le valvole.
Per le sonde, la prova che vale: immergi la sonda in un bicchiere d'acqua e
aspetta dieci minuti. `/sensori` deve leggere **0-5 kPa**. Tirala fuori, lasciala
asciugare all'aria un giorno, e deve salire **oltre i 100 kPa**. Se resta
inchiodata su un valore o dice sempre `scollegata`, il problema è nel cablaggio,
non nel terreno.
+288
View File
@@ -0,0 +1,288 @@
/*
* Pollice Verde — firmware per ESP32 con scheda a relè
* ────────────────────────────────────────────────────
* Espone tre indirizzi HTTP sulla rete di casa:
*
* GET /stato → JSON con lo stato dei canali
* GET /valvola?canale=0&secondi=1200 → apre il canale 0 per 20 minuti
* GET /valvola?canale=0&secondi=0 → chiude subito il canale 0
* GET /stop → chiude tutto
* GET /sensori → lettura delle sonde Watermark, in kPa
* GET /sensori?tempC=21 → idem, con compensazione di temperatura
*
* Il pezzo importante è che la durata la conosce la scheda, non solo l'agente:
* se l'agente si spegne, se cade la rete o se qualcuno stacca il Raspberry a
* metà turno, il canale si chiude comunque allo scadere del tempo. Un allagamento
* notturno è il tipo di guasto che non si vuole scoprire la mattina dopo.
*
* ⚠️ Le elettrovalvole da giardino sono a 24 V AC o 9 V a impulso: il relè
* commuta quella tensione, NON la 230 V. Se il tuo impianto usa la rete
* elettrica, falla vedere a un elettricista prima di toccarla.
*
* Librerie: nessuna oltre a quelle del core ESP32 (WiFi, WebServer).
*/
#include <WiFi.h>
#include <WebServer.h>
// ── Da personalizzare ───────────────────────────────────────────────────────
const char* WIFI_SSID = "NOME-DELLA-TUA-RETE";
const char* WIFI_PASSWORD = "PASSWORD-DELLA-TUA-RETE";
// Pin dei relè, uno per canale. L'ordine qui è l'ordine dei canali: il primo
// elemento è il canale 0, che è quello da scrivere nella scheda dell'aiuola.
const int PIN_RELE[] = {26, 27, 14, 12};
const int NUM_CANALI = sizeof(PIN_RELE) / sizeof(PIN_RELE[0]);
// Quasi tutte le schede a relè cinesi sono attive a livello basso: il relè
// scatta quando il pin va a 0. Se la tua fa il contrario, metti false.
const bool RELE_ATTIVO_BASSO = true;
// Nessun canale può restare aperto più di così, qualunque cosa chieda l'agente.
const unsigned long MASSIMO_SECONDI = 3 * 60 * 60; // 3 ore
// Un solo settore per volta: la pressione di un impianto domestico non basta
// per due valvole aperte insieme. Metti false se il tuo impianto lo regge.
const bool UNO_ALLA_VOLTA = true;
// ── Sonde Watermark ─────────────────────────────────────────────────────────
// Ogni sonda va letta con un partitore: la sonda in serie a una resistenza nota,
// il tutto fra due pin di eccitazione, e l'ADC che legge il punto di mezzo.
//
// La cosa da NON sbagliare: i Watermark si leggono in **corrente alternata**.
// Una tensione continua elettrolizza il gesso e in poche settimane la sonda è
// da buttare. Per questo ogni misura si fa due volte a polarità invertita, per
// pochi microsecondi, e si media. Fra una misura e l'altra i pin restano a zero.
const int PIN_ECCITAZIONE_A[] = {32, 25}; // un pin per sonda
const int PIN_ECCITAZIONE_B[] = {33, 4}; // il pin opposto della stessa sonda
const int PIN_LETTURA[] = {34, 35}; // ingressi ADC (solo pin di solo-ingresso)
const int NUM_SONDE = sizeof(PIN_LETTURA) / sizeof(PIN_LETTURA[0]);
const float RESISTENZA_NOTA = 10000.0; // ohm, in serie alla sonda
const float TENSIONE_ECCITAZIONE = 3.3;
const int MICROSECONDI_ECCITAZIONE = 50; // brevissimo: più a lungo, più si consuma la sonda
const float TEMPERATURA_SUOLO_PREDEFINITA = 24.0; // °C, se l'agente non ne passa una
// ────────────────────────────────────────────────────────────────────────────
WebServer server(80);
unsigned long chiusuraPrevista[8]; // millis() a cui chiudere, 0 = chiuso
void scriviRele(int canale, bool acceso) {
int livello = acceso ? (RELE_ATTIVO_BASSO ? LOW : HIGH)
: (RELE_ATTIVO_BASSO ? HIGH : LOW);
digitalWrite(PIN_RELE[canale], livello);
}
void chiudi(int canale) {
scriviRele(canale, false);
chiusuraPrevista[canale] = 0;
}
void chiudiTutto() {
for (int i = 0; i < NUM_CANALI; i++) chiudi(i);
}
void apri(int canale, unsigned long secondi) {
if (secondi > MASSIMO_SECONDI) secondi = MASSIMO_SECONDI;
if (UNO_ALLA_VOLTA) {
for (int i = 0; i < NUM_CANALI; i++)
if (i != canale && chiusuraPrevista[i] != 0) chiudi(i);
}
scriviRele(canale, true);
chiusuraPrevista[canale] = millis() + secondi * 1000UL;
}
/**
* Misura la resistenza di una sonda con doppia eccitazione a polarità invertita.
* Restituisce la media delle due letture, in ohm.
*/
float leggiResistenza(int sonda) {
float somma = 0;
for (int verso = 0; verso < 2; verso++) {
int alto = verso == 0 ? PIN_ECCITAZIONE_A[sonda] : PIN_ECCITAZIONE_B[sonda];
int basso = verso == 0 ? PIN_ECCITAZIONE_B[sonda] : PIN_ECCITAZIONE_A[sonda];
digitalWrite(alto, HIGH);
digitalWrite(basso, LOW);
delayMicroseconds(MICROSECONDI_ECCITAZIONE);
int grezzo = analogRead(PIN_LETTURA[sonda]);
digitalWrite(alto, LOW); // subito a riposo: niente continua sulla sonda
float volt = (grezzo / 4095.0) * TENSIONE_ECCITAZIONE;
// Nel verso invertito il partitore si legge specchiato.
float frazione = verso == 0 ? volt / TENSIONE_ECCITAZIONE
: 1.0 - (volt / TENSIONE_ECCITAZIONE);
if (frazione <= 0.001) frazione = 0.001;
if (frazione >= 0.999) frazione = 0.999;
somma += RESISTENZA_NOTA * (frazione / (1.0 - frazione));
delay(90); // riposo fra i due versi
}
return somma / 2.0;
}
/**
* Da resistenza a centibar (= kPa). Equazioni di Shock, Barnum e Seddigh (1998),
* le stesse usate da Irrometer: la relazione non è lineare e cambia forma su tre
* intervalli, e dipende dalla temperatura del terreno.
*/
float resistenzaInKpa(float ohm, float tempC) {
float kohm = ohm / 1000.0;
float kpa;
if (ohm > 550.0) {
if (ohm > 8000.0) {
kpa = -2.246 - 5.239 * kohm * (1.0 + 0.018 * (tempC - 24.0)) -
0.06756 * kohm * kohm * pow((1.0 + 0.018 * (tempC - 24.0)), 2.0);
} else if (ohm > 1000.0) {
kpa = (-3.213 * kohm - 4.093) / (1.0 - 0.009733 * kohm - 0.01205 * tempC);
} else {
kpa = (kohm * 23.156 - 12.736) * (1.0 + 0.018 * (tempC - 24.0));
}
} else {
// Sotto i 550 ohm il terreno è saturo: la sonda non distingue più niente.
kpa = 0.0;
}
if (kpa < 0.0) kpa = 0.0;
if (kpa > 239.0) kpa = 239.0; // fondo scala del sensore
return kpa;
}
/** Diagnostica del collegamento, per non scambiare un cavo staccato per siccità. */
const char* saluteSonda(float ohm) {
if (ohm > 60000.0) return "scollegata"; // circuito aperto: filo staccato
if (ohm < 200.0) return "in corto"; // due fili che si toccano, o acqua nel morsetto
if (ohm > 35000.0) return "fuori scala"; // oltre il campo utile del Watermark
return "ok";
}
String jsonSensori(float tempC) {
String s = "{\"temperatura\":" + String(tempC, 1) + ",\"sonde\":[";
for (int i = 0; i < NUM_SONDE; i++) {
if (i) s += ",";
float ohm = leggiResistenza(i);
float kpa = resistenzaInKpa(ohm, tempC);
s += "{\"sensore\":" + String(i) +
",\"ohm\":" + String(ohm, 0) +
",\"kpa\":" + String(kpa, 1) +
",\"salute\":\"" + String(saluteSonda(ohm)) + "\"}";
}
s += "]}";
return s;
}
String jsonStato() {
String s = "{\"firmware\":\"pollice-verde-1.0\",\"canali\":[";
unsigned long ora = millis();
for (int i = 0; i < NUM_CANALI; i++) {
if (i) s += ",";
unsigned long restanti = 0;
if (chiusuraPrevista[i] != 0 && chiusuraPrevista[i] > ora)
restanti = (chiusuraPrevista[i] - ora) / 1000UL;
s += "{\"canale\":" + String(i) +
",\"aperto\":" + (chiusuraPrevista[i] != 0 ? "true" : "false") +
",\"secondiRestanti\":" + String(restanti) + "}";
}
s += "],\"ip\":\"" + WiFi.localIP().toString() + "\"}";
return s;
}
void rispondi(int codice, const String& corpo) {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.send(codice, "application/json", corpo);
}
void gestisciValvola() {
if (!server.hasArg("canale") || !server.hasArg("secondi")) {
rispondi(400, "{\"errore\":\"servono canale e secondi\"}");
return;
}
int canale = server.arg("canale").toInt();
long secondi = server.arg("secondi").toInt();
if (canale < 0 || canale >= NUM_CANALI) {
rispondi(400, "{\"errore\":\"canale inesistente\"}");
return;
}
if (secondi <= 0) {
chiudi(canale);
} else {
apri(canale, (unsigned long)secondi);
}
rispondi(200, jsonStato());
}
void setup() {
Serial.begin(115200);
// Prima cosa all'accensione: tutti i relè chiusi, PRIMA di impostarli come
// uscite. Al contrario, un ESP32 che riparte apre le valvole per un istante.
for (int i = 0; i < NUM_CANALI; i++) {
digitalWrite(PIN_RELE[i], RELE_ATTIVO_BASSO ? HIGH : LOW);
pinMode(PIN_RELE[i], OUTPUT);
scriviRele(i, false);
chiusuraPrevista[i] = 0;
}
// Pin delle sonde: a riposo entrambi bassi, così fra una lettura e l'altra
// non passa corrente e il gesso non si consuma.
for (int i = 0; i < NUM_SONDE; i++) {
pinMode(PIN_ECCITAZIONE_A[i], OUTPUT);
pinMode(PIN_ECCITAZIONE_B[i], OUTPUT);
digitalWrite(PIN_ECCITAZIONE_A[i], LOW);
digitalWrite(PIN_ECCITAZIONE_B[i], LOW);
pinMode(PIN_LETTURA[i], INPUT);
}
analogSetAttenuation(ADC_11db); // fondo scala ~3,3 V
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Mi collego al Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Pronto. Indirizzo da mettere in Pollice Verde: ");
Serial.println(WiFi.localIP());
server.on("/stato", []() { rispondi(200, jsonStato()); });
server.on("/sensori", []() {
float t = server.hasArg("tempC") ? server.arg("tempC").toFloat()
: TEMPERATURA_SUOLO_PREDEFINITA;
rispondi(200, jsonSensori(t));
});
server.on("/valvola", gestisciValvola);
server.on("/stop", []() {
chiudiTutto();
rispondi(200, jsonStato());
});
server.onNotFound([]() { rispondi(404, "{\"errore\":\"non trovato\"}"); });
server.begin();
}
void loop() {
server.handleClient();
// Il vero guardiano: chiude i canali scaduti anche se nessuno lo chiede.
unsigned long ora = millis();
for (int i = 0; i < NUM_CANALI; i++) {
if (chiusuraPrevista[i] != 0 && (long)(ora - chiusuraPrevista[i]) >= 0) {
Serial.println("Tempo scaduto: chiudo il canale " + String(i));
chiudi(i);
}
}
// Se il Wi-Fi cade non chiudiamo subito (un turno in corso deve finire), ma
// proviamo a riconnetterci: senza rete l'agente non può più dare lo stop.
static unsigned long ultimoControllo = 0;
if (ora - ultimoControllo > 30000) {
ultimoControllo = ora;
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi caduto, riprovo");
WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}
}
}