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
289 lines
11 KiB
Arduino
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);
|
|
}
|
|
}
|
|
}
|