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

View File
Whitespace-only changes.
+445
View File
@@ -0,0 +1,445 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from "node:fs"
import { dirname, join } from "node:path"
import type { Aiuola, Evento, LetturaSonda, Stato } from "../../src/lib/types"
import { caricaMeteo, isoOggi, type DatiMeteo } from "../../src/lib/meteo"
import { calcolaConsiglio } from "../../src/lib/irrigazione"
import { programmaSettore } from "../../src/lib/programma"
import { coltura } from "../../src/lib/colture"
import { creaDriver, type Driver } from "./driver"
import { controllaSettore, limitaDurata, litriErogatiOggi } from "./sicurezza"
import { leggiSonde } from "./sonde"
/**
* L'agente è il pezzo sempre acceso. Fa tre cose:
* 1. custodisce lo stato del giardino, che il telefono legge e aggiorna;
* 2. ogni minuto guarda l'orologio e, all'orario di partenza, calcola il
* programma del giorno ed esegue i turni un settore alla volta;
* 3. registra nel diario quello che ha davvero erogato, così il bilancio
* idrico dell'app resta agganciato alla realtà.
*
* Gira su un Raspberry Pi, un NAS, un vecchio portatile: qualsiasi cosa con
* Node e la corrente attaccata.
*/
const CARTELLA = process.env.POLLICE_DATI ?? join(process.cwd(), "agente", "dati")
const FILE_STATO = join(CARTELLA, "stato.json")
const FILE_REGISTRO = join(CARTELLA, "registro.log")
const FILE_PARTENZA = join(CARTELLA, "ultima-partenza.txt")
export interface Esecuzione {
quando: string
aiuolaId: string
aiuola: string
canale: number
minuti: number
litri: number
mm: number
esito: "eseguito" | "simulato" | "saltato" | "errore"
dettaglio: string
}
export class Agente {
stato: Stato
meteo: DatiMeteo | null = null
registro: string[] = []
esecuzioni: Esecuzione[] = []
/** Ultima lettura di ogni sonda, tenuta in memoria per l'interfaccia. */
sonde: LetturaSonda[] = []
ultimaLetturaSonde: string | null = null
erroreSonde: string | null = null
/** Canali attualmente aperti, per poterli chiudere in emergenza. */
aperti = new Set<number>()
inEsecuzione = false
constructor(stato?: Stato) {
this.stato = stato ?? leggiStato()
}
/**
* Giorno dell'ultima partenza automatica, su file: se l'agente si riavvia
* proprio all'orario di partenza non deve rieseguire il programma.
*/
get ultimaPartenza(): string | null {
try {
return existsSync(FILE_PARTENZA) ? readFileSync(FILE_PARTENZA, "utf8").trim() : null
} catch {
return null
}
}
set ultimaPartenza(giorno: string | null) {
try {
mkdirSync(CARTELLA, { recursive: true })
writeFileSync(FILE_PARTENZA, giorno ?? "")
} catch {
/* se non si può scrivere, si rischia solo una doppia partenza */
}
}
scrivi(msg: string): void {
const riga = `${new Date().toISOString()} ${msg}`
this.registro.push(riga)
if (this.registro.length > 500) this.registro.shift()
console.log(riga)
try {
mkdirSync(dirname(FILE_REGISTRO), { recursive: true })
appendFileSync(FILE_REGISTRO, riga + "\n")
} catch {
/* il registro su file è un di più: se non si può scrivere, pazienza */
}
}
get driver(): Driver {
const c = this.stato.impostazioni.centralina
// Senza automazione attiva si resta sempre sul driver simulato, qualunque
// cosa dica la configurazione: è l'interruttore generale.
const tipo = c.automazioneAttiva ? c.driver : "simulato"
return creaDriver(tipo, c.host, (m) => this.scrivi(m))
}
salva(): void {
salvaStato(this.stato)
}
/**
* Unisce lo stato che arriva dall'app. L'app è padrona della configurazione del
* giardino e del diario; l'agente lo è solo di quello che ha registrato oggi,
* che il telefono può non aver ancora visto. Gli eventi automatici più vecchi
* seguono la lista dell'app: così se cancelli dal diario una lettura sbagliata,
* resta cancellata invece di ricomparire al primo aggiornamento.
*/
fondiStato(inArrivo: Stato): Stato {
const oggi = isoOggi()
const uniti = new Map(inArrivo.eventi.map((e) => [e.id, e]))
for (const e of this.stato.eventi) {
if (e.automatico && e.data === oggi && !uniti.has(e.id)) uniti.set(e.id, e)
}
this.stato = { ...inArrivo, eventi: [...uniti.values()].sort((a, b) => b.data.localeCompare(a.data)) }
this.salva()
return this.stato
}
async aggiornaMeteo(forza = false): Promise<void> {
const luogo = this.stato.impostazioni.luogo
if (!luogo) return
try {
this.meteo = await caricaMeteo(luogo, forza)
} catch (e) {
this.scrivi(`Meteo non raggiungibile: ${(e as Error).message}`)
}
}
/**
* Interroga le sonde e ne annota il valore nel diario, una volta al giorno per
* aiuola. Conserviamo il valore più basso della giornata: è quello che dice se
* a un certo punto il profilo si è davvero riempito, ed è quindi il buon punto
* di partenza per il bilancio idrico.
*/
async aggiornaSonde(): Promise<void> {
const c = this.stato.impostazioni.centralina
if (!c.hostSensori && !c.host) return
if (c.driver === "simulato") return
try {
const tempC = this.meteo?.giorni.find((g) => g.data === isoOggi())
const media = tempC ? (tempC.tMax + tempC.tMin) / 2 : undefined
this.sonde = await leggiSonde(c, media)
this.ultimaLetturaSonde = new Date().toISOString()
this.erroreSonde = null
this.annotaSonde()
} catch (e) {
this.erroreSonde = (e as Error).message
this.scrivi(`Sonde non leggibili: ${this.erroreSonde}`)
}
}
private annotaSonde(): void {
const oggi = isoOggi()
let cambiato = false
const eventi = [...this.stato.eventi]
for (const a of this.stato.aiuole) {
if (a.sensore === undefined) continue
const lettura = this.sonde.find((x) => x.sensore === a.sensore)
if (!lettura || lettura.salute !== "ok") continue
const i = eventi.findIndex((e) => e.tipo === "sonda" && e.data === oggi && e.aiuolaId === a.id)
if (i >= 0) {
const precedente = eventi[i].quantita ?? 999
if (lettura.kpa >= precedente) continue
eventi[i] = { ...eventi[i], quantita: Math.round(lettura.kpa) }
} else {
eventi.unshift({
id: `sonda-${a.id}-${oggi}`,
data: oggi,
tipo: "sonda",
aiuolaId: a.id,
quantita: Math.round(lettura.kpa),
unita: "kPa",
testo: `Sonda ${a.sensore}: valore più umido della giornata`,
automatico: true,
})
}
cambiato = true
}
if (cambiato) {
this.stato = { ...this.stato, eventi }
this.salva()
}
}
/** Turni previsti per oggi, settore per settore, già passati dai controlli. */
pianoDiOggi(): { aiuola: Aiuola; minuti: number; litri: number; mm: number; blocco?: string }[] {
if (!this.meteo) return []
const oggi = isoOggi()
const meteoOggi = this.meteo.giorni.find((g) => g.data === oggi)
const sic = this.stato.impostazioni.centralina.sicurezze
const giaErogati = litriErogatiOggi(this.stato, oggi)
const consigli = this.stato.piantagioni
.filter((p) => !p.archiviata)
.map((p) => {
const c = coltura(p.colturaId)
const a = this.stato.aiuole.find((x) => x.id === p.aiuolaId)
if (!c || !a || !this.meteo) return null
return calcolaConsiglio(p, c, a, this.stato.eventi, this.meteo.giorni, this.stato.impostazioni)
})
.filter((x): x is NonNullable<typeof x> => x !== null)
// Una lettura vecchia non vale: la si considera scaduta dopo tre intervalli
// di lettura, con un minimo di due ore.
const c = this.stato.impostazioni.centralina
const scadenzaMs = Math.max(c.intervalloLetturaMinuti * 3, 120) * 60_000
const sondeFresche = this.sonde.filter((x) => Date.now() - Date.parse(x.quando) < scadenzaMs)
const sondeAttese =
c.driver !== "simulato" &&
Boolean(c.hostSensori || c.host) &&
this.stato.aiuole.some((a) => a.sensore !== undefined)
const piano: { aiuola: Aiuola; minuti: number; litri: number; mm: number; blocco?: string }[] = []
let litriPrenotati = giaErogati
for (const a of this.stato.aiuole) {
const prog = programmaSettore(a, consigli, this.meteo.giorni, this.stato.impostazioni)
if (!prog) continue
const turnoOggi = prog.turni.find((t) => t.data === oggi)
if (!turnoOggi) continue
const controllo = controllaSettore(a, meteoOggi, sic, sondeFresche, sondeAttese)
if (!controllo.consentito) {
piano.push({ aiuola: a, minuti: 0, litri: 0, mm: turnoOggi.mm, blocco: controllo.motivo })
continue
}
if (turnoOggi.minuti === null) {
piano.push({
aiuola: a,
minuti: 0,
litri: turnoOggi.litri,
mm: turnoOggi.mm,
blocco: `${a.nome}: manca la portata del settore, non so per quanti minuti tenere aperto.`,
})
continue
}
const lim = limitaDurata(turnoOggi.minuti, turnoOggi.litri, litriPrenotati, sic)
if (lim.minuti <= 0) {
piano.push({ aiuola: a, minuti: 0, litri: 0, mm: turnoOggi.mm, blocco: lim.avviso })
continue
}
litriPrenotati += lim.litri
piano.push({
aiuola: a,
minuti: lim.minuti,
litri: lim.litri,
mm: Math.round(((turnoOggi.mm * lim.minuti) / turnoOggi.minuti) * 10) / 10,
blocco: lim.avviso,
})
}
return piano
}
/** Esegue il programma del giorno, un settore alla volta. */
async esegui(soloProva = false): Promise<Esecuzione[]> {
if (this.inEsecuzione) {
this.scrivi("Esecuzione già in corso: ignoro la richiesta.")
return []
}
this.inEsecuzione = true
const fatte: Esecuzione[] = []
try {
await this.aggiornaMeteo()
// Prima di decidere, si guarda il terreno: la misura batte la stima.
await this.aggiornaSonde()
const piano = this.pianoDiOggi()
if (piano.length === 0) {
this.scrivi("Nessun turno previsto oggi.")
return []
}
const c = this.stato.impostazioni.centralina
const simulazione = soloProva || !c.automazioneAttiva || c.driver === "simulato"
// In simulazione si usa il driver finto, non quello vero: una prova a secco
// non deve far scattare i relè nemmeno per un istante.
const driver = simulazione ? creaDriver("simulato", "", (m) => this.scrivi(m)) : this.driver
for (const voce of piano) {
if (voce.minuti <= 0) {
const e: Esecuzione = {
quando: new Date().toISOString(),
aiuolaId: voce.aiuola.id,
aiuola: voce.aiuola.nome,
canale: voce.aiuola.canale ?? -1,
minuti: 0,
litri: 0,
mm: 0,
esito: "saltato",
dettaglio: voce.blocco ?? "Turno non necessario.",
}
fatte.push(e)
this.scrivi(`SALTATO ${voce.aiuola.nome}: ${e.dettaglio}`)
continue
}
const canale = voce.aiuola.canale as number
try {
this.scrivi(
`${simulazione ? "PROVA" : "APRO"} ${voce.aiuola.nome} (canale ${canale}) per ${voce.minuti} min = ${voce.litri} litri`
)
await driver.apri(canale, voce.minuti * 60)
this.aperti.add(canale)
if (!simulazione) await attendi(voce.minuti * 60_000)
await driver.chiudi(canale)
this.aperti.delete(canale)
const esec: Esecuzione = {
quando: new Date().toISOString(),
aiuolaId: voce.aiuola.id,
aiuola: voce.aiuola.nome,
canale,
minuti: voce.minuti,
litri: voce.litri,
mm: voce.mm,
esito: simulazione ? "simulato" : "eseguito",
dettaglio: voce.blocco ?? "Turno completato.",
}
fatte.push(esec)
if (!simulazione) this.registraIrrigazione(voce.aiuola, voce.mm, voce.litri)
if (c.pausaFraSettori > 0 && !simulazione) await attendi(c.pausaFraSettori * 60_000)
} catch (err) {
// Qualsiasi cosa vada storta, la prima mossa è chiudere.
await driver.chiudi(canale).catch(() => {})
this.aperti.delete(canale)
const messaggio = (err as Error).message
fatte.push({
quando: new Date().toISOString(),
aiuolaId: voce.aiuola.id,
aiuola: voce.aiuola.nome,
canale,
minuti: 0,
litri: 0,
mm: 0,
esito: "errore",
dettaglio: messaggio,
})
this.scrivi(`ERRORE su ${voce.aiuola.nome}: ${messaggio} — valvola richiusa.`)
}
}
} finally {
this.inEsecuzione = false
}
this.esecuzioni = [...fatte, ...this.esecuzioni].slice(0, 200)
this.salva()
return fatte
}
/** Apertura manuale, con chiusura automatica garantita. */
async apriManuale(canale: number, minuti: number): Promise<void> {
const driver = this.driver
const secondi = Math.min(Math.max(minuti, 1), this.stato.impostazioni.centralina.sicurezze.minutiMassimiPerTurno) * 60
this.scrivi(`Apertura manuale del canale ${canale} per ${Math.round(secondi / 60)} minuti`)
await driver.apri(canale, secondi)
this.aperti.add(canale)
// Il dispositivo ha già la sua chiusura a tempo; questa è la seconda.
setTimeout(() => {
driver.chiudi(canale).catch((e: Error) => this.scrivi(`Chiusura del canale ${canale} non riuscita: ${e.message}`))
this.aperti.delete(canale)
}, secondi * 1000)
}
async chiudiTutto(): Promise<void> {
const driver = this.driver
const canali = new Set<number>([...this.aperti, ...this.stato.aiuole.map((a) => a.canale ?? -1).filter((n) => n >= 0)])
for (const c of canali) await driver.chiudi(c).catch(() => {})
this.aperti.clear()
this.scrivi("Tutte le valvole chiuse.")
}
private registraIrrigazione(aiuola: Aiuola, mm: number, litri: number): void {
const evento: Evento = {
id: `auto-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
data: isoOggi(),
tipo: "irrigazione",
aiuolaId: aiuola.id,
mm,
quantita: litri,
unita: "litri",
testo: `Turno automatico sul canale ${aiuola.canale}`,
automatico: true,
}
this.stato = { ...this.stato, eventi: [evento, ...this.stato.eventi] }
this.salva()
}
}
function attendi(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function leggiStato(): Stato {
try {
if (existsSync(FILE_STATO)) return JSON.parse(readFileSync(FILE_STATO, "utf8")) as Stato
} catch (e) {
console.error("Stato illeggibile, riparto da vuoto:", (e as Error).message)
}
return {
versione: 1,
impostazioni: {
luogo: null,
risparmioPacciamatura: 0.25,
efficienza: { goccia: 0.9, subirrigazione: 0.95, manuale: 0.75, aspersione: 0.65 },
temaScuro: null,
centralina: {
urlAgente: "",
automazioneAttiva: false,
driver: "simulato",
host: "",
orarioPartenza: "06:00",
pausaFraSettori: 2,
hostSensori: "",
intervalloLetturaMinuti: 15,
sicurezze: {
minutiMassimiPerTurno: 180,
litriMassimiAlGiorno: 2000,
bloccoGeloSotto: 3,
bloccoVentoSopra: 30,
bloccoPioggiaSopra: 5,
vetoSonde: true,
},
},
},
aiuole: [],
piantagioni: [],
eventi: [],
}
}
export function salvaStato(s: Stato): void {
mkdirSync(CARTELLA, { recursive: true })
writeFileSync(FILE_STATO, JSON.stringify(s, null, 2))
}
+142
View File
@@ -0,0 +1,142 @@
import type { DriverCentralina } from "../../src/lib/types"
/**
* Driver delle valvole. Ogni driver sa fare due cose: aprire un canale per un
* numero di secondi e chiuderlo. Tutti i comandi di apertura passano anche una
* durata al dispositivo, quando il dispositivo la supporta: è la seconda rete di
* sicurezza, quella che chiude l'acqua anche se l'agente muore nel frattempo.
*/
export interface Driver {
nome: string
/** Apre il canale per la durata indicata. */
apri(canale: number, secondi: number): Promise<void>
chiudi(canale: number): Promise<void>
/** Verifica che il dispositivo risponda, senza toccare le valvole. */
verifica(): Promise<string>
}
const TIMEOUT_MS = 6000
async function chiama(url: string): Promise<string> {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const r = await fetch(url, { signal: ctrl.signal })
const testo = await r.text()
if (!r.ok) throw new Error(`HTTP ${r.status}: ${testo.slice(0, 120)}`)
return testo
} finally {
clearTimeout(t)
}
}
/** Non tocca niente: scrive soltanto cosa avrebbe fatto. È il default. */
function simulato(registra: (m: string) => void): Driver {
return {
nome: "simulato",
async apri(canale, secondi) {
registra(`[SIMULATO] aprirei il canale ${canale} per ${Math.round(secondi / 60)} minuti`)
},
async chiudi(canale) {
registra(`[SIMULATO] chiuderei il canale ${canale}`)
},
async verifica() {
return "Modalità simulazione: nessun dispositivo contattato, nessuna valvola aperta."
},
}
}
/** Firmware Pollice Verde su ESP32: vedi firmware/pollice-verde-esp32.ino */
function esp32(host: string): Driver {
const base = `http://${host}`
return {
nome: `ESP32 ${host}`,
async apri(canale, secondi) {
await chiama(`${base}/valvola?canale=${canale}&secondi=${Math.round(secondi)}`)
},
async chiudi(canale) {
await chiama(`${base}/valvola?canale=${canale}&secondi=0`)
},
async verifica() {
return await chiama(`${base}/stato`)
},
}
}
/** Shelly di seconda generazione (Plus 1, Pro, Plus 2PM…): API RPC. */
function shellyGen2(host: string): Driver {
const base = `http://${host}/rpc`
return {
nome: `Shelly Gen2 ${host}`,
async apri(canale, secondi) {
// toggle_after fa spegnere il relè dal firmware stesso: se l'agente si
// pianta a metà turno, l'acqua si chiude comunque.
await chiama(`${base}/Switch.Set?id=${canale}&on=true&toggle_after=${Math.round(secondi)}`)
},
async chiudi(canale) {
await chiama(`${base}/Switch.Set?id=${canale}&on=false`)
},
async verifica() {
return await chiama(`${base}/Shelly.GetDeviceInfo`)
},
}
}
/** Shelly di prima generazione (Shelly 1, 1PM, 2.5). */
function shellyGen1(host: string): Driver {
const base = `http://${host}`
return {
nome: `Shelly Gen1 ${host}`,
async apri(canale, secondi) {
await chiama(`${base}/relay/${canale}?turn=on&timer=${Math.round(secondi)}`)
},
async chiudi(canale) {
await chiama(`${base}/relay/${canale}?turn=off`)
},
async verifica() {
return await chiama(`${base}/status`)
},
}
}
/** Qualsiasi dispositivo con firmware Tasmota. */
function tasmota(host: string): Driver {
const base = `http://${host}/cm?cmnd=`
return {
nome: `Tasmota ${host}`,
async apri(canale, secondi) {
// PulseTime è in decimi di secondo, con offset 100 per i valori oltre i 111 s.
const decimi = Math.round(secondi * 10)
const pulse = decimi <= 111 ? decimi : Math.min(Math.round(secondi) + 100, 64900)
await chiama(`${base}${encodeURIComponent(`PulseTime${canale + 1} ${pulse}`)}`)
await chiama(`${base}${encodeURIComponent(`Power${canale + 1} ON`)}`)
},
async chiudi(canale) {
await chiama(`${base}${encodeURIComponent(`Power${canale + 1} OFF`)}`)
},
async verifica() {
return await chiama(`${base}${encodeURIComponent("Status 0")}`)
},
}
}
export function creaDriver(
tipo: DriverCentralina,
host: string,
registra: (m: string) => void
): Driver {
if (tipo === "simulato" || !host) return simulato(registra)
switch (tipo) {
case "esp32":
return esp32(host)
case "shelly-gen2":
return shellyGen2(host)
case "shelly-gen1":
return shellyGen1(host)
case "tasmota":
return tasmota(host)
default:
return simulato(registra)
}
}
+241
View File
@@ -0,0 +1,241 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
import { readFileSync, existsSync, statSync } from "node:fs"
import { extname, join, normalize } from "node:path"
import { Agente } from "./agente"
import { creaDriver } from "./driver"
import type { Stato } from "../../src/lib/types"
import { isoOggi } from "../../src/lib/meteo"
/**
* Server dell'agente. Serve l'app compilata (così apri lo stesso indirizzo dal
* telefono e dal computer) ed espone le poche API che le servono.
*
* Sta volutamente in HTTP sulla rete di casa: è l'unico modo perché una pagina
* possa parlare con dispositivi locali senza inciampare nel blocco dei contenuti
* misti. Non esporlo su internet.
*/
const PORTA = Number(process.env.POLLICE_PORTA ?? 8088)
const CARTELLA_APP = process.env.POLLICE_APP ?? join(process.cwd(), "dist")
const agente = new Agente()
const TIPI: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
}
function json(res: ServerResponse, dati: unknown, codice = 200): void {
const corpo = JSON.stringify(dati)
res.writeHead(codice, {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PUT, OPTIONS",
"access-control-allow-headers": "content-type",
})
res.end(corpo)
}
function corpo(req: IncomingMessage): Promise<string> {
return new Promise((risolvi, rifiuta) => {
let dati = ""
req.on("data", (c) => {
dati += c
if (dati.length > 5_000_000) rifiuta(new Error("Richiesta troppo grande"))
})
req.on("end", () => risolvi(dati))
req.on("error", rifiuta)
})
}
function serviFile(res: ServerResponse, percorso: string): boolean {
const sicuro = normalize(percorso)
if (!sicuro.startsWith(normalize(CARTELLA_APP))) return false
if (!existsSync(sicuro) || !statSync(sicuro).isFile()) return false
res.writeHead(200, { "content-type": TIPI[extname(sicuro)] ?? "application/octet-stream" })
res.end(readFileSync(sicuro))
return true
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`)
const via = url.pathname
if (req.method === "OPTIONS") return json(res, {})
try {
// ── API ───────────────────────────────────────────────────────────────
if (via === "/api/stato" && req.method === "GET") {
return json(res, { stato: agente.stato })
}
if (via === "/api/stato" && req.method === "PUT") {
const inArrivo = JSON.parse(await corpo(req)) as Stato
if (!Array.isArray(inArrivo.aiuole)) return json(res, { errore: "Stato non valido" }, 400)
return json(res, { stato: agente.fondiStato(inArrivo) })
}
if (via === "/api/centralina" && req.method === "GET") {
await agente.aggiornaMeteo()
const c = agente.stato.impostazioni.centralina
return json(res, {
versione: 1,
automazioneAttiva: c.automazioneAttiva,
driver: c.driver,
host: c.host,
orarioPartenza: c.orarioPartenza,
modalita: c.automazioneAttiva && c.driver !== "simulato" ? "attiva" : "simulazione",
aperti: [...agente.aperti],
inEsecuzione: agente.inEsecuzione,
ultimaPartenza: agente.ultimaPartenza,
piano: agente.pianoDiOggi().map((v) => ({
aiuolaId: v.aiuola.id,
aiuola: v.aiuola.nome,
settore: v.aiuola.settore ?? null,
canale: v.aiuola.canale ?? null,
minuti: v.minuti,
litri: v.litri,
mm: v.mm,
blocco: v.blocco ?? null,
})),
sonde: agente.sonde,
ultimaLetturaSonde: agente.ultimaLetturaSonde,
erroreSonde: agente.erroreSonde,
esecuzioni: agente.esecuzioni.slice(0, 30),
registro: agente.registro.slice(-40),
litriOggi: agente.stato.eventi
.filter((e) => e.tipo === "irrigazione" && e.data === isoOggi() && e.automatico)
.reduce((s, e) => s + (e.quantita ?? 0), 0),
})
}
if (via === "/api/sonde" && req.method === "POST") {
await agente.aggiornaSonde()
return json(res, { sonde: agente.sonde, quando: agente.ultimaLetturaSonde, errore: agente.erroreSonde })
}
if (via === "/api/verifica" && req.method === "POST") {
const c = agente.stato.impostazioni.centralina
const d = creaDriver(c.driver, c.host, (m) => agente.scrivi(m))
try {
const risposta = await d.verifica()
return json(res, { ok: true, driver: d.nome, risposta: risposta.slice(0, 400) })
} catch (e) {
return json(res, { ok: false, driver: d.nome, errore: (e as Error).message })
}
}
if (via === "/api/valvola" && req.method === "POST") {
const { canale, minuti } = JSON.parse(await corpo(req)) as { canale: number; minuti: number }
if (typeof canale !== "number" || typeof minuti !== "number") {
return json(res, { errore: "Servono canale e minuti" }, 400)
}
await agente.apriManuale(canale, minuti)
return json(res, { ok: true, canale, minuti })
}
if (via === "/api/stop" && req.method === "POST") {
await agente.chiudiTutto()
return json(res, { ok: true })
}
if (via === "/api/prova" && req.method === "POST") {
const fatte = await agente.esegui(true)
return json(res, { ok: true, esecuzioni: fatte })
}
if (via === "/api/esegui" && req.method === "POST") {
// Un programma vero dura decine di minuti: si avvia e si risponde subito,
// altrimenti la richiesta resterebbe appesa fino alla fine dei turni.
void agente.esegui(false).catch((e: Error) => agente.scrivi(`Programma interrotto: ${e.message}`))
return json(res, { ok: true, avviato: true })
}
// ── App compilata ─────────────────────────────────────────────────────
if (req.method === "GET") {
const richiesto = join(CARTELLA_APP, via === "/" ? "index.html" : decodeURIComponent(via))
if (serviFile(res, richiesto)) return
// Applicazione a pagina singola: qualsiasi altro percorso torna l'index.
if (serviFile(res, join(CARTELLA_APP, "index.html"))) return
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
return res.end(
"App non compilata. Esegui «npm run build» nella cartella del progetto, poi riavvia l'agente."
)
}
json(res, { errore: "Non trovato" }, 404)
} catch (e) {
agente.scrivi(`Errore nella richiesta ${via}: ${(e as Error).message}`)
json(res, { errore: (e as Error).message }, 500)
}
})
/** Le sonde si interrogano di rado: la tensione idrica si muove in ore. */
function letturaSonde(): void {
const prossima = () => {
const minuti = Math.max(agente.stato.impostazioni.centralina.intervalloLetturaMinuti, 5)
setTimeout(async () => {
await agente.aggiornaSonde()
prossima()
}, minuti * 60_000)
}
prossima()
}
/** Ogni minuto: è l'ora di partire? */
function orologio(): void {
setInterval(async () => {
const c = agente.stato.impostazioni.centralina
const ora = new Date()
const adesso = `${String(ora.getHours()).padStart(2, "0")}:${String(ora.getMinutes()).padStart(2, "0")}`
const oggi = isoOggi()
if (adesso !== c.orarioPartenza) return
if (agente.ultimaPartenza === oggi) return
agente.ultimaPartenza = oggi
agente.scrivi(`Orario di partenza (${adesso}): avvio il programma del giorno.`)
await agente.esegui(false)
}, 60_000)
}
async function avvia(): Promise<void> {
// Prima cosa all'accensione: chiudere tutto. Se l'agente è stato riavviato a
// metà turno, una valvola potrebbe essere rimasta aperta.
await agente.chiudiTutto().catch(() => {})
await agente.aggiornaMeteo(true)
await agente.aggiornaSonde()
server.listen(PORTA, () => {
const c = agente.stato.impostazioni.centralina
console.log(`\n🌱 Agente Pollice Verde in ascolto sulla porta ${PORTA}`)
console.log(` App: http://<indirizzo-di-questa-macchina>:${PORTA}`)
console.log(` Modalità: ${c.automazioneAttiva && c.driver !== "simulato" ? "ATTIVA — le valvole si apriranno davvero" : "simulazione (nessuna valvola verrà aperta)"}`)
console.log(` Driver: ${c.driver}${c.host ? ` su ${c.host}` : ""}`)
console.log(` Partenza: ${c.orarioPartenza}`)
console.log(` Sonde: ${agente.sonde.length ? `${agente.sonde.length} collegate` : "nessuna"}${agente.erroreSonde ? ` (${agente.erroreSonde})` : ""}\n`)
})
orologio()
letturaSonde()
}
// Qualunque sia il motivo dell'uscita, l'acqua si chiude.
for (const segnale of ["SIGINT", "SIGTERM"] as const) {
process.on(segnale, () => {
console.log("\nChiudo le valvole e termino…")
agente
.chiudiTutto()
.catch(() => {})
.finally(() => process.exit(0))
})
}
process.on("uncaughtException", (e) => {
console.error("Errore non gestito:", e)
agente.chiudiTutto().catch(() => {})
})
avvia()
+117
View File
@@ -0,0 +1,117 @@
import type { Aiuola, LetturaSonda, Sicurezze, Stato } from "../../src/lib/types"
import { sogliaDi } from "../../src/lib/sonde"
import type { GiornoMeteo } from "../../src/lib/meteo"
/**
* Controlli da fare PRIMA di aprire una valvola. Un'automazione che sbaglia non
* fa un errore di calcolo: allaga un giardino per una notte intera. Ogni blocco
* qui dentro esiste perché il caso corrispondente è successo a qualcuno.
*/
export interface EsitoControllo {
consentito: boolean
motivo?: string
}
export function controllaSettore(
aiuola: Aiuola,
meteoOggi: GiornoMeteo | undefined,
s: Sicurezze,
sonde: LetturaSonda[] = [],
/** Vero quando è configurato un dispositivo che dovrebbe fornire letture. */
sondeAttese = false
): EsitoControllo {
if (aiuola.canale === undefined || aiuola.canale === null) {
return { consentito: false, motivo: `${aiuola.nome}: nessun canale assegnato sulla centralina.` }
}
if (!meteoOggi) {
return { consentito: false, motivo: "Dati meteo del giorno mancanti: meglio non muovere niente." }
}
if (meteoOggi.tMin <= s.bloccoGeloSotto) {
return {
consentito: false,
motivo: `Minima prevista ${meteoOggi.tMin.toFixed(0)} °C: sotto ${s.bloccoGeloSotto} °C non si irriga, l'acqua nelle tubazioni gela e il terreno bagnato peggiora il danno da gelata.`,
}
}
if (meteoOggi.pioggia >= s.bloccoPioggiaSopra) {
return {
consentito: false,
motivo: `Previsti ${meteoOggi.pioggia.toFixed(0)} mm di pioggia oggi: turno annullato.`,
}
}
if (aiuola.metodo === "aspersione" && meteoOggi.vento >= s.bloccoVentoSopra) {
return {
consentito: false,
motivo: `Vento a ${meteoOggi.vento.toFixed(0)} km/h su un settore ad aspersione: l'acqua finirebbe ovunque tranne che sulle piante.`,
}
}
// Il veto della sonda: se il terreno è misurato ancora umido, il calcolo può
// dire quello che vuole. La misura batte la stima.
if (s.vetoSonde && aiuola.sensore !== undefined) {
const lettura = sonde.find((x) => x.sensore === aiuola.sensore)
if (lettura && lettura.salute === "ok") {
const soglia = sogliaDi(aiuola)
if (lettura.kpa < soglia) {
return {
consentito: false,
motivo: `La sonda legge ${lettura.kpa.toFixed(0)} kPa, sotto la soglia di ${soglia} kPa: il terreno è ancora umido, turno annullato.`,
}
}
} else if (lettura) {
return {
consentito: false,
motivo: `Sonda ${aiuola.sensore} ${lettura.salute} (${lettura.ohm.toFixed(0)} ohm): con una sonda guasta non mi fido ad aprire da solo. Controlla il collegamento, o togli il veto delle sonde in Impostazioni.`,
}
} else if (sondeAttese) {
// Nessuna lettura recente: il dispositivo è spento, irraggiungibile, o la
// sonda non esiste. Con una misura vecchia di ore si rischia di irrigare su
// un dato che non vale più niente: meglio fermarsi.
return {
consentito: false,
motivo: `Nessuna lettura recente dalla sonda ${aiuola.sensore}: il dispositivo non risponde. Il turno è sospeso finché non torna, oppure togli il veto delle sonde in Impostazioni.`,
}
}
}
return { consentito: true }
}
/** Litri già erogati oggi dall'automazione, per non superare il tetto giornaliero. */
export function litriErogatiOggi(stato: Stato, giorno: string): number {
return stato.eventi
.filter((e) => e.tipo === "irrigazione" && e.data === giorno && e.automatico)
.reduce((somma, e) => somma + (e.quantita ?? 0), 0)
}
/** Tronca la durata di un turno ai limiti impostati e spiega perché. */
export function limitaDurata(
minuti: number,
litri: number,
litriGiaErogati: number,
s: Sicurezze
): { minuti: number; litri: number; avviso?: string } {
let m = minuti
let l = litri
let avviso: string | undefined
if (m > s.minutiMassimiPerTurno) {
const fattore = s.minutiMassimiPerTurno / m
m = s.minutiMassimiPerTurno
l = Math.round(l * fattore)
avviso = `Turno troncato a ${m} minuti dal limite di sicurezza (ne servirebbero ${Math.round(minuti)}).`
}
const residuo = s.litriMassimiAlGiorno - litriGiaErogati
if (residuo <= 0) {
return { minuti: 0, litri: 0, avviso: `Tetto giornaliero di ${s.litriMassimiAlGiorno} litri già raggiunto.` }
}
if (l > residuo) {
const fattore = residuo / l
m = Math.max(Math.round(m * fattore), 1)
l = residuo
avviso = `Turno ridotto a ${m} minuti per restare sotto il tetto di ${s.litriMassimiAlGiorno} litri al giorno.`
}
return { minuti: m, litri: l, avviso }
}
+42
View File
@@ -0,0 +1,42 @@
import type { Centralina, LetturaSonda } from "../../src/lib/types"
/**
* Lettura delle sonde Watermark dal dispositivo.
*
* Si interrogano di rado — ogni quarto d'ora basta e avanza: la tensione idrica
* di un terreno si muove in ore, non in secondi, e ogni lettura consuma un po'
* di gesso della sonda.
*/
export interface RispostaSonde {
temperatura: number
sonde: { sensore: number; ohm: number; kpa: number; salute: string }[]
}
const TIMEOUT_MS = 8000
export async function leggiSonde(c: Centralina, tempC?: number): Promise<LetturaSonda[]> {
const host = c.hostSensori || c.host
if (!host || c.driver === "simulato") return []
const url = new URL(`http://${host}/sensori`)
if (typeof tempC === "number" && Number.isFinite(tempC)) url.searchParams.set("tempC", tempC.toFixed(1))
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const r = await fetch(url, { signal: ctrl.signal })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const dati = (await r.json()) as RispostaSonde
const quando = new Date().toISOString()
return (dati.sonde ?? []).map((s) => ({
sensore: s.sensore,
kpa: s.kpa,
ohm: s.ohm,
salute: s.salute,
quando,
}))
} finally {
clearTimeout(t)
}
}