Files
polliceverde/firmware/pollice-verde-esp32.ino
lucianoandClaude Opus 5 b412d51658 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
2026-09-07 14:40:49 +02:00

289 lines
11 KiB
Arduino

/*
* 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);
}
}
}