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:
commit
b412d51658
46 files changed
+10608
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user