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

+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.DS_Store
*.local
tsconfig.tsbuildinfo
agente/dist
agente/dati/*
!agente/dati/.gitkeep
+186
View File
@@ -0,0 +1,186 @@
# 🌱 Pollice Verde
App per gestire orto e frutteto: dice **cosa fare**, **quando**, e soprattutto **quanta acqua dare a ogni coltura**,
calcolandolo sul meteo reale della tua zona invece che su un calendario fisso.
Funziona nel browser, si installa sulla schermata home del telefono e resta consultabile anche senza rete in giardino.
Nessun account, nessun server: i dati vivono nel browser del tuo dispositivo.
## Avvio
```bash
npm install
npm run dev # sviluppo, http://localhost:5173
npm run build # versione ottimizzata in dist/
npm run preview # prova la build, anche da telefono sulla rete di casa
```
Per usarla dal telefono: lancia `npm run preview`, apri sul telefono l'indirizzo `Network:` che compare nel terminale
(computer e telefono sulla stessa rete Wi-Fi), poi "Aggiungi a schermata Home".
Per averla sempre disponibile senza tenere acceso il computer, la cartella `dist/` è un sito statico: caricala su un
qualsiasi hosting gratuito (Netlify, Vercel, GitHub Pages) e avrai un indirizzo tuo.
## Cosa fa
| Sezione | A cosa serve |
|---|---|
| **Oggi** | Meteo, allerte (gelate, ondate di calore, vento, piogge lunghe), cosa bagnare adesso e con quanti litri, operazioni del mese |
| **Giardino** | Aiuole (terreno, metodo irriguo, pacciamatura, serra, vaso) e colture piantate, con avvisi di rotazione |
| **Acqua** | Per ogni coltura: riserva d'acqua nel terreno, consumo giornaliero, dose, minuti di impianto, andamento a 31 giorni |
| **Turni** | Programma pronto per una centralina a settori: minuti e intervallo da impostare, giorni esatti con le piogge già scontate, avvisi su settori mal raggruppati e turni troppo lunghi. Con l'agente collegato, anche il pannello di comando delle valvole |
| **Guida** | 58 schede fra ortaggi, frutta e aromatiche: calendario, sesti, consociazioni, cure, problemi tipici e rimedi. La ricerca capisce i nomi dialettali: *cocomero*, *popone*, *petonciano*, *ramerino*, *rughetta*, *granoturco* |
| **Diario** | Registro di irrigazioni, concimazioni, trattamenti, potature e raccolti |
## Come viene calcolata l'irrigazione
Il metodo è quello **FAO-56**, lo stesso usato dai consorzi irrigui, riportato alla scala di un orto familiare.
Il terreno è trattato come un serbatoio:
1. **Capacità** — dipende dal tipo di suolo (un terreno franco trattiene 160 mm d'acqua per metro di profondità, uno
sabbioso 80) moltiplicato per la profondità delle radici di quella specie. Un pomodoro su terreno franco ha a
disposizione ~128 mm; un'insalata su sabbia ~24.
2. **Prelievo** — ogni giorno la pianta consuma `ET0 × Kc`, dove ET0 è l'evapotraspirazione di riferimento misurata
dal servizio meteo e Kc un coefficiente che dipende dalla specie e dalla fase in cui si trova.
3. **Ricarica** — pioggia (al netto dei primi millimetri che evaporano) e irrigazioni registrate, corrette per
l'efficienza del metodo: a goccia arriva alle radici il 90% dell'acqua, con l'aspersore il 65%.
4. **Soglia** — quando il consumo raggiunge la frazione di riserva che quella specie tollera (il sedano soffre già al
20%, l'olivo regge fino al 65%) è il momento di irrigare, e la dose è l'acqua mancante.
Il bilancio viene ricostruito sui 21 giorni passati con dati meteo reali e proiettato sui 10 giorni di previsione, così
l'app sa dire anche **fra quanti giorni** servirà la prossima irrigazione — e quando conviene saltare un turno perché
sta per piovere.
La **soglia di intervento** non coincide con la soglia di stress e dipende dal metodo: con la goccia si bagna al 55%
della riserva utile (turni brevi e frequenti, bulbo umido costante), con l'aspersione all'85%, irrigando a mano si
sfrutta tutta la riserva con turni radi e abbondanti.
Correzioni applicate: pacciamatura (−25% di consumo, regolabile), serra (−25% di evaporazione e nessun apporto di
pioggia), coltura in vaso (il serbatoio è il terriccio, non il terreno).
## Automazione con una centralina
La sezione **Turni** traduce il calcolo in qualcosa da digitare sulla centralina. Per ogni aiuola con un'elettrovalvola
serve indicare, nel form dell'aiuola, il **settore** (il nome della stazione) e la **portata**, in litri/ora del settore
oppure per m². La portata si misura meglio di quanto si calcoli: apri solo quella valvola, raccogli per un minuto
l'acqua di un gocciolatore, moltiplica per il numero di gocciolatori e per 60.
L'app produce due cose diverse, perché rispondono a due domande diverse:
- **il programma fisso** — intervallo e minuti di regime, simulati a terreno in equilibrio e tempo asciutto: è il caso
peggiore, quello da lasciare impostato sulla centralina;
- **i giorni esatti** — la sequenza reale dei prossimi 10 giorni, che parte dall'acqua che c'è adesso nel terreno,
recupera l'eventuale arretrato e sconta le piogge previste.
Su un settore con più colture il programma è tarato sulla più esigente, e l'app avvisa quando su una stessa valvola
convivono piante con ritmi troppo diversi (l'insalata vuole acqua ogni tre giorni, l'olivo ogni tre settimane).
### Controllo automatico delle valvole
L'apertura vera è gestita dall'**agente** (`agente/`): un piccolo programma Node che gira su qualcosa di sempre acceso —
Raspberry Pi, NAS, vecchio portatile — e fa tre cose:
1. custodisce lo stato del giardino, che l'app legge e aggiorna via rete;
2. all'orario stabilito calcola il programma del giorno e apre le valvole, **un settore alla volta**;
3. registra nel diario quello che ha davvero erogato, così il bilancio idrico resta agganciato alla realtà.
```bash
npm run agente # compila app e agente, poi lo avvia sulla porta 8088
```
L'agente serve anche l'app compilata: apri `http://<ip-della-macchina>:8088` dal telefono e hai tutto. Sta in HTTP sulla
rete locale di proposito — è l'unico modo perché una pagina possa parlare con dispositivi locali senza inciampare nel
blocco dei contenuti misti. **Non esporlo su internet.**
Dispositivi supportati: **ESP32** col firmware incluso (`firmware/`), **Shelly Gen 1 e Gen 2**, **Tasmota**, più la
modalità **simulata** che è quella predefinita.
#### Sicurezze
L'automazione parte sempre spenta e va attivata a mano. Quando è attiva:
| Blocco | Cosa fa |
|---|---|
| Minuti massimi per turno | Tronca i turni troppo lunghi (predefinito 180) |
| Litri massimi al giorno | Tetto su tutto il giardino (predefinito 2000) |
| Blocco gelo | Niente acqua con minima sotto 3 °C |
| Blocco vento | Salta i settori ad aspersione sopra 30 km/h |
| Blocco pioggia | Annulla il turno se sono previsti più di 5 mm |
| Veto delle sonde | Se una sonda misura il terreno ancora umido, il turno salta: la misura batte la stima |
| Sonda guasta o muta | Un settore con sonda scollegata, in corto o senza letture recenti non viene irrigato |
| Canale mancante | Un'aiuola senza canale assegnato non viene mai aperta |
| Chiusura all'avvio | L'agente chiude tutto all'accensione e all'uscita |
| Doppia chiusura a tempo | La durata è nota anche al dispositivo: se l'agente muore, l'acqua si chiude lo stesso |
| Un settore per volta | Mai due valvole aperte insieme |
La prova a secco (`Turni → Prova a secco`) usa sempre il driver simulato: non fa scattare i relè nemmeno per un istante.
### Sonde nel terreno (Watermark)
Il bilancio idrico è un modello: bravo a prevedere, ma cieco. Una sonda **Watermark
200SS** misura la tensione idrica reale in kPa — la fatica che la radice fa per
estrarre l'acqua — e il sistema la usa in tre modi:
- **veto**: se prima di un turno il terreno risulta ancora umido, il turno salta;
- **riancoraggio**: quando una sonda legge la capacità di campo (≤ 15 kPa), il
bilancio riparte da un dato misurato invece che da una stima;
- **confronto**: nella sezione Acqua, sonda e calcolo vengono messi a confronto, e
quando non vanno d'accordo l'app dice quale manopola guardare.
Soglie di intervento predefinite, in kPa: sabbioso 25, franco-sabbioso 30, franco
40, franco-argilloso 50, argilloso 60. Modificabili per singola aiuola.
Il firmware legge le sonde in corrente alternata (due misure a polarità invertita
da 50 µs) e converte resistenza in kPa con le equazioni di Shock, Barnum e Seddigh,
le stesse usate da Irrometer, con compensazione della temperatura del terreno.
Schema di collegamento e istruzioni di posa in `firmware/README.md`.
Restano fuori portata di un'app nel browser le notifiche ad app chiusa: per quelle servirebbe un servizio di push o un
bot di messaggistica sull'agente.
## Struttura
```
src/
lib/
types.ts modello dati
colture.ts indice delle colture
colture.ortaggi.ts 30 ortaggi con coefficienti FAO-56 e schede colturali
colture.aromatiche.ts 9 aromatiche
colture.frutta.ts 19 fruttiferi
meteo.ts client Open-Meteo (geocoding, previsioni, cache offline)
irrigazione.ts motore a bilancio idrico
programma.ts generatore del programma per centraline a settori
agente.ts client dell'agente della centralina
sonde.ts soglie, livelli e confronto sonda/calcolo
attivita.ts operazioni del mese, allerte meteo, rotazioni
state/
store.tsx stato dell'app, persistenza in localStorage
consigli.ts calcolo dei consigli per tutte le piantagioni
pages/ le sette schermate
components/comuni.tsx modale, campi, barre, grafico del bilancio
public/
sw.js service worker per l'uso offline
manifest.webmanifest installazione come app
agente/
src/agente.ts pianificazione, esecuzione dei turni, registro
src/server.ts server HTTP e orologio di partenza
src/driver.ts ESP32, Shelly Gen1/Gen2, Tasmota, simulato
src/sicurezza.ts blocchi gelo, vento, pioggia, sonde, tetti giornalieri
src/sonde.ts lettura delle Watermark dal dispositivo
firmware/
pollice-verde-esp32.ino firmware ESP32: relè, sonde Watermark, chiusura a tempo
```
## Fonti
- Meteo ed evapotraspirazione: [Open-Meteo](https://open-meteo.com) — gratuito, senza chiave API.
- Coefficienti colturali, profondità radicali e fattori di deplezione: FAO Irrigation and Drainage Paper 56,
*Crop evapotranspiration*.
## Nota
Il calcolo è una stima molto buona ma non conosce il tuo microclima: l'ombra del muro a nord, la zona che resta sempre
umida, il vento che secca un angolo. Usalo come guida e verifica col dito nel terreno — due falangi: se esce asciutto è
ora di bagnare. Dopo qualche settimana saprai di quanto correggere, e le manopole in Impostazioni servono a quello.
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)
}
}
+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);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1f7a4d" />
<meta name="description" content="Pollice Verde — gestione dell'orto e del frutteto con piano di irrigazione calcolato sul meteo reale." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Pollice Verde</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2217
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "pollice-verde",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Gestione e manutenzione dell'orto e del frutteto, con piano di irrigazione basato su bilancio idrico e dati meteo reali.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host",
"typecheck": "tsc -b --noEmit",
"build:agente": "esbuild agente/src/server.ts --bundle --platform=node --target=node20 --format=esm --outfile=agente/dist/agente.mjs --log-level=warning",
"agente": "npm run build && npm run build:agente && node agente/dist/agente.mjs"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"esbuild": "^0.24.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#1f7a4d"/>
<path d="M256 400V214" stroke="#dcf0e4" stroke-width="26" stroke-linecap="round"/>
<path d="M256 246c0-58 44-104 104-112 6 62-38 112-104 112z" fill="#7ed6a4"/>
<path d="M256 300c0-52-40-94-94-101-5 56 34 101 94 101z" fill="#3fae7a"/>
<path d="M168 400h176" stroke="#e8a33d" stroke-width="26" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Pollice Verde — orto e frutteto",
"short_name": "Pollice Verde",
"description": "Gestione dell'orto e del frutteto con piano di irrigazione calcolato sul meteo reale.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f7f8f5",
"theme_color": "#1f7a4d",
"lang": "it",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+52
View File
@@ -0,0 +1,52 @@
/* Service worker minimale: l'app deve aprirsi anche in fondo all'orto senza campo.
- guscio dell'app: cache-first, aggiornata in sottofondo
- meteo: rete per prima, con l'ultima risposta buona come rete di sicurezza */
const CACHE = "pollice-verde-v1"
const GUSCIO = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"]
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(GUSCIO)).then(() => self.skipWaiting()))
})
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((chiavi) => Promise.all(chiavi.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener("fetch", (e) => {
const req = e.request
if (req.method !== "GET") return
const url = new URL(req.url)
if (url.hostname.endsWith("open-meteo.com")) {
e.respondWith(
fetch(req)
.then((r) => {
const copia = r.clone()
caches.open(CACHE).then((c) => c.put(req, copia))
return r
})
.catch(() => caches.match(req))
)
return
}
if (url.origin !== location.origin) return
e.respondWith(
caches.match(req).then((colpo) => {
const rete = fetch(req)
.then((r) => {
if (r.ok) {
const copia = r.clone()
caches.open(CACHE).then((c) => c.put(req, copia))
}
return r
})
.catch(() => colpo || caches.match("/index.html"))
return colpo || rete
})
)
})
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react"
import { Provider, useApp } from "./state/store"
import Oggi from "./pages/Oggi"
import Giardino from "./pages/Giardino"
import Acqua from "./pages/Acqua"
import Programma from "./pages/Programma"
import Guida from "./pages/Guida"
import Diario from "./pages/Diario"
import Impostazioni from "./pages/Impostazioni"
type Pagina = "oggi" | "giardino" | "acqua" | "programma" | "guida" | "diario" | "impostazioni"
const VOCI: { v: Pagina; ico: string; l: string }[] = [
{ v: "oggi", ico: "🌤️", l: "Oggi" },
{ v: "giardino", ico: "🪴", l: "Orto" },
{ v: "acqua", ico: "💧", l: "Acqua" },
{ v: "programma", ico: "📅", l: "Turni" },
{ v: "guida", ico: "📖", l: "Guida" },
{ v: "diario", ico: "📔", l: "Diario" },
]
function Interno() {
const { stato } = useApp()
const [pagina, setPagina] = useState<Pagina>("oggi")
// Il tema segue il sistema, salvo scelta esplicita.
useEffect(() => {
const scelta = stato.impostazioni.temaScuro
const sistema = window.matchMedia("(prefers-color-scheme: dark)")
const applica = () => {
const scuro = scelta === null ? sistema.matches : scelta
document.documentElement.dataset.tema = scuro ? "scuro" : "chiaro"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", scuro ? "#0f1613" : "#1f7a4d")
}
applica()
sistema.addEventListener("change", applica)
return () => sistema.removeEventListener("change", applica)
}, [stato.impostazioni.temaScuro])
useEffect(() => {
window.scrollTo({ top: 0 })
}, [pagina])
return (
<div className="app">
<header className="testata">
<div className="marchio">
<span className="logo">🌱</span>
<span>Pollice Verde</span>
</div>
<div className="spazio" />
<button
className="chiudi"
aria-label="Impostazioni"
title="Impostazioni"
aria-current={pagina === "impostazioni" ? "page" : undefined}
onClick={() => setPagina(pagina === "impostazioni" ? "oggi" : "impostazioni")}
>
{pagina === "impostazioni" ? "✕" : "⚙️"}
</button>
</header>
<nav className="navigazione" aria-label="Sezioni">
{VOCI.map((v) => (
<button key={v.v} aria-current={pagina === v.v ? "page" : undefined} onClick={() => setPagina(v.v)}>
<span className="ico">{v.ico}</span>
<span>{v.l}</span>
</button>
))}
</nav>
<main className="contenuto">
{pagina === "oggi" && <Oggi vaiA={(p) => setPagina(p as Pagina)} />}
{pagina === "giardino" && <Giardino />}
{pagina === "acqua" && <Acqua />}
{pagina === "programma" && <Programma />}
{pagina === "guida" && <Guida />}
{pagina === "diario" && <Diario />}
{pagina === "impostazioni" && <Impostazioni />}
</main>
</div>
)
}
export default function App() {
return (
<Provider>
<Interno />
</Provider>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { useCallback, useEffect, useState } from "react"
import { useApp } from "../state/store"
import {
apriValvola,
chiudiTutto,
eseguiOra,
leggiCentralina,
leggiSondeOra,
provaProgramma,
type StatoCentralina,
} from "../lib/agente"
import { COLORE_LIVELLO, DESCRIZIONE_LIVELLO, livello, sogliaDi } from "../lib/sonde"
import { Barra } from "./comuni"
import { formattaDurata } from "../lib/irrigazione"
/** Pannello di comando della centralina: cosa farà oggi, cosa ha fatto, e i comandi a mano. */
export default function Centralina() {
const { stato } = useApp()
const url = stato.impostazioni.centralina.urlAgente
const [dati, setDati] = useState<StatoCentralina | null>(null)
const [errore, setErrore] = useState<string | null>(null)
const [occupato, setOccupato] = useState(false)
const [messaggio, setMessaggio] = useState<string | null>(null)
const aggiorna = useCallback(() => {
if (!url) return
leggiCentralina(url)
.then((d) => {
setDati(d)
setErrore(null)
})
.catch((e: Error) => setErrore(e.message))
}, [url])
useEffect(() => {
aggiorna()
const t = setInterval(aggiorna, 20_000)
return () => clearInterval(t)
}, [aggiorna])
if (!url) return null
async function comando(azione: () => Promise<unknown>, testo: string) {
setOccupato(true)
setMessaggio(null)
try {
await azione()
setMessaggio(testo)
aggiorna()
} catch (e) {
setMessaggio(`Non riuscito: ${(e as Error).message}`)
} finally {
setOccupato(false)
}
}
if (errore) {
return (
<section className="scheda">
<div className="riga spazia">
<h2>🎛️ Centralina</h2>
<button className="pulsante secondario piccolo" onClick={aggiorna}>Riprova</button>
</div>
<div className="avviso attenzione" style={{ marginTop: 12 }}>
<span className="em">📡</span>
<div>
<div className="grassetto">Agente non raggiungibile</div>
<p className="sottile">
{errore}. Controlla che l'agente sia acceso e che l'indirizzo in Impostazioni sia giusto. Finché non
risponde, l'automazione è ferma e il giardino non viene irrigato da solo.
</p>
</div>
</div>
</section>
)
}
if (!dati) {
return (
<section className="scheda">
<h2>🎛️ Centralina</h2>
<p className="sommario" style={{ marginTop: 6 }}>Mi collego all'agente…</p>
</section>
)
}
const attiva = dati.modalita === "attiva"
const totaleOggi = dati.piano.reduce((s, v) => s + v.litri, 0)
return (
<section className="scheda">
<div className="riga spazia">
<div>
<h2>🎛️ Centralina</h2>
<p className="sottile">
{dati.driver}
{dati.host ? ` · ${dati.host}` : ""} · partenza alle {dati.orarioPartenza}
</p>
</div>
<span className={`etichetta ${attiva ? "" : "neutro"}`}>{attiva ? "Automazione attiva" : "Simulazione"}</span>
</div>
{!attiva && (
<div className="avviso info" style={{ marginTop: 12 }}>
<span className="em">🧪</span>
<p className="sottile">
Modalità simulazione: l'agente calcola tutto e scrive cosa farebbe, ma nessuna valvola si apre. È il modo
giusto per verificare che i canali siano assegnati bene prima di dare acqua vera. Quando ti fidi, accendi
l'automazione in Impostazioni.
</p>
</div>
)}
{dati.inEsecuzione && (
<div className="avviso attenzione" style={{ marginTop: 12 }}>
<span className="em">💦</span>
<p className="sottile">
<span className="grassetto">Irrigazione in corso.</span>{" "}
{dati.aperti.length ? `Canale ${dati.aperti.join(", ")} aperto.` : "Fra un settore e l'altro."}
</p>
</div>
)}
<div className="riga avvolgi" style={{ gap: 20, marginTop: 14 }}>
<div className="metrica">
<div className="nome">Previsti oggi</div>
<div className="valore">{Math.round(totaleOggi)}<span className="unita">litri</span></div>
</div>
<div className="metrica">
<div className="nome">Già erogati</div>
<div className="valore">{Math.round(dati.litriOggi)}<span className="unita">litri</span></div>
</div>
<div className="metrica">
<div className="nome">Settori in programma</div>
<div className="valore">{dati.piano.filter((v) => v.minuti > 0).length}</div>
</div>
</div>
{dati.sonde.length > 0 && (
<>
<h3 style={{ marginTop: 18, marginBottom: 8, fontSize: ".85rem", color: "var(--muted-fg)" }}>
SONDE NEL TERRENO
</h3>
<ul className="lista-pulita">
{dati.sonde.map((s) => {
const aiuola = stato.aiuole.find((a) => a.sensore === s.sensore)
const soglia = aiuola ? sogliaDi(aiuola) : 40
const l = livello(s, soglia)
return (
<li key={s.sensore}>
<div className="riga spazia" style={{ marginBottom: 5 }}>
<span className="sottile" style={{ color: "var(--fg)" }}>
🌡️ {aiuola?.nome ?? `Sonda ${s.sensore}`}
</span>
<span className="sottile grassetto" style={{ color: COLORE_LIVELLO[l] }}>
{s.salute === "ok" ? `${s.kpa.toFixed(0)} kPa` : s.salute}
</span>
</div>
{/* La barra è l'acqua rimasta: piena a capacità di campo, vuota alla soglia. */}
<Barra
percentuale={s.salute === "ok" ? Math.max(100 - (s.kpa / soglia) * 100, 0) : 0}
colore={COLORE_LIVELLO[l]}
/>
<p className="sottile" style={{ marginTop: 5 }}>{DESCRIZIONE_LIVELLO[l]}</p>
</li>
)
})}
</ul>
<div className="riga avvolgi" style={{ gap: 8, marginTop: 10 }}>
<button
className="pulsante secondario piccolo"
disabled={occupato}
onClick={() => comando(() => leggiSondeOra(url), "Sonde rilette.")}
>
🔄 Leggi le sonde adesso
</button>
{dati.ultimaLetturaSonde && (
<span className="sottile">
ultima lettura {new Date(dati.ultimaLetturaSonde).toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
</>
)}
{dati.erroreSonde && (
<div className="avviso attenzione" style={{ marginTop: 12 }}>
<span className="em">🌡️</span>
<p className="sottile">
Sonde non leggibili: {dati.erroreSonde}. Con il veto delle sonde attivo i settori che ne hanno una
assegnata non verranno irrigati finché non tornano a rispondere.
</p>
</div>
)}
{dati.piano.length > 0 && (
<>
<h3 style={{ marginTop: 18, marginBottom: 8, fontSize: ".85rem", color: "var(--muted-fg)" }}>
PROGRAMMA DI OGGI
</h3>
<ul className="lista-pulita">
{dati.piano.map((v) => (
<li key={v.aiuolaId} className="scheda stretta" style={{ boxShadow: "none" }}>
<div className="riga spazia">
<div>
<div className="grassetto" style={{ fontSize: ".92rem" }}>
{v.settore ?? v.aiuola}
{v.canale !== null && <span style={{ fontWeight: 400, color: "var(--muted-fg)" }}> · canale {v.canale}</span>}
</div>
<div className="sottile">
{v.minuti > 0 ? `${formattaDurata(v.minuti)} · ${v.litri} litri · ${v.mm} mm` : "nessun turno"}
</div>
</div>
{v.canale !== null && (
<button
className="pulsante secondario piccolo"
disabled={occupato}
onClick={() => comando(() => apriValvola(url, v.canale as number, 10), `Canale ${v.canale} aperto per 10 minuti.`)}
>
Apri 10 min
</button>
)}
</div>
{v.blocco && (
<p className="sottile" style={{ marginTop: 7, color: "var(--warn)" }}>{v.blocco}</p>
)}
</li>
))}
</ul>
</>
)}
<div className="riga avvolgi" style={{ gap: 8, marginTop: 16 }}>
<button
className="pulsante secondario piccolo"
disabled={occupato}
onClick={() => comando(() => provaProgramma(url), "Prova a secco completata: nessuna valvola è stata toccata.")}
>
🧪 Prova a secco
</button>
<button
className="pulsante piccolo"
disabled={occupato || dati.inEsecuzione}
onClick={() => {
if (attiva && !confirm("Avviare adesso il programma di oggi? L'acqua partirà davvero.")) return
comando(() => eseguiOra(url), "Programma avviato.")
}}
>
▶ Esegui ora
</button>
<button
className="pulsante pericolo piccolo"
disabled={occupato}
onClick={() => comando(() => chiudiTutto(url), "Tutte le valvole chiuse.")}
>
⏹ Chiudi tutto
</button>
</div>
{messaggio && <p className="sottile" style={{ marginTop: 10 }}>{messaggio}</p>}
{dati.esecuzioni.length > 0 && (
<>
<h3 style={{ marginTop: 20, marginBottom: 8, fontSize: ".85rem", color: "var(--muted-fg)" }}>
ULTIME ESECUZIONI
</h3>
<table className="tabellina">
<tbody>
{dati.esecuzioni.slice(0, 8).map((e, i) => (
<tr key={i}>
<td>
{new Date(e.quando).toLocaleString("it-IT", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
</td>
<td>
<span className="grassetto">{e.aiuola}</span> ·{" "}
<span style={{ color: e.esito === "errore" ? "var(--danger)" : e.esito === "saltato" ? "var(--muted-fg)" : "var(--ok)" }}>
{e.esito}
</span>
{e.minuti > 0 && ` · ${formattaDurata(e.minuti)}, ${e.litri} litri`}
<div className="sottile">{e.dettaglio}</div>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, type ReactNode } from "react"
export function Modale({
titolo,
onChiudi,
children,
}: {
titolo: string
onChiudi: () => void
children: ReactNode
}) {
useEffect(() => {
const esc = (e: KeyboardEvent) => e.key === "Escape" && onChiudi()
document.addEventListener("keydown", esc)
document.body.style.overflow = "hidden"
return () => {
document.removeEventListener("keydown", esc)
document.body.style.overflow = ""
}
}, [onChiudi])
return (
<div className="velo" onClick={(e) => e.target === e.currentTarget && onChiudi()}>
<div className="modale" role="dialog" aria-modal="true" aria-label={titolo}>
<div className="intestazione">
<h2>{titolo}</h2>
<button className="chiudi" onClick={onChiudi} aria-label="Chiudi">
✕
</button>
</div>
{children}
</div>
</div>
)
}
export function Campo({
label,
aiuto,
children,
}: {
label: string
aiuto?: string
children: ReactNode
}) {
return (
<div className="campo">
<label>{label}</label>
{children}
{aiuto && <div className="aiuto">{aiuto}</div>}
</div>
)
}
export function Vuoto({ icona, titolo, testo, azione }: { icona: string; titolo: string; testo: string; azione?: ReactNode }) {
return (
<div className="vuoto">
<span className="icona">{icona}</span>
<div className="grassetto" style={{ color: "var(--fg)", marginBottom: 4 }}>{titolo}</div>
<p style={{ maxWidth: 380, margin: "0 auto 14px", fontSize: ".88rem" }}>{testo}</p>
{azione}
</div>
)
}
export function Metrica({ nome, valore, unita }: { nome: string; valore: string | number; unita?: string }) {
return (
<div className="metrica">
<div className="nome">{nome}</div>
<div className="valore">
{valore}
{unita && <span className="unita"> {unita}</span>}
</div>
</div>
)
}
export function Barra({ percentuale, colore }: { percentuale: number; colore: string }) {
return (
<div className="barra" role="meter" aria-valuenow={Math.round(percentuale)} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${Math.min(Math.max(percentuale, 0), 100)}%`, background: colore }} />
</div>
)
}
/** Andamento della riserva d'acqua nel terreno: storico a sinistra, previsione a destra. */
export function GraficoBilancio({
serie,
raw,
taw,
}: {
serie: { data: string; deficit: number; previsione: boolean; pioggia: number; irrigazione: number }[]
raw: number
taw: number
}) {
if (serie.length < 2) return null
const W = 320
const H = 130
const pad = { alto: 8, basso: 18, sx: 4, dx: 4 }
const max = Math.max(taw, raw * 1.3, ...serie.map((s) => s.deficit)) || 1
const x = (i: number) => pad.sx + (i / (serie.length - 1)) * (W - pad.sx - pad.dx)
// Il grafico mostra l'acqua RIMASTA: in alto pieno, in basso vuoto.
const y = (d: number) => pad.alto + (d / max) * (H - pad.alto - pad.basso)
const linea = serie.map((s, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(s.deficit).toFixed(1)}`).join(" ")
const area = `${linea} L${x(serie.length - 1).toFixed(1)},${H - pad.basso} L${x(0).toFixed(1)},${H - pad.basso} Z`
const iPrimaPrevisione = serie.findIndex((s) => s.previsione)
const xSoglia = iPrimaPrevisione > 0 ? x(iPrimaPrevisione) : null
return (
<div>
<svg className="grafico" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
{/* Soglia di stress: sotto questa linea la pianta inizia a soffrire. */}
<rect x={0} y={y(raw)} width={W} height={Math.max(H - pad.basso - y(raw), 0)} fill="var(--danger)" opacity=".07" />
<line x1={0} x2={W} y1={y(raw)} y2={y(raw)} stroke="var(--danger)" strokeWidth="1" strokeDasharray="4 3" opacity=".6" />
{xSoglia !== null && (
<line x1={xSoglia} x2={xSoglia} y1={pad.alto} y2={H - pad.basso} stroke="var(--bordo-forte)" strokeWidth="1" strokeDasharray="2 3" />
)}
<path d={area} fill="var(--acqua)" opacity=".12" />
<path d={linea} fill="none" stroke="var(--acqua)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{serie.map((s, i) =>
s.pioggia > 2 ? <circle key={`p${i}`} cx={x(i)} cy={pad.alto + 2} r="2.4" fill="var(--acqua)" /> : null
)}
{serie.map((s, i) =>
s.irrigazione > 0 ? <circle key={`i${i}`} cx={x(i)} cy={pad.alto + 2} r="2.4" fill="var(--verde-700)" /> : null
)}
</svg>
<div className="riga spazia sottile" style={{ marginTop: -6 }}>
<span>3 settimane fa</span>
<span style={{ color: "var(--danger)" }}>— — soglia di stress</span>
<span>tra 10 giorni</span>
</div>
</div>
)
}
export function dataBreve(iso: string): string {
return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { day: "numeric", month: "short" })
}
export function dataCompleta(iso: string): string {
return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { weekday: "long", day: "numeric", month: "long" })
}
+321
View File
@@ -0,0 +1,321 @@
/* ── Pollice Verde — design system ──────────────────────────────────────────
Palette presa dall'orto: verde foglia, terra bagnata, cielo dopo la pioggia.
Mobile-first: l'app si usa col telefono in mano, tra le aiuole, con una mano
sola e spesso in pieno sole — da qui i contrasti alti e i bersagli grandi. */
:root {
--verde-900: #0f2e1d;
--verde-700: #1f7a4d;
--verde-600: #2a9463;
--verde-500: #3fae7a;
--verde-100: #dcf0e4;
--verde-50: #f1f9f4;
--terra: #8a5a3b;
--sole: #e8a33d;
--acqua: #3b82c4;
--bg: #f7f8f5;
--card: #ffffff;
--fg: #16241c;
--muted-fg: #64756b;
--bordo: #e2e8e3;
--bordo-forte: #cbd6cd;
--ok: #2a9463;
--warn: #d98518;
--danger: #cf4437;
--info: #3b82c4;
--ombra: 0 1px 2px rgba(16, 40, 26, .06), 0 4px 16px rgba(16, 40, 26, .06);
--ombra-alta: 0 8px 32px rgba(16, 40, 26, .14);
--r: 14px;
--r-s: 9px;
--safe: env(safe-area-inset-bottom, 0px);
color-scheme: light;
}
:root[data-tema="scuro"] {
--bg: #0f1613;
--card: #182320;
--fg: #e8f0ea;
--muted-fg: #96a89d;
--bordo: #26332d;
--bordo-forte: #34443c;
--verde-100: #1c3a2a;
--verde-50: #16241e;
--ombra: 0 1px 2px rgba(0, 0, 0, .3), 0 4px 16px rgba(0, 0, 0, .28);
--ombra-alta: 0 8px 32px rgba(0, 0, 0, .5);
--ok: #3fae7a;
--warn: #e8a33d;
--danger: #e46a5c;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
overscroll-behavior-y: none;
}
h1, h2, h3, h4 { margin: 0; line-height: 1.25; font-weight: 650; letter-spacing: -.01em; }
h1 { font-size: 1.45rem; }
h2 { font-size: 1.12rem; }
h3 { font-size: 1rem; }
p { margin: 0; }
button, input, select, textarea { font: inherit; color: inherit; }
/* ── Struttura ─────────────────────────────────────────────────────────── */
.app { display: flex; flex-direction: column; min-height: 100%; }
.testata {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 12px;
padding: 14px 16px calc(14px) 16px;
padding-top: max(14px, env(safe-area-inset-top, 0px));
background: color-mix(in srgb, var(--bg) 86%, transparent);
backdrop-filter: saturate(180%) blur(12px);
border-bottom: 1px solid var(--bordo);
}
.testata .marchio { display: flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: -.02em; }
.testata .marchio span.logo { font-size: 1.35rem; }
.testata .spazio { flex: 1; }
.contenuto {
flex: 1;
width: 100%;
max-width: 940px;
margin: 0 auto;
padding: 16px 16px calc(96px + var(--safe)) 16px;
display: flex; flex-direction: column; gap: 16px;
}
/* Barra di navigazione: in basso su telefono, in alto su desktop. */
.navigazione {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 1px; padding: 6px 4px calc(6px + var(--safe)) 4px;
background: color-mix(in srgb, var(--card) 94%, transparent);
backdrop-filter: saturate(180%) blur(14px);
border-top: 1px solid var(--bordo);
}
.navigazione button {
display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 7px 1px 5px; border: 0; border-radius: var(--r-s);
background: transparent; color: var(--muted-fg);
font-size: .64rem; font-weight: 600; cursor: pointer;
transition: color .15s, background .15s;
}
.navigazione button .ico { font-size: 1.2rem; line-height: 1; }
.navigazione button[aria-current="page"] { color: var(--verde-700); background: var(--verde-50); }
@media (min-width: 760px) {
.navigazione {
position: sticky; top: 0; bottom: auto;
max-width: 940px; margin: 0 auto;
grid-template-columns: repeat(6, auto); justify-content: center;
border-top: 0; border-bottom: 1px solid var(--bordo);
padding: 8px;
}
.navigazione button { flex-direction: row; gap: 8px; padding: 9px 14px; font-size: .85rem; }
.contenuto { padding-bottom: 48px; }
}
/* ── Elementi ──────────────────────────────────────────────────────────── */
.scheda {
background: var(--card);
border: 1px solid var(--bordo);
border-radius: var(--r);
padding: 16px;
box-shadow: var(--ombra);
}
.scheda.stretta { padding: 13px 14px; }
.titolo-sezione {
display: flex; align-items: baseline; gap: 10px;
margin-bottom: 2px;
}
.titolo-sezione .conta {
font-size: .78rem; color: var(--muted-fg); font-weight: 600;
background: var(--verde-50); border-radius: 20px; padding: 2px 9px;
}
.sommario { color: var(--muted-fg); font-size: .88rem; }
.griglia { display: grid; gap: 12px; }
.griglia.due { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.griglia.tre { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.riga { display: flex; align-items: center; gap: 10px; }
.riga.spazia { justify-content: space-between; }
.riga.avvolgi { flex-wrap: wrap; }
.pulsante {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 10px 16px; min-height: 42px;
border: 1px solid var(--verde-700); border-radius: var(--r-s);
background: var(--verde-700); color: #fff;
font-weight: 600; font-size: .92rem; cursor: pointer;
transition: filter .15s, transform .05s;
}
.pulsante:hover { filter: brightness(1.08); }
.pulsante:active { transform: translateY(1px); }
.pulsante.secondario { background: transparent; color: var(--fg); border-color: var(--bordo-forte); }
.pulsante.secondario:hover { background: var(--verde-50); }
.pulsante.pericolo { background: transparent; border-color: var(--danger); color: var(--danger); }
.pulsante.piccolo { padding: 6px 11px; min-height: 34px; font-size: .82rem; }
.pulsante:disabled { opacity: .5; cursor: not-allowed; }
.etichetta {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 20px;
font-size: .74rem; font-weight: 650; letter-spacing: .01em;
background: var(--verde-50); color: var(--verde-700);
border: 1px solid transparent;
}
.etichetta.attenzione { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); }
.etichetta.critico { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.etichetta.neutro { background: transparent; border-color: var(--bordo); color: var(--muted-fg); }
.etichetta.acqua { background: color-mix(in srgb, var(--acqua) 14%, transparent); color: var(--acqua); }
.campo { display: flex; flex-direction: column; gap: 5px; }
.campo > label { font-size: .8rem; font-weight: 650; color: var(--muted-fg); }
.campo .aiuto { font-size: .76rem; color: var(--muted-fg); line-height: 1.4; }
input[type="text"], input[type="number"], input[type="date"], select, textarea {
width: 100%; padding: 10px 12px; min-height: 42px;
border: 1px solid var(--bordo-forte); border-radius: var(--r-s);
background: var(--card);
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--verde-500); outline-offset: 1px; border-color: transparent;
}
textarea { min-height: 74px; resize: vertical; }
.interruttore { display: flex; align-items: center; gap: 9px; cursor: pointer; padding: 4px 0; }
.interruttore input { width: 19px; height: 19px; accent-color: var(--verde-700); }
.vuoto {
text-align: center; padding: 34px 18px; color: var(--muted-fg);
border: 1px dashed var(--bordo-forte); border-radius: var(--r);
}
.vuoto .icona { font-size: 2.2rem; display: block; margin-bottom: 8px; }
/* Barra di riempimento del serbatoio d'acqua nel terreno. */
.barra { height: 9px; border-radius: 6px; background: var(--verde-100); overflow: hidden; }
.barra > i { display: block; height: 100%; border-radius: 6px; transition: width .4s ease; }
.metrica { display: flex; flex-direction: column; gap: 1px; }
.metrica .valore { font-size: 1.3rem; font-weight: 700; letter-spacing: -.02em; }
.metrica .unita { font-size: .8rem; font-weight: 600; color: var(--muted-fg); }
.metrica .nome { font-size: .74rem; color: var(--muted-fg); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.lista-pulita { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
.lista-punti { margin: 0; padding-left: 19px; display: flex; flex-direction: column; gap: 6px; }
.lista-punti li { line-height: 1.5; }
.separatore { height: 1px; background: var(--bordo); margin: 4px 0; }
/* Fascia meteo scorrevole */
.strisciaMeteo { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; }
.strisciaMeteo > div {
flex: 0 0 68px; text-align: center; padding: 9px 4px;
border: 1px solid var(--bordo); border-radius: var(--r-s); background: var(--card);
}
.strisciaMeteo .g { font-size: .7rem; color: var(--muted-fg); font-weight: 650; text-transform: capitalize; }
.strisciaMeteo .i { font-size: 1.25rem; line-height: 1.5; }
.strisciaMeteo .t { font-size: .78rem; font-weight: 650; }
.strisciaMeteo .p { font-size: .7rem; color: var(--acqua); font-weight: 650; min-height: 1em; }
.strisciaMeteo .oggi { border-color: var(--verde-500); box-shadow: 0 0 0 1px var(--verde-500); }
/* Calendario a 12 mesi */
.calendario { display: grid; grid-template-columns: repeat(12, 1fr); gap: 2px; }
.calendario > i {
height: 20px; border-radius: 3px; background: var(--verde-100); font-style: normal;
display: flex; align-items: center; justify-content: center; font-size: .58rem; color: var(--muted-fg);
}
.calendario > i.semina { background: var(--sole); color: #3b2606; }
.calendario > i.trapianto { background: var(--verde-500); color: #08240f; }
.calendario > i.raccolta { background: var(--danger); color: #fff; }
.legenda { display: flex; gap: 12px; flex-wrap: wrap; font-size: .74rem; color: var(--muted-fg); }
.legenda span { display: inline-flex; align-items: center; gap: 5px; }
.legenda b { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
/* Finestra modale */
.velo {
position: fixed; inset: 0; z-index: 50; background: rgba(10, 22, 16, .55);
display: flex; align-items: flex-end; justify-content: center; padding: 0;
backdrop-filter: blur(3px);
}
.modale {
background: var(--card); width: 100%; max-width: 560px; max-height: 92vh; overflow-y: auto;
border-radius: 18px 18px 0 0; padding: 18px 16px calc(24px + var(--safe));
box-shadow: var(--ombra-alta);
display: flex; flex-direction: column; gap: 14px;
animation: sali .22s cubic-bezier(.2, .8, .3, 1);
}
@keyframes sali { from { transform: translateY(20px); opacity: .4; } }
@media (min-width: 700px) {
.velo { align-items: center; padding: 20px; }
.modale { border-radius: var(--r); }
}
.modale .intestazione { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.chiudi {
border: 0; background: var(--verde-50); color: var(--fg); width: 34px; height: 34px;
border-radius: 50%; font-size: 1.1rem; cursor: pointer; line-height: 1;
}
.avviso {
display: flex; gap: 11px; padding: 13px 14px; border-radius: var(--r);
border: 1px solid; align-items: flex-start;
}
.avviso .em { font-size: 1.25rem; line-height: 1.2; }
.avviso.info { background: color-mix(in srgb, var(--info) 9%, transparent); border-color: color-mix(in srgb, var(--info) 30%, transparent); }
.avviso.attenzione { background: color-mix(in srgb, var(--warn) 10%, transparent); border-color: color-mix(in srgb, var(--warn) 32%, transparent); }
.avviso.grave { background: color-mix(in srgb, var(--danger) 10%, transparent); border-color: color-mix(in srgb, var(--danger) 32%, transparent); }
.cerca {
display: flex; gap: 8px; align-items: center;
border: 1px solid var(--bordo-forte); border-radius: var(--r-s);
padding: 0 12px; background: var(--card);
}
.cerca input { border: 0; padding: 11px 0; background: transparent; }
.cerca input:focus { outline: none; }
.chip {
border: 1px solid var(--bordo-forte); background: var(--card); color: var(--muted-fg);
border-radius: 20px; padding: 6px 13px; font-size: .82rem; font-weight: 600; cursor: pointer;
white-space: nowrap;
}
.chip[aria-pressed="true"] { background: var(--verde-700); border-color: var(--verde-700); color: #fff; }
.chip-riga { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 3px; }
.voce {
display: flex; gap: 12px; align-items: center; width: 100%; text-align: left;
padding: 12px; border: 1px solid var(--bordo); border-radius: var(--r);
background: var(--card); cursor: pointer;
}
.voce:hover { border-color: var(--verde-500); }
.voce .em { font-size: 1.5rem; }
.voce .corpo { flex: 1; min-width: 0; }
.voce .corpo .t { font-weight: 650; }
.voce .corpo .s { font-size: .8rem; color: var(--muted-fg); }
.grafico { width: 100%; height: 130px; display: block; }
.tabellina { width: 100%; border-collapse: collapse; font-size: .86rem; }
.tabellina td { padding: 7px 0; border-bottom: 1px solid var(--bordo); vertical-align: top; }
.tabellina td:first-child { color: var(--muted-fg); width: 45%; }
.tabellina tr:last-child td { border-bottom: 0; }
.sottile { font-size: .82rem; color: var(--muted-fg); line-height: 1.5; }
.centrato { text-align: center; }
.grassetto { font-weight: 650; }
+115
View File
@@ -0,0 +1,115 @@
import type { LetturaSonda, Stato } from "./types"
/**
* Client dell'agente della centralina. L'app resta perfettamente utilizzabile
* senza: se l'indirizzo non è configurato o l'agente non risponde, tutto
* continua a funzionare in locale e l'automazione semplicemente non c'è.
*/
export interface VoceProgramma {
aiuolaId: string
aiuola: string
settore: string | null
canale: number | null
minuti: number
litri: number
mm: number
blocco: string | null
}
export interface EsecuzioneRemota {
quando: string
aiuola: string
canale: number
minuti: number
litri: number
mm: number
esito: "eseguito" | "simulato" | "saltato" | "errore"
dettaglio: string
}
export interface StatoCentralina {
automazioneAttiva: boolean
driver: string
host: string
orarioPartenza: string
modalita: "attiva" | "simulazione"
aperti: number[]
inEsecuzione: boolean
ultimaPartenza: string | null
piano: VoceProgramma[]
esecuzioni: EsecuzioneRemota[]
registro: string[]
litriOggi: number
sonde: LetturaSonda[]
ultimaLetturaSonde: string | null
erroreSonde: string | null
}
const TIMEOUT = 8000
async function chiama<T>(url: string, opzioni?: RequestInit): Promise<T> {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), TIMEOUT)
try {
const r = await fetch(url, { ...opzioni, signal: ctrl.signal })
const dati = (await r.json()) as T & { errore?: string }
if (!r.ok) throw new Error(dati.errore ?? `HTTP ${r.status}`)
return dati
} catch (e) {
if ((e as Error).name === "AbortError") throw new Error("L'agente non risponde")
throw e
} finally {
clearTimeout(t)
}
}
function base(url: string): string {
return url.replace(/\/+$/, "")
}
export async function leggiStatoRemoto(url: string): Promise<Stato> {
const r = await chiama<{ stato: Stato }>(`${base(url)}/api/stato`)
return r.stato
}
export async function inviaStato(url: string, stato: Stato): Promise<Stato> {
const r = await chiama<{ stato: Stato }>(`${base(url)}/api/stato`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(stato),
})
return r.stato
}
export function leggiCentralina(url: string): Promise<StatoCentralina> {
return chiama<StatoCentralina>(`${base(url)}/api/centralina`)
}
export function verificaDispositivo(url: string): Promise<{ ok: boolean; driver: string; risposta?: string; errore?: string }> {
return chiama(`${base(url)}/api/verifica`, { method: "POST" })
}
export function apriValvola(url: string, canale: number, minuti: number): Promise<{ ok: boolean }> {
return chiama(`${base(url)}/api/valvola`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ canale, minuti }),
})
}
export function chiudiTutto(url: string): Promise<{ ok: boolean }> {
return chiama(`${base(url)}/api/stop`, { method: "POST" })
}
export function provaProgramma(url: string): Promise<{ esecuzioni: EsecuzioneRemota[] }> {
return chiama(`${base(url)}/api/prova`, { method: "POST" })
}
export function eseguiOra(url: string): Promise<{ ok: boolean }> {
return chiama(`${base(url)}/api/esegui`, { method: "POST" })
}
export function leggiSondeOra(url: string): Promise<{ sonde: LetturaSonda[]; quando: string | null; errore: string | null }> {
return chiama(`${base(url)}/api/sonde`, { method: "POST" })
}
+235
View File
@@ -0,0 +1,235 @@
import type { Aiuola, Coltura, Evento, Piantagione } from "./types"
import { COLTURE, coltura } from "./colture"
import { isoOggi } from "./meteo"
export type Priorita = "urgente" | "consigliata" | "opzionale"
export interface Attivita {
id: string
titolo: string
dettaglio: string
categoria: "semina" | "trapianto" | "raccolta" | "potatura" | "cura" | "concimazione"
priorita: Priorita
colturaId?: string
piantagioneId?: string
emoji: string
}
const GIORNI_MS = 86400000
function giorniDa(data: string): number {
return Math.round((Date.now() - Date.parse(data)) / GIORNI_MS)
}
/** Cosa c'è da fare questo mese, dedotto dalle colture presenti e dal calendario. */
export function attivitaDelMese(
mese: number,
aiuole: Aiuola[],
piantagioni: Piantagione[],
eventi: Evento[]
): Attivita[] {
const out: Attivita[] = []
const attive = piantagioni.filter((p) => !p.archiviata)
// 1. Operazioni sulle colture già in campo.
for (const p of attive) {
const c = coltura(p.colturaId)
const a = aiuole.find((x) => x.id === p.aiuolaId)
if (!c || !a) continue
const eta = giorniDa(p.dataMessaADimora)
if (c.raccolta.includes(mese)) {
out.push({
id: `raccolta-${p.id}`,
titolo: `Raccolta di ${c.nome.toLowerCase()}`,
dettaglio: `In ${a.nome}. Siamo nel periodo di raccolta: controlla ogni 2-3 giorni, raccogliere spesso stimola nuova produzione.`,
categoria: "raccolta",
priorita: "consigliata",
colturaId: c.id,
piantagioneId: p.id,
emoji: c.emoji,
})
}
if (c.potatura?.includes(mese)) {
out.push({
id: `potatura-${p.id}`,
titolo: `Potatura di ${c.nome.toLowerCase()}`,
dettaglio: `In ${a.nome}. ${c.cure.find((x) => x.toLowerCase().includes("pot")) ?? "Mese giusto per intervenire sulla struttura della pianta."}`,
categoria: "potatura",
priorita: "consigliata",
colturaId: c.id,
piantagioneId: p.id,
emoji: "✂️",
})
}
// Concimazione: circa un mese dopo l'impianto e poi ogni 4-6 settimane in stagione.
const ultimaConcimazione = eventi
.filter((e) => e.tipo === "concimazione" && (e.piantagioneId === p.id || e.aiuolaId === a.id))
.map((e) => e.data)
.sort()
.pop()
const giorniDaConcime = ultimaConcimazione ? giorniDa(ultimaConcimazione) : eta
const inStagione = c.perenne
? mese >= (c.stagioneVegetativa?.[0] ?? 3) && mese <= (c.stagioneVegetativa?.[1] ?? 10)
: eta > 25 && eta < 200
if (inStagione && giorniDaConcime > 35) {
out.push({
id: `concime-${p.id}`,
titolo: `Concimare ${c.nome.toLowerCase()}`,
dettaglio: `${ultimaConcimazione ? `Ultima concimazione ${giorniDaConcime} giorni fa. ` : ""}${c.concimazione}`,
categoria: "concimazione",
priorita: giorniDaConcime > 60 ? "consigliata" : "opzionale",
colturaId: c.id,
piantagioneId: p.id,
emoji: "🌾",
})
}
if (!a.pacciamatura && mese >= 5 && mese <= 8 && c.sensibilitaIdrica === "alta") {
out.push({
id: `pacciama-${p.id}-${a.id}`,
titolo: `Pacciamare ${a.nome}`,
dettaglio: `${c.nome} è molto sensibile alla siccità e l'aiuola non è pacciamata. Uno strato di 5-8 cm di paglia o sfalcio secco taglia il fabbisogno d'acqua di circa un quarto e tiene giù le infestanti.`,
categoria: "cura",
priorita: "consigliata",
emoji: "🌾",
})
}
}
// 2. Semine e trapianti possibili questo mese, escludendo ciò che è già in campo.
const giaPresenti = new Set(attive.map((p) => p.colturaId))
for (const c of COLTURE) {
if (giaPresenti.has(c.id)) continue
if (c.seminaSemenzaio?.includes(mese)) {
out.push({
id: `semenzaio-${c.id}`,
titolo: `Semina in semenzaio: ${c.nome.toLowerCase()}`,
dettaglio: `Mese giusto per avviare le piantine al riparo. Trapianto previsto a ${nomiMesi(c.trapianto)}. ${c.sesto}.`,
categoria: "semina",
priorita: "opzionale",
colturaId: c.id,
emoji: c.emoji,
})
}
if (c.seminaDiretta?.includes(mese)) {
out.push({
id: `semina-${c.id}`,
titolo: `Semina in pieno campo: ${c.nome.toLowerCase()}`,
dettaglio: `Si può seminare direttamente a dimora. ${c.sesto}.`,
categoria: "semina",
priorita: "opzionale",
colturaId: c.id,
emoji: c.emoji,
})
}
if (c.trapianto?.includes(mese) && !c.seminaDiretta?.includes(mese)) {
out.push({
id: `trapianto-${c.id}`,
titolo: `Trapianto: ${c.nome.toLowerCase()}`,
dettaglio: `Periodo adatto alla messa a dimora. ${c.sesto}. Esposizione: ${c.esposizione}.`,
categoria: "trapianto",
priorita: "opzionale",
colturaId: c.id,
emoji: c.emoji,
})
}
}
const ordine: Record<Priorita, number> = { urgente: 0, consigliata: 1, opzionale: 2 }
return out.sort((a, b) => ordine[a.priorita] - ordine[b.priorita])
}
function nomiMesi(mesi?: number[]): string {
if (!mesi?.length) return "—"
const n = ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
return mesi.map((m) => n[m - 1]).join(", ")
}
/** Avvisi meteo che richiedono un intervento immediato nell'orto. */
export interface Allerta {
livello: "info" | "attenzione" | "grave"
titolo: string
testo: string
emoji: string
}
export function allerteMeteo(giorni: { data: string; tMax: number; tMin: number; pioggia: number; vento: number }[]): Allerta[] {
const oggi = isoOggi()
const prossimi = giorni.filter((g) => g.data >= oggi).slice(0, 5)
const out: Allerta[] = []
const gelo = prossimi.find((g) => g.tMin <= 2)
if (gelo) {
out.push({
livello: gelo.tMin <= 0 ? "grave" : "attenzione",
titolo: `Rischio gelata: ${gelo.tMin.toFixed(0)} °C il ${formatta(gelo.data)}`,
testo: "Copri le colture sensibili con tessuto non tessuto entro il tramonto. Un terreno umido rilascia calore di notte: una bagnatura leggera il pomeriggio prima aiuta davvero. Ritira i vasi di agrumi e aromatiche delicate.",
emoji: "❄️",
})
}
const caldo = prossimi.find((g) => g.tMax >= 34)
if (caldo) {
out.push({
livello: caldo.tMax >= 38 ? "grave" : "attenzione",
titolo: `Ondata di calore: ${caldo.tMax.toFixed(0)} °C il ${formatta(caldo.data)}`,
testo: "Irriga prima delle 8 del mattino e aumenta la dose, non la frequenza. Ombreggia al 30-50% pomodori, peperoni e insalate: sopra i 35 °C i fiori non allegano. Verifica che la pacciamatura sia spessa almeno 5 cm.",
emoji: "🔥",
})
}
const pioggiaTot = prossimi.slice(0, 3).reduce((s, g) => s + g.pioggia, 0)
if (pioggiaTot >= 25) {
out.push({
livello: "attenzione",
titolo: `Attesi ${pioggiaTot.toFixed(0)} mm di pioggia in 3 giorni`,
testo: "Sospendi le irrigazioni. Dopo piogge prolungate con temperature miti sale il rischio di peronospora su pomodoro, patata e vite: valuta un trattamento preventivo a base di rame appena torna il sereno. Controlla che non ci siano ristagni.",
emoji: "🌧️",
})
}
const vento = prossimi.find((g) => g.vento >= 45)
if (vento) {
out.push({
livello: "attenzione",
titolo: `Vento forte: ${vento.vento.toFixed(0)} km/h il ${formatta(vento.data)}`,
testo: "Controlla tutori e legature di pomodori, fagioli rampicanti e alberi giovani. Rimuovi teli e reti ombreggianti che possono fare vela. Il vento asciuga il terreno molto più in fretta del sole: prevedi un'irrigazione in più.",
emoji: "💨",
})
}
return out
}
function formatta(iso: string): string {
const d = new Date(iso + "T12:00:00")
return d.toLocaleDateString("it-IT", { weekday: "short", day: "numeric", month: "short" })
}
/** Suggerimento di rotazione: cosa NON rimettere nella stessa aiuola. */
export function rotazione(aiuolaId: string, piantagioni: Piantagione[]): { famiglie: string[]; nota: string } {
const famiglie = new Set<string>()
const limite = Date.now() - 3 * 365 * GIORNI_MS
for (const p of piantagioni) {
if (p.aiuolaId !== aiuolaId) continue
if (Date.parse(p.dataMessaADimora) < limite) continue
const c = coltura(p.colturaId)
if (c && !c.perenne) famiglie.add(c.famiglia)
}
const lista = [...famiglie]
return {
famiglie: lista,
nota: lista.length
? `In questa aiuola negli ultimi 3 anni sono passate: ${lista.join(", ")}. Evita di ripetere le stesse famiglie: si accumulano parassiti e malattie specifiche e il terreno si impoverisce sempre degli stessi elementi.`
: "Nessuna coltura registrata qui negli ultimi 3 anni: aiuola libera per qualsiasi famiglia botanica.",
}
}
export function coltureAdatte(c: Coltura, mese: number): boolean {
return Boolean(
c.seminaDiretta?.includes(mese) || c.seminaSemenzaio?.includes(mese) || c.trapianto?.includes(mese)
)
}
+294
View File
@@ -0,0 +1,294 @@
import type { Coltura } from "./types"
/** Aromatiche: consumi bassi, ma alcune (basilico, menta) sono sorprendentemente esigenti. */
export const AROMATICHE: Coltura[] = [
{
id: "basilico",
nome: "Basilico",
sinonimi: ["basilico genovese"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: false,
seminaSemenzaio: [3, 4],
seminaDiretta: [4, 5, 6],
trapianto: [5, 6],
raccolta: [6, 7, 8, 9],
kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.0, finale: 0.9 },
fasiGiorni: { iniziale: 20, sviluppo: 25, piena: 60, finale: 15 },
profonditaRadici: 0.4,
fattoreDeplezione: 0.35,
sensibilitaIdrica: "alta",
fasiCritiche: "Tutta l'estate: appassisce in poche ore e le foglie perdono aroma.",
sesto: "25 cm in tutte le direzioni",
piantePerMq: 16,
esposizione: "pieno sole",
phIdeale: [6, 7],
consociazioni: ["Pomodoro", "Peperone", "Melanzana"],
daEvitare: ["Ruta"],
concimazione: "Compost leggero. Troppo azoto diluisce gli oli essenziali.",
cure: [
"Cima sempre sopra una coppia di foglie: la pianta si allarga a cespuglio.",
"Togli i fiori appena spuntano o le foglie diventano dure e amare.",
"Irriga alla base, mai sulle foglie, e mai in pieno sole.",
"Teme il freddo: sotto i 10 °C annerisce."
],
problemi: [
{ nome: "Peronospora del basilico", sintomo: "Ingiallimento tra le nervature, muffa grigia sotto.", rimedio: "Varietà resistenti, niente bagnature fogliari, sesto arieggiato." }
]
},
{
id: "prezzemolo",
nome: "Prezzemolo",
sinonimi: ["petrosino", "petrosemolo"],
categoria: "aromatica",
famiglia: "Ombrellifere",
emoji: "🌿",
perenne: false,
seminaDiretta: [3, 4, 5, 8, 9],
raccolta: [5, 6, 7, 8, 9, 10, 11],
kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 },
fasiGiorni: { iniziale: 30, sviluppo: 30, piena: 90, finale: 20 },
profonditaRadici: 0.4,
fattoreDeplezione: 0.35,
sensibilitaIdrica: "media",
fasiCritiche: "Germinazione, lenta: 20-25 giorni con superficie sempre umida.",
sesto: "15 cm sulla fila, 25 cm tra le file",
piantePerMq: 25,
esposizione: "mezz'ombra",
phIdeale: [6, 7],
consociazioni: ["Pomodoro", "Asparago", "Rosa"],
daEvitare: ["Lattuga", "Carota"],
concimazione: "Azoto leggero dopo ogni taglio.",
cure: [
"Metti i semi a bagno 24 ore prima di seminarli: accorci di una settimana la germinazione.",
"Taglia gli steli esterni alla base, non la punta.",
"È biennale: il secondo anno va a seme, meglio riseminarlo ogni anno."
],
problemi: [
{ nome: "Oidio", sintomo: "Patina bianca in estate.", rimedio: "Arieggia, taglia basso, riprende pulito." }
]
},
{
id: "rosmarino",
nome: "Rosmarino",
sinonimi: ["ramerino"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9, 10],
raccolta: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.6, finale: 0.5 },
profonditaRadici: 0.8,
fattoreDeplezione: 0.6,
sensibilitaIdrica: "bassa",
fasiCritiche: "Nessuna, una volta radicato. Muore più spesso per troppa acqua che per troppo poca.",
sesto: "80 cm in tutte le direzioni",
piantePerMq: 1.5,
esposizione: "pieno sole",
phIdeale: [6.5, 8],
consociazioni: ["Cavolo", "Carota", "Fagiolo"],
daEvitare: [],
concimazione: "Nessuna. Preferisce terreni poveri, sassosi e drenati.",
cure: [
"Il drenaggio è tutto: in vaso metti argilla espansa sul fondo.",
"Potatura leggera dopo la fioritura, mai sul legno vecchio.",
"Si moltiplica facilissimo per talea in agosto."
],
problemi: [
{ nome: "Marciume radicale", sintomo: "Rami che seccano dall'interno.", rimedio: "Quasi sempre eccesso d'acqua o ristagno: riduci drasticamente." },
{ nome: "Crisolina", sintomo: "Coleotteri metallici verdi che spogliano i rametti.", rimedio: "Raccolta manuale." }
]
},
{
id: "salvia",
nome: "Salvia",
sinonimi: ["salvia officinale"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9],
raccolta: [3, 4, 5, 6, 7, 8, 9, 10, 11],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.4, sviluppo: 0.55, piena: 0.65, finale: 0.55 },
profonditaRadici: 0.6,
fattoreDeplezione: 0.55,
sensibilitaIdrica: "bassa",
fasiCritiche: "Nessuna: molto resistente alla siccità.",
sesto: "60 cm in tutte le direzioni",
piantePerMq: 2.5,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: ["Cavolo", "Carota", "Rosmarino"],
daEvitare: ["Cetriolo"],
concimazione: "Nessuna.",
cure: [
"Potala corta ogni primavera o si spoglia e lignifica.",
"Rinnovala ogni 4-5 anni per talea.",
"Teme molto più l'umidità invernale del gelo."
],
problemi: [
{ nome: "Oidio", sintomo: "Patina bianca in estate umida.", rimedio: "Sfoltisci il cespuglio, zolfo." }
]
},
{
id: "timo",
nome: "Timo",
sinonimi: ["timo serpillo", "pepolino"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9],
raccolta: [4, 5, 6, 7, 8, 9, 10],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.6, finale: 0.5 },
profonditaRadici: 0.4,
fattoreDeplezione: 0.6,
sensibilitaIdrica: "bassa",
fasiCritiche: "Nessuna.",
sesto: "30 cm in tutte le direzioni",
piantePerMq: 9,
esposizione: "pieno sole",
phIdeale: [6.5, 8],
consociazioni: ["Cavolo", "Melanzana", "Pomodoro"],
daEvitare: [],
concimazione: "Nessuna: in terreno grasso perde aroma.",
cure: [
"Raccogli poco prima della fioritura, quando gli oli essenziali sono al massimo.",
"Potatura di riordino a fine inverno.",
"Perfetto in vaso e nelle bordure assolate."
],
problemi: [
{ nome: "Marciume", sintomo: "Ciuffi che seccano in inverno.", rimedio: "Drenaggio, terreno sabbioso, niente pacciamatura umida a contatto." }
]
},
{
id: "menta",
nome: "Menta",
sinonimi: ["mentuccia", "menta piperita"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9],
raccolta: [5, 6, 7, 8, 9, 10],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.6, sviluppo: 0.85, piena: 1.05, finale: 0.9 },
profonditaRadici: 0.5,
fattoreDeplezione: 0.3,
sensibilitaIdrica: "alta",
fasiCritiche: "Tutta l'estate: è l'aromatica che beve di più.",
sesto: "40 cm in tutte le direzioni",
piantePerMq: 6,
esposizione: "mezz'ombra",
phIdeale: [6, 7.5],
consociazioni: ["Cavolo", "Pomodoro"],
daEvitare: ["Prezzemolo", "Camomilla"],
concimazione: "Compost in primavera.",
cure: [
"Coltivala SEMPRE in vaso o in un contenitore interrato: gli stoloni invadono tutto l'orto in due stagioni.",
"Vuole terreno fresco e mezz'ombra, al contrario delle altre aromatiche.",
"Taglia spesso: i getti giovani sono i più profumati.",
"Dividi il cespo ogni 2-3 anni."
],
problemi: [
{ nome: "Ruggine della menta", sintomo: "Pustole arancioni sotto le foglie.", rimedio: "Taglia tutto a raso ed elimina i residui: ricaccia pulita." }
]
},
{
id: "origano",
nome: "Origano",
sinonimi: ["maggiorana"],
categoria: "aromatica",
famiglia: "Labiate",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9],
raccolta: [6, 7, 8, 9],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.65, finale: 0.5 },
profonditaRadici: 0.4,
fattoreDeplezione: 0.6,
sensibilitaIdrica: "bassa",
fasiCritiche: "Nessuna.",
sesto: "30 cm in tutte le direzioni",
piantePerMq: 9,
esposizione: "pieno sole",
phIdeale: [6.5, 8],
consociazioni: ["Pomodoro", "Cavolo"],
daEvitare: [],
concimazione: "Nessuna.",
cure: [
"Raccogli i rametti in piena fioritura e falli seccare a mazzi all'ombra.",
"Taglia il cespo a metà a fine estate.",
"Più il terreno è arido e sassoso, più è profumato."
],
problemi: []
},
{
id: "erba-cipollina",
nome: "Erba cipollina",
sinonimi: ["cipollina"],
categoria: "aromatica",
famiglia: "Liliacee",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 9],
raccolta: [4, 5, 6, 7, 8, 9, 10],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.0, finale: 0.9 },
profonditaRadici: 0.3,
fattoreDeplezione: 0.3,
sensibilitaIdrica: "media",
fasiCritiche: "Estate.",
sesto: "25 cm in tutte le direzioni",
piantePerMq: 16,
esposizione: "mezz'ombra",
phIdeale: [6, 7],
consociazioni: ["Carota", "Pomodoro", "Rosa", "Fragola"],
daEvitare: ["Fagiolo", "Pisello"],
concimazione: "Compost in primavera.",
cure: [
"Taglia a 3 cm dal suolo: ricaccia in due settimane.",
"I fiori viola sono commestibili e ottimi in insalata.",
"Dividi il cespo ogni 3 anni."
],
problemi: []
},
{
id: "alloro",
nome: "Alloro",
sinonimi: ["lauro"],
categoria: "aromatica",
famiglia: "Lauracee",
emoji: "🌿",
perenne: true,
trapianto: [3, 4, 10],
raccolta: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.5, sviluppo: 0.6, piena: 0.7, finale: 0.65 },
profonditaRadici: 1.0,
fattoreDeplezione: 0.55,
sensibilitaIdrica: "bassa",
fasiCritiche: "Solo i primi due anni dopo l'impianto.",
sesto: "150 cm",
piantePerMq: 0.4,
esposizione: "pieno sole",
phIdeale: [6, 8],
consociazioni: [],
daEvitare: [],
concimazione: "Compost ogni primavera.",
cure: [
"Ottimo come siepe frangivento per proteggere l'orto.",
"Tosalo in maggio e a fine agosto per tenerlo compatto.",
"Una volta adulto non ha praticamente bisogno di irrigazione."
],
problemi: [
{ nome: "Cocciniglia e fumaggine", sintomo: "Scudetti sui rami, patina nera appiccicosa.", rimedio: "Olio bianco in inverno, sfoltimento dei rami interni." }
]
}
]
+630
View File
@@ -0,0 +1,630 @@
import type { Coltura } from "./types"
/**
* Frutteto. Per le piante perenni il kc varia con la stagione: "iniziale" vale al
* germogliamento, "piena" in estate a chioma completa, "finale" dopo la raccolta.
* Fuori dalla stagione vegetativa il fabbisogno è quasi nullo (pianta in riposo).
*/
export const FRUTTA: Coltura[] = [
{
id: "melo",
nome: "Melo",
sinonimi: ["mela", "mele", "meli"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍎",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [8, 9, 10],
potatura: [1, 2, 6],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Allegagione (aprile-maggio) e ingrossamento dei frutti (giugno-luglio).",
sesto: "Vaso: 400 cm. Fusetto: 120 cm sulla fila, 350 cm tra le file.",
piantePerMq: 0.1,
esposizione: "pieno sole",
phIdeale: [6, 7],
consociazioni: ["Erba medica", "Tagete", "Aglio ai piedi"],
daEvitare: ["Noce (juglone)", "Prato fitto sotto la chioma"],
concimazione: "Letame maturo in autunno nell'area di gocciolamento; azoto alla ripresa, potassio in estate.",
cure: [
"Potatura invernale di formazione e potatura verde a giugno per far entrare la luce.",
"Dirada i frutticini a giugno lasciandone uno per mazzetto ogni 15 cm: frutti più grossi e niente alternanza di produzione.",
"Servono due varietà compatibili per l'impollinazione incrociata.",
"Irriga in profondità e di rado: le radici arrivano a 1,5 m. Bagnature superficiali fanno solo danni.",
"Fascia di terreno nudo o pacciamata di 1 m attorno al tronco nei primi anni."
],
problemi: [
{ nome: "Ticchiolatura", sintomo: "Macchie olivastre su foglie e frutti dopo primavere piovose.", rimedio: "Rame o zolfo alla ripresa, raccolta e distruzione delle foglie cadute, varietà resistenti." },
{ nome: "Carpocapsa", sintomo: "Frutti bacati con foro e rosura.", rimedio: "Trappole a feromoni, confusione sessuale, virus della granulosi." },
{ nome: "Afide grigio", sintomo: "Foglie accartocciate agli apici in primavera.", rimedio: "Olio bianco in inverno, sapone molle, coccinelle." }
],
note: "Un melo adulto in piena estate può consumare 40-70 litri al giorno."
},
{
id: "pero",
nome: "Pero",
sinonimi: ["pera", "pere", "peri"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍐",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [7, 8, 9],
potatura: [1, 2, 6],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Allegagione e ingrossamento.",
sesto: "350-400 cm",
piantePerMq: 0.1,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: ["Tagete", "Aglio"],
daEvitare: ["Noce"],
concimazione: "Come il melo, ma più contenuta in azoto: gli eccessi favoriscono il colpo di fuoco.",
cure: [
"Raccogli le pere leggermente acerbe e falle maturare in casa: sull'albero diventano farinose.",
"Potatura di formazione a palmetta o fusetto.",
"Dirada lasciando 1-2 frutti per mazzetto."
],
problemi: [
{ nome: "Colpo di fuoco batterico", sintomo: "Rami che seccano di colpo con aspetto bruciato e uncino apicale.", rimedio: "Taglia 40 cm sotto la lesione disinfettando le lame; è malattia da denuncia obbligatoria." },
{ nome: "Psilla", sintomo: "Melata appiccicosa e fumaggine nera.", rimedio: "Olio bianco, argilla caolinica, equilibrio dell'azoto." }
]
},
{
id: "pesco",
nome: "Pesco e nettarina",
sinonimi: ["pesca", "pesche", "nettarina", "nettarine", "pescanoce", "percoca"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍑",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [6, 7, 8],
potatura: [2, 8],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 },
profonditaRadici: 1.2,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "alta",
fasiCritiche: "Le 4 settimane prima della raccolta: è lì che il frutto fa il 60% del peso.",
sesto: "400 cm",
piantePerMq: 0.1,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: ["Aglio", "Tagete"],
daEvitare: ["Reimpianto su vecchio pesco (stanchezza del terreno)"],
concimazione: "Azoto alla ripresa, potassio da giugno. Letame in autunno.",
cure: [
"Pota ogni anno e con decisione: il pesco fruttifica solo sui rami dell'anno precedente.",
"Dirada i frutti a 15-20 cm l'uno dall'altro entro maggio.",
"Trattamento con rame alla caduta delle foglie e alla rottura delle gemme contro la bolla.",
"Vita produttiva più breve degli altri fruttiferi: 15-20 anni.",
"Non tollera il ristagno: piantalo su un dosso se il terreno è pesante."
],
problemi: [
{ nome: "Bolla del pesco", sintomo: "Foglie primaverili bollose, rosse e deformate.", rimedio: "Rame a fine inverno PRIMA della schiusa delle gemme: dopo non serve più a nulla." },
{ nome: "Cidia e mosca", sintomo: "Frutti bacati.", rimedio: "Trappole a feromoni, raccolta dei frutti caduti." },
{ nome: "Gommosi", sintomo: "Colature di resina ambrata sul tronco.", rimedio: "Evita ferite e ristagni, disinfetta i tagli di potatura." }
]
},
{
id: "albicocco",
nome: "Albicocco",
sinonimi: ["albicocca", "albicocche", "crisommola"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍑",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [6, 7],
potatura: [8, 9],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 },
profonditaRadici: 1.3,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Maggio-giugno, ingrossamento.",
sesto: "450 cm",
piantePerMq: 0.08,
esposizione: "pieno sole",
phIdeale: [6.5, 8],
consociazioni: [],
daEvitare: ["Zone con gelate tardive: fiorisce prestissimo"],
concimazione: "Moderata. Tollera bene i terreni calcarei.",
cure: [
"Pota in estate, dopo la raccolta: i tagli invernali favoriscono i cancri rameali.",
"Fiorisce a febbraio-marzo: scegli una posizione riparata dalle gelate tardive.",
"Dirada i frutti se l'allegagione è troppo abbondante."
],
problemi: [
{ nome: "Monilia", sintomo: "Rami e fiori che seccano, frutti mummificati sull'albero.", rimedio: "Elimina le mummie, rame in fioritura, potatura arieggiante." }
]
},
{
id: "susino",
nome: "Susino",
sinonimi: ["susina", "susine", "prugna", "prugne", "prugno"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🫐",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [7, 8, 9],
potatura: [8, 9],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 },
profonditaRadici: 1.2,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Giugno-luglio.",
sesto: "400 cm",
piantePerMq: 0.1,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: [],
daEvitare: [],
concimazione: "Contenuta: tende a produrre troppo e a spezzarsi.",
cure: [
"Dirada abbondantemente: il susino carica troppo e i rami si rompono.",
"Elimina i polloni radicali ogni anno.",
"Molto rustico: è tra i fruttiferi più facili per chi inizia."
],
problemi: [
{ nome: "Cidia del susino", sintomo: "Frutti bacati che cadono presto.", rimedio: "Trappole a feromoni, raccolta dei frutti a terra." },
{ nome: "Afidi", sintomo: "Foglie accartocciate in primavera.", rimedio: "Olio bianco invernale, sapone molle." }
]
},
{
id: "ciliegio",
nome: "Ciliegio",
sinonimi: ["ciliegia", "ciliegie", "visciola", "amarena", "duroni"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍒",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [5, 6, 7],
potatura: [7, 8],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Aprile-maggio. Attenzione: acqua abbondante a ridosso della raccolta spacca le ciliegie.",
sesto: "500 cm (franco) o 300 cm su portinnesto nanizzante",
piantePerMq: 0.06,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: [],
daEvitare: ["Terreni pesanti e asfittici"],
concimazione: "Poca. Il ciliegio non ama le concimazioni azotate spinte.",
cure: [
"Pota SOLO in estate dopo la raccolta: i tagli invernali provocano gommosi.",
"Riduci l'acqua nelle 2 settimane prima della raccolta, altrimenti i frutti spaccano.",
"Reti anti-uccello: altrimenti la raccolta la fanno i merli.",
"La maggior parte delle varietà ha bisogno di un impollinatore."
],
problemi: [
{ nome: "Mosca del ciliegio", sintomo: "Vermetti bianchi nella polpa.", rimedio: "Trappole cromotropiche gialle da metà maggio, varietà precoci." },
{ nome: "Cracking", sintomo: "Frutti spaccati dopo la pioggia.", rimedio: "Irrigazione regolare senza picchi, teli di copertura." }
]
},
{
id: "fico",
nome: "Fico",
sinonimi: ["fichi", "ficaia", "fioroni"],
categoria: "frutta",
famiglia: "Moracee",
emoji: "🫒",
perenne: true,
trapianto: [10, 11, 3],
raccolta: [6, 8, 9],
potatura: [2, 3],
stagioneVegetativa: [4, 10],
kc: { iniziale: 0.4, sviluppo: 0.65, piena: 0.85, finale: 0.6 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.6,
sensibilitaIdrica: "bassa",
fasiCritiche: "Solo l'ingrossamento estivo, e solo in annate molto siccitose.",
sesto: "500 cm",
piantePerMq: 0.05,
esposizione: "pieno sole",
phIdeale: [6, 8],
consociazioni: [],
daEvitare: ["Vicinanza a muri e tubature: le radici sono invadenti"],
concimazione: "Praticamente nulla. In terreno grasso fa legno e non frutti.",
cure: [
"Il più facile dei fruttiferi: si accontenta di tutto e si moltiplica per talea in un bicchiere d'acqua.",
"Potatura leggera: i fioroni nascono sul legno dell'anno prima, i forniti sui getti nuovi.",
"Troppa acqua rende i frutti insipidi e acquosi.",
"Attenzione alle radici: piantalo lontano da muri e pavimentazioni."
],
problemi: [
{ nome: "Marciume dei frutti", sintomo: "Fichi che fermentano sulla pianta con l'umidità.", rimedio: "Raccogli tempestivamente, arieggia la chioma." }
]
},
{
id: "vite",
nome: "Vite da tavola",
sinonimi: ["uva", "vigna", "vigneto", "filare", "grappolo"],
categoria: "frutta",
famiglia: "Vitacee",
emoji: "🍇",
perenne: true,
trapianto: [11, 12, 1, 2, 3],
raccolta: [8, 9, 10],
potatura: [1, 2, 6],
stagioneVegetativa: [4, 10],
kc: { iniziale: 0.3, sviluppo: 0.6, piena: 0.85, finale: 0.45 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.45,
sensibilitaIdrica: "bassa",
fasiCritiche: "Dalla fioritura all'invaiatura. Dopo l'invaiatura si asciuga per concentrare gli zuccheri.",
sesto: "100-150 cm sulla fila, 250 cm tra le file",
piantePerMq: 0.4,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: ["Rosa (indicatore precoce di oidio)", "Senape come sovescio"],
daEvitare: ["Cavoli"],
concimazione: "Molto contenuta: letame ogni 3 anni. L'eccesso di azoto porta malattie e acini che spaccano.",
cure: [
"Potatura secca in inverno (Guyot o cordone speronato) e potatura verde in giugno.",
"Sfogliatura della zona dei grappoli a luglio: sole e aria riducono la botrite.",
"Trattamenti a zolfo (oidio) e rame (peronospora) scanditi dalle piogge.",
"Sospendi l'irrigazione dall'invaiatura in poi: è quello che fa l'uva dolce."
],
problemi: [
{ nome: "Peronospora", sintomo: "Macchie d'olio sulle foglie, muffa bianca sotto, grappoli bruniti.", rimedio: "Rame preventivo secondo la regola dei tre dieci (10 cm di germoglio, 10 mm di pioggia, 10 °C)." },
{ nome: "Oidio", sintomo: "Polvere grigia su foglie e acini che spaccano.", rimedio: "Zolfo dalla ripresa vegetativa, ogni 10-12 giorni in stagione." },
{ nome: "Botrite", sintomo: "Muffa grigia sui grappoli maturi.", rimedio: "Sfogliatura, grappoli spargoli, raccolta tempestiva." }
]
},
{
id: "olivo",
nome: "Olivo",
sinonimi: ["ulivo", "oliva", "olive", "olivi"],
categoria: "frutta",
famiglia: "Oleacee",
emoji: "🫒",
perenne: true,
trapianto: [3, 4, 10],
raccolta: [10, 11, 12],
potatura: [3, 4],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.6, sviluppo: 0.65, piena: 0.7, finale: 0.65 },
profonditaRadici: 1.5,
fattoreDeplezione: 0.65,
sensibilitaIdrica: "bassa",
fasiCritiche: "Fioritura (maggio) e indurimento del nocciolo (luglio): anche solo 2-3 irrigazioni di soccorso raddoppiano la resa.",
sesto: "600 cm",
piantePerMq: 0.03,
esposizione: "pieno sole",
phIdeale: [6.5, 8.5],
consociazioni: ["Fava e favino come sovescio"],
daEvitare: ["Ristagni idrici"],
concimazione: "Azoto in primavera, potassio in estate. Non esagerare.",
cure: [
"Potatura a vaso policonico: la luce deve arrivare dentro la chioma.",
"Anche in asciutta produce, ma 2-3 irrigazioni di soccorso in luglio-agosto fanno una differenza enorme.",
"Alterna: potando ogni anno si riduce il fenomeno.",
"Raccogli all'invaiatura (da verde a violaceo) per l'olio migliore."
],
problemi: [
{ nome: "Mosca dell'olivo", sintomo: "Fori nelle drupe, cascola, olio con acidità alta.", rimedio: "Trappole cromotropiche e a feromoni da luglio, argilla caolinica, anticipo della raccolta." },
{ nome: "Occhio di pavone", sintomo: "Macchie tonde concentriche sulle foglie, defogliazione.", rimedio: "Rame dopo la raccolta e in primavera, potatura arieggiante." }
]
},
{
id: "limone",
nome: "Limone e agrumi",
sinonimi: ["arancio", "arancia", "arance", "mandarino", "mandarini", "agrume", "agrumi", "clementina", "cedro", "pompelmo", "limoni"],
categoria: "frutta",
famiglia: "Rutacee",
emoji: "🍋",
perenne: true,
trapianto: [3, 4, 5],
raccolta: [11, 12, 1, 2, 3, 4],
potatura: [3, 4],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.65, sviluppo: 0.65, piena: 0.7, finale: 0.7 },
profonditaRadici: 1.0,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Tutta la stagione calda: gli agrumi hanno radici superficiali e soffrono in fretta.",
sesto: "400 cm in piena terra; vaso da almeno 50 litri",
piantePerMq: 0.1,
esposizione: "pieno sole",
phIdeale: [6, 7],
consociazioni: [],
daEvitare: ["Terreni calcarei (clorosi ferrica)", "Ristagni"],
concimazione: "Concime specifico per agrumi da marzo a settembre, ogni 3-4 settimane. Ferro chelato contro la clorosi.",
cure: [
"In vaso: rinvaso ogni 2-3 anni e drenaggio perfetto. È il contenitore, non il clima, il limite principale.",
"Sotto i -2 °C proteggi con tessuto non tessuto o ricovera il vaso.",
"Potatura leggera dopo la raccolta: togli i rami secchi, i succhioni e i polloni sotto il punto d'innesto.",
"Foglie gialle con nervature verdi = carenza di ferro, non sete."
],
problemi: [
{ nome: "Cocciniglia", sintomo: "Scudetti sui rami, melata e fumaggine nera.", rimedio: "Olio bianco a fine inverno, lavaggi con sapone molle." },
{ nome: "Minatrice serpentina", sintomo: "Gallerie argentee sulle foglie giovani.", rimedio: "Colpisce i getti nuovi: tollerabile su piante adulte, olio di neem sui giovani." },
{ nome: "Clorosi ferrica", sintomo: "Foglie gialle a nervature verdi.", rimedio: "Ferro chelato, acidificazione del substrato, acqua non troppo calcarea." }
]
},
{
id: "kiwi",
nome: "Kiwi (actinidia)",
sinonimi: ["actinidia", "kiwi giallo"],
categoria: "frutta",
famiglia: "Actinidiacee",
emoji: "🥝",
perenne: true,
trapianto: [11, 12, 3],
raccolta: [10, 11],
potatura: [1, 2, 6, 7],
stagioneVegetativa: [4, 11],
kc: { iniziale: 0.4, sviluppo: 0.75, piena: 1.05, finale: 0.95 },
profonditaRadici: 0.7,
fattoreDeplezione: 0.35,
sensibilitaIdrica: "alta",
fasiCritiche: "Tutta l'estate: è il fruttifero che beve di più in assoluto.",
sesto: "400 cm sulla fila, 500 cm tra le file, su pergola o tendone",
piantePerMq: 0.05,
esposizione: "pieno sole",
phIdeale: [5.5, 6.8],
consociazioni: [],
daEvitare: ["Terreni calcarei", "Posizioni ventose"],
concimazione: "Esigente: azoto in primavera, potassio in estate, sostanza organica abbondante.",
cure: [
"Serve una pianta maschio ogni 6-8 femmine.",
"Radici superficiali e grande superficie fogliare: richiede molta acqua e non tollera la siccità nemmeno per pochi giorni.",
"Pacciamatura spessa obbligatoria.",
"Struttura di sostegno robusta: una pianta adulta pesa quintali."
],
problemi: [
{ nome: "Batteriosi (PSA)", sintomo: "Cancri con essudato rossastro, disseccamento dei tralci.", rimedio: "Rame, potature con attrezzi disinfettati e solo in tempo asciutto, varietà tolleranti." }
]
},
{
id: "lampone",
nome: "Lampone",
sinonimi: ["lamponi", "mora", "more", "rovo", "ribes"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🫐",
perenne: true,
trapianto: [10, 11, 2, 3],
raccolta: [6, 7, 9, 10],
potatura: [11, 12, 2],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.4, sviluppo: 0.75, piena: 1.0, finale: 0.85 },
profonditaRadici: 0.5,
fattoreDeplezione: 0.35,
sensibilitaIdrica: "alta",
fasiCritiche: "Fioritura e maturazione. Radici superficiali: turni frequenti.",
sesto: "50 cm sulla fila, 200 cm tra le file",
piantePerMq: 1,
esposizione: "mezz'ombra",
phIdeale: [5.5, 6.5],
consociazioni: ["Aglio", "Tagete"],
daEvitare: ["Solanacee (verticillium)"],
concimazione: "Compost e pacciamatura organica ogni primavera.",
cure: [
"Unifere: taglia a terra i tralci che hanno fruttificato subito dopo la raccolta.",
"Rifiorenti: taglia tutto a raso in inverno, produrranno in autunno sui getti nuovi.",
"Pacciamatura permanente di paglia o cippato: le radici sono a 20 cm e soffrono il secco.",
"Serve una struttura con due fili per contenere i tralci."
],
problemi: [
{ nome: "Botrite", sintomo: "Frutti ammuffiti.", rimedio: "Raccolta ogni 2 giorni, sfoltimento dei polloni, niente acqua sui frutti." }
]
},
{
id: "mirtillo",
nome: "Mirtillo",
sinonimi: ["mirtilli", "blueberry"],
categoria: "frutta",
famiglia: "Ericacee",
emoji: "🫐",
perenne: true,
trapianto: [10, 11, 3],
raccolta: [6, 7, 8],
potatura: [1, 2],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.4, sviluppo: 0.7, piena: 1.0, finale: 0.85 },
profonditaRadici: 0.4,
fattoreDeplezione: 0.35,
sensibilitaIdrica: "alta",
fasiCritiche: "Tutta la stagione: radici fascicolate e superficialissime, senza peli radicali.",
sesto: "120 cm sulla fila, 250 cm tra le file",
piantePerMq: 0.7,
esposizione: "pieno sole",
phIdeale: [4.5, 5.5],
consociazioni: ["Altre acidofile"],
daEvitare: ["Terreno calcareo", "Acqua di rubinetto dura"],
concimazione: "Concime per acidofile. Mai calce, mai cenere.",
cure: [
"Il pH è tutto: sopra 6 la pianta ingiallisce e muore lentamente. Coltivalo in torba acida o in vaso.",
"Irriga con acqua piovana: l'acqua calcarea alza il pH del substrato in pochi mesi.",
"Pacciamatura di aghi di pino o corteccia.",
"Pianta 2-3 varietà diverse: l'impollinazione incrociata aumenta la resa."
],
problemi: [
{ nome: "Clorosi", sintomo: "Foglie gialle con nervature verdi.", rimedio: "pH troppo alto: acidifica, usa acqua piovana, ferro chelato." }
]
},
{
id: "melograno",
nome: "Melograno",
sinonimi: ["melagrana", "melagrane", "melograni", "granato"],
categoria: "frutta",
famiglia: "Lythracee",
emoji: "🍎",
perenne: true,
trapianto: [11, 12, 3],
raccolta: [9, 10, 11],
potatura: [2, 3],
stagioneVegetativa: [4, 11],
kc: { iniziale: 0.4, sviluppo: 0.65, piena: 0.85, finale: 0.65 },
profonditaRadici: 1.2,
fattoreDeplezione: 0.6,
sensibilitaIdrica: "bassa",
fasiCritiche: "Ingrossamento estivo: irregolarità idrica = frutti spaccati.",
sesto: "400 cm",
piantePerMq: 0.08,
esposizione: "pieno sole",
phIdeale: [6, 8],
consociazioni: [],
daEvitare: ["Ristagni"],
concimazione: "Contenuta.",
cure: [
"Regolarità nell'irrigazione: gli sbalzi fanno spaccare i frutti, che poi marciscono.",
"Elimina i polloni ogni anno, allevalo a cespuglio con 3-4 branche.",
"Molto resistente alla siccità e alla salinità: ideale al Sud."
],
problemi: [
{ nome: "Cracking dei frutti", sintomo: "Frutti spaccati a settembre.", rimedio: "Turni regolari da agosto, raccolta anticipata se arrivano piogge." }
]
},
{
id: "cachi",
nome: "Cachi (kaki)",
sinonimi: ["kaki", "caco", "loto", "diospero", "cachi vaniglia"],
categoria: "frutta",
famiglia: "Ebenacee",
emoji: "🍊",
perenne: true,
trapianto: [11, 12, 3],
raccolta: [10, 11, 12],
potatura: [1, 2],
stagioneVegetativa: [4, 11],
kc: { iniziale: 0.4, sviluppo: 0.7, piena: 0.95, finale: 0.75 },
profonditaRadici: 1.2,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Giugno-agosto: la carenza provoca la cascola dei frutti.",
sesto: "500 cm",
piantePerMq: 0.05,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: [],
daEvitare: [],
concimazione: "Moderata, letame in autunno.",
cure: [
"Potatura leggera: fruttifica sui getti dell'anno nati da gemme apicali, se poti troppo elimini la produzione.",
"La cascola estiva dei frutticini è spesso stress idrico: mantieni turni regolari.",
"Raccogli a fine autunno e lascia ammezzire in cassetta con una mela."
],
problemi: [
{ nome: "Cascola dei frutti", sintomo: "Frutticini che cadono a giugno-luglio.", rimedio: "Irrigazione regolare, equilibrio dell'azoto, diradamento." }
]
},
{
id: "nocciolo",
nome: "Nocciolo",
sinonimi: ["nocciola", "nocciole", "avellana"],
categoria: "frutta",
famiglia: "Betulacee",
emoji: "🌰",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [8, 9],
potatura: [1, 2],
stagioneVegetativa: [3, 10],
kc: { iniziale: 0.4, sviluppo: 0.7, piena: 0.95, finale: 0.75 },
profonditaRadici: 1.0,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Giugno-luglio, riempimento del seme.",
sesto: "450 cm",
piantePerMq: 0.06,
esposizione: "mezz'ombra",
phIdeale: [6, 7.5],
consociazioni: [],
daEvitare: [],
concimazione: "Letame ogni autunno.",
cure: [
"Servono almeno due varietà per l'impollinazione (fiorisce in pieno inverno).",
"Allevalo a cespuglio con 5-6 polloni, rinnovandone uno all'anno.",
"Raccogli le nocciole cadute a terra e falle asciugare bene prima di conservarle."
],
problemi: [
{ nome: "Balanino", sintomo: "Nocciole vuote con un forellino.", rimedio: "Raccolta e distruzione delle nocciole cadute precocemente." },
{ nome: "Cimice asiatica", sintomo: "Semi con aree brune e amare (cimiciato).", rimedio: "Trappole, reti, gestione del cotico erboso." }
]
},
{
id: "noce",
nome: "Noce",
sinonimi: ["noci", "gheriglio"],
categoria: "frutta",
famiglia: "Juglandacee",
emoji: "🌰",
perenne: true,
trapianto: [11, 12, 1, 2],
raccolta: [9, 10],
potatura: [8, 9],
stagioneVegetativa: [4, 10],
kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.1, finale: 0.7 },
profonditaRadici: 1.8,
fattoreDeplezione: 0.5,
sensibilitaIdrica: "media",
fasiCritiche: "Luglio-agosto.",
sesto: "800 cm",
piantePerMq: 0.02,
esposizione: "pieno sole",
phIdeale: [6.5, 7.5],
consociazioni: [],
daEvitare: ["Qualsiasi coltura sotto la chioma: il juglone inibisce le altre piante"],
concimazione: "Letame in autunno; pianta longeva e poco esigente da adulta.",
cure: [
"Non piantarlo vicino all'orto: il juglone delle radici inibisce pomodori, patate e meli.",
"Pota solo in agosto-settembre: in inverno e primavera 'piange' linfa senza fermarsi.",
"Radici profondissime: da adulto quasi non si irriga."
],
problemi: [
{ nome: "Antracnosi", sintomo: "Macchie nere su foglie e mallo dopo primavere piovose.", rimedio: "Rame alla ripresa, raccolta delle foglie cadute." }
]
},
{
id: "nespolo",
nome: "Nespolo del Giappone",
sinonimi: ["nespola", "nespole", "nespolo giapponese"],
categoria: "frutta",
famiglia: "Rosacee",
emoji: "🍑",
perenne: true,
trapianto: [3, 4, 10],
raccolta: [4, 5],
potatura: [6, 7],
stagioneVegetativa: [3, 11],
kc: { iniziale: 0.5, sviluppo: 0.65, piena: 0.8, finale: 0.7 },
profonditaRadici: 1.0,
fattoreDeplezione: 0.55,
sensibilitaIdrica: "bassa",
fasiCritiche: "Marzo-aprile, ingrossamento dei frutti.",
sesto: "500 cm",
piantePerMq: 0.05,
esposizione: "pieno sole",
phIdeale: [6, 7.5],
consociazioni: [],
daEvitare: [],
concimazione: "Poca, in primavera dopo la raccolta.",
cure: [
"Fiorisce in autunno e matura in primavera: è il primo frutto dell'anno.",
"Dirada i frutticini per averli più grossi.",
"Pota subito dopo la raccolta, a maggio-giugno.",
"Sempreverde, ornamentale e molto rustico."
],
problemi: [
{ nome: "Colpo di fuoco", sintomo: "Getti anneriti e ripiegati.", rimedio: "Taglio ampio sotto la lesione, attrezzi disinfettati." }
]
}
]
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
import type { Coltura } from "./types"
import { ORTAGGI } from "./colture.ortaggi"
import { AROMATICHE } from "./colture.aromatiche"
import { FRUTTA } from "./colture.frutta"
export const COLTURE: Coltura[] = [...ORTAGGI, ...AROMATICHE, ...FRUTTA].sort((a, b) =>
a.nome.localeCompare(b.nome, "it")
)
const INDICE = new Map(COLTURE.map((c) => [c.id, c]))
export function coltura(id: string): Coltura | undefined {
return INDICE.get(id)
}
/** Toglie accenti e maiuscole: "petonciano" e "Petónciano" devono pescare lo stesso. */
function normalizza(t: string): string {
return t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim()
}
/**
* Ricerca per nome, sinonimi regionali e famiglia botanica. In Italia la stessa
* pianta cambia nome ogni cento chilometri — cocomero e anguria, zucchino e
* zucchina, ramerino e rosmarino — e chi cerca usa la parola di casa sua.
*/
export function corrisponde(c: Coltura, query: string): boolean {
const q = normalizza(query)
if (!q) return true
if (normalizza(c.nome).includes(q)) return true
if ((c.sinonimi ?? []).some((s) => normalizza(s).includes(q))) return true
// La famiglia botanica si cerca solo con parole lunghe: altrimenti "mora"
// pescherebbe il fico, che è una Moracea.
return q.length >= 5 && normalizza(c.famiglia).includes(q)
}
/** Vero se la coltura va mostrata sotto quella categoria, anche come seconda casa. */
export function nellaCategoria(c: Coltura, categoria: string): boolean {
return categoria === "tutte" || c.categoria === categoria || Boolean(c.ancheIn?.includes(categoria as Coltura["categoria"]))
}
export const MESI = [
"gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno",
"luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre",
]
export const MESI_BREVI = ["G", "F", "M", "A", "M", "G", "L", "A", "S", "O", "N", "D"]
+402
View File
@@ -0,0 +1,402 @@
import type { Aiuola, Coltura, Evento, Impostazioni, Metodo, Piantagione, TipoSuolo } from "./types"
import type { GiornoMeteo } from "./meteo"
import { KPA_CAPACITA_DI_CAMPO } from "./sonde"
import { isoOggi } from "./meteo"
/**
* Motore di irrigazione a bilancio idrico (metodo FAO-56).
*
* L'idea: il terreno è un serbatoio. Ogni giorno la pianta ne toglie acqua
* (evapotraspirazione ETc = ET0 × Kc), la pioggia e le irrigazioni lo ricaricano.
* Sappiamo quanto è grande il serbatoio (tipo di suolo × profondità delle radici)
* e quanto se ne può svuotare prima che la pianta soffra (fattore di deplezione).
* Quando il conto arriva a quella soglia, è il momento di irrigare — e sappiamo
* anche di quanti millimetri, perché è esattamente l'acqua mancante.
*
* Ricostruiamo il bilancio sui 21 giorni passati con i dati meteo reali, poi lo
* proiettiamo in avanti sulle previsioni per dire quando toccherà la prossima volta.
*/
/** Acqua disponibile per metro di profondità, in mm (capacità di campo − punto di appassimento). */
const ACQUA_DISPONIBILE: Record<TipoSuolo, number> = {
sabbioso: 80,
"franco-sabbioso": 120,
franco: 160,
"franco-argilloso": 190,
argilloso: 200,
}
export const DESCRIZIONE_SUOLO: Record<TipoSuolo, string> = {
sabbioso: "Scorre tra le dita, drena in fretta, si asciuga in 2-3 giorni. Turni brevi e dosi piccole.",
"franco-sabbioso": "Leggero ma con un po' di corpo. Buon compromesso, turni medio-brevi.",
franco: "Il terreno ideale dell'orto: trattiene bene senza ristagnare.",
"franco-argilloso": "Pesante ma fertile, trattiene molta acqua. Turni lunghi e dosi generose.",
argilloso: "Compatto, si spacca da secco e diventa colloso da bagnato. Irrigazioni rade e lente per non far scorrere via l'acqua.",
}
/** Quanta acqua erogata arriva davvero alle radici. */
export const EFFICIENZA_PREDEFINITA = {
goccia: 0.9,
subirrigazione: 0.95,
manuale: 0.75,
aspersione: 0.65,
}
/**
* Soglia a cui conviene intervenire, che non coincide con la soglia di stress.
* Con la goccia si tiene un bulbo umido costante: si bagna a metà della riserva
* utile, più spesso e meno per volta. Con un turno manuale o per aspersione,
* che si dà di rado, si lascia invece consumare tutta la riserva disponibile.
*/
export function sogliaIntervento(metodo: Metodo, raw: number): number {
if (metodo === "goccia" || metodo === "subirrigazione") return raw * 0.55
if (metodo === "aspersione") return raw * 0.85
return raw
}
export type Stato = "ottimale" | "attenzione" | "critico" | "eccesso" | "riposo"
export interface Bilancio {
data: string
et0: number
kc: number
etc: number
pioggia: number
pioggiaEfficace: number
irrigazione: number
/** Acqua mancante rispetto alla capacità di campo, mm. */
deficit: number
previsione: boolean
}
export interface Consiglio {
piantagioneId: string
coltura: Coltura
aiuola: Aiuola
/** Giorni dalla messa a dimora. */
eta: number
fase: string
kc: number
/** Consumo di oggi, mm (= litri per m²). */
etcOggi: number
litriGiornoPerPianta: number
/** Capacità totale del serbatoio suolo esplorato dalle radici, mm. */
taw: number
/** Quota consumabile senza stress, mm. */
raw: number
/** Millimetri di consumo a cui conviene far partire il turno, dato il metodo. */
soglia: number
deficit: number
/** 0-100: quanto della riserva utile è stata consumata. */
consumo: number
stato: Stato
irrigareOggi: boolean
/** Dose netta da riportare alla capacità di campo, mm. */
doseMm: number
/** Litri effettivamente da erogare sull'aiuola, considerando l'efficienza del metodo. */
doseLitri: number
minuti: number | null
giorniAllaProssima: number | null
pioggiaPrevista48h: number
/** Superficie effettivamente occupata dalla coltura, m². */
areaBagnata: number
/** Vero finché non c'è nessuna irrigazione registrata a cui ancorare il bilancio. */
stimaProvvisoria: boolean
motivazione: string
serie: Bilancio[]
}
/** Fase fenologica e Kc del giorno, per annuali (dai giorni) e perenni (dal mese). */
function kcDelGiorno(c: Coltura, giorniDaImpianto: number, mese: number): { kc: number; fase: string } {
if (c.perenne) {
const [inizio, fine] = c.stagioneVegetativa ?? [3, 10]
const inStagione = inizio <= fine ? mese >= inizio && mese <= fine : mese >= inizio || mese <= fine
if (!inStagione) return { kc: c.kc.iniziale * 0.35, fase: "riposo vegetativo" }
const durata = inizio <= fine ? fine - inizio : 12 - inizio + fine
const avanzamento = (inizio <= fine ? mese - inizio : (mese - inizio + 12) % 12) / Math.max(durata, 1)
if (avanzamento < 0.2) return { kc: c.kc.iniziale, fase: "germogliamento" }
if (avanzamento < 0.4) return { kc: c.kc.sviluppo, fase: "sviluppo della chioma" }
if (avanzamento < 0.8) return { kc: c.kc.piena, fase: "piena vegetazione" }
return { kc: c.kc.finale, fase: "post-raccolta" }
}
const f = c.fasiGiorni ?? { iniziale: 25, sviluppo: 35, piena: 45, finale: 25 }
const g = giorniDaImpianto
if (g < 0) return { kc: 0, fase: "non ancora messa a dimora" }
if (g <= f.iniziale) return { kc: c.kc.iniziale, fase: "attecchimento" }
if (g <= f.iniziale + f.sviluppo) {
// Nella fase di sviluppo il Kc sale in modo lineare fino al valore di piena copertura.
const t = (g - f.iniziale) / Math.max(f.sviluppo, 1)
return { kc: c.kc.iniziale + (c.kc.piena - c.kc.iniziale) * t, fase: "sviluppo" }
}
if (g <= f.iniziale + f.sviluppo + f.piena) return { kc: c.kc.piena, fase: "piena produzione" }
const t = Math.min((g - f.iniziale - f.sviluppo - f.piena) / Math.max(f.finale, 1), 1)
return { kc: c.kc.piena + (c.kc.finale - c.kc.piena) * t, fase: "maturazione" }
}
/**
* Non tutta la pioggia entra nel terreno: i primi millimetri bagnano solo foglie e
* superficie ed evaporano, e i grandi acquazzoni scorrono via.
*/
function pioggiaEfficace(mm: number, sottoSerra: boolean): number {
if (sottoSerra) return 0
if (mm <= 2) return 0
const utile = (mm - 2) * 0.85
return Math.min(utile, 40)
}
function giorniTra(a: string, b: string): number {
return Math.round((Date.parse(b) - Date.parse(a)) / 86400000)
}
export function calcolaConsiglio(
p: Piantagione,
c: Coltura,
aiuola: Aiuola,
eventi: Evento[],
meteo: GiornoMeteo[],
imp: Impostazioni
): Consiglio {
const oggi = isoOggi()
const inVaso = aiuola.contenitore === "vaso"
// Dimensione del serbatoio.
const profondita = Math.min(c.profonditaRadici, inVaso ? 0.35 : c.profonditaRadici)
// In vaso il serbatoio non è il terreno ma il terriccio: circa il 30% del suo
// volume è acqua che la pianta può usare. Lo convertiamo in mm dividendo per la
// chioma che quel vaso può sostenere (regola pratica: ~1 m² ogni 50 litri di
// substrato), così il resto del calcolo resta identico alla piena terra.
const areaVaso = Math.min(Math.max((aiuola.litriVaso ?? 20) / 50, 0.05), areaPerPianta(c))
const taw = inVaso && aiuola.litriVaso
? (aiuola.litriVaso * 0.3) / areaVaso
: ACQUA_DISPONIBILE[aiuola.suolo] * profondita
const raw = taw * c.fattoreDeplezione
const soglia = sogliaIntervento(aiuola.metodo, raw)
// Riduzioni: la pacciamatura taglia l'evaporazione dal suolo, la serra taglia
// vento e radiazione (ma esclude del tutto la pioggia).
const fattorePacciamatura = aiuola.pacciamatura ? 1 - imp.risparmioPacciamatura : 1
const fattoreSerra = aiuola.serra ? 0.75 : 1
const irrigazioniPerData = new Map<string, number>()
for (const e of eventi) {
if (e.tipo !== "irrigazione" || !e.mm) continue
if (e.piantagioneId ? e.piantagioneId !== p.id : e.aiuolaId !== aiuola.id) continue
irrigazioniPerData.set(e.data, (irrigazioniPerData.get(e.data) ?? 0) + e.mm)
}
const pioggiaManuale = new Map<string, number>()
for (const e of eventi) {
if (e.tipo === "pioggia" && e.mm) pioggiaManuale.set(e.data, (pioggiaManuale.get(e.data) ?? 0) + e.mm)
}
const efficienza = imp.efficienza[aiuola.metodo] ?? 0.8
// Bilancio giorno per giorno, partendo dal terreno a capacità di campo.
// Il punto di partenza è l'ultima irrigazione registrata: è l'unico momento in
// cui sappiamo con certezza che il terreno era pieno. Senza registrazioni
// risaliamo a tre settimane fa e lo diciamo apertamente all'utente, perché la
// stima resta un'ipotesi finché non arriva il primo dato vero.
// Vale come "serbatoio pieno" solo un'irrigazione abbondante: una bagnatura
// leggera non ricarica il profilo e non può fare da punto di partenza.
const daIrrigazioni = [...irrigazioniPerData.entries()]
.filter(([, mm]) => mm * efficienza >= raw * 0.6)
.map(([data]) => data)
// Una sonda che legge la capacità di campo è l'ancoraggio migliore in assoluto:
// non è una stima di quanto abbiamo dato, è una misura di quanto c'è.
const daSonde = eventi
.filter(
(e) =>
e.tipo === "sonda" &&
e.unita === "kPa" &&
typeof e.quantita === "number" &&
e.quantita <= KPA_CAPACITA_DI_CAMPO &&
(e.piantagioneId ? e.piantagioneId === p.id : e.aiuolaId === aiuola.id)
)
.map((e) => e.data)
const ultimaIrrigazione = [...daIrrigazioni, ...daSonde].sort().pop() ?? null
const inizio = [p.dataMessaADimora, ultimaIrrigazione].filter(Boolean).sort().pop() as string | undefined
let deficit = 0
const serie: Bilancio[] = []
const mese = new Date().getMonth() + 1
for (const g of meteo) {
if (inizio && g.data < inizio) continue
if (g.data < p.dataMessaADimora && !c.perenne) continue
const eta = giorniTra(p.dataMessaADimora, g.data)
const meseGiorno = Number(g.data.slice(5, 7))
const { kc: k } = kcDelGiorno(c, eta, meseGiorno)
const kcGiorno = inVaso ? Math.max(k, 0.35) : k
const etc = g.et0 * kcGiorno * fattorePacciamatura * fattoreSerra
const pio = (pioggiaManuale.get(g.data) ?? g.pioggia)
const peff = pioggiaEfficace(pio, aiuola.serra)
const irr = (irrigazioniPerData.get(g.data) ?? 0) * efficienza
deficit = Math.min(Math.max(deficit + etc - peff - irr, 0), taw)
serie.push({
data: g.data,
et0: g.et0,
kc: kcGiorno,
etc,
pioggia: pio,
pioggiaEfficace: peff,
irrigazione: irrigazioniPerData.get(g.data) ?? 0,
deficit,
previsione: g.previsione,
})
if (g.data === oggi) break
}
const etaOggi = giorniTra(p.dataMessaADimora, oggi)
const { kc: kcGrezzo, fase } = kcDelGiorno(c, etaOggi, mese)
// Una pianta in vaso non va mai in riposo davvero: il terriccio si asciuga
// comunque, e sotto una tettoia non prende nemmeno la pioggia invernale.
// Un agrume in vaso a gennaio beve poco, ma beve.
const kc = inVaso ? Math.max(kcGrezzo, 0.35) : kcGrezzo
const et0Oggi = meteo.find((g) => g.data === oggi)?.et0 ?? 3
const etcOggi = et0Oggi * kc * fattorePacciamatura * fattoreSerra
const deficitOggi = serie.length ? serie[serie.length - 1].deficit : 0
// Proiezione sulle previsioni: quando il serbatoio scende sotto la soglia?
const futuri = meteo.filter((g) => g.data > oggi)
let d = deficitOggi
let giorniAllaProssima: number | null = null
const seriePrevista: Bilancio[] = []
for (let i = 0; i < futuri.length; i++) {
const g = futuri[i]
const meseGiorno = Number(g.data.slice(5, 7))
const { kc: kGrezzo } = kcDelGiorno(c, giorniTra(p.dataMessaADimora, g.data), meseGiorno)
const k = inVaso ? Math.max(kGrezzo, 0.35) : kGrezzo
const etc = g.et0 * k * fattorePacciamatura * fattoreSerra
const peff = pioggiaEfficace(g.pioggia, aiuola.serra)
d = Math.min(Math.max(d + etc - peff, 0), taw)
seriePrevista.push({
data: g.data, et0: g.et0, kc: k, etc, pioggia: g.pioggia,
pioggiaEfficace: peff, irrigazione: 0, deficit: d, previsione: true,
})
if (giorniAllaProssima === null && d >= soglia) giorniAllaProssima = i + 1
}
const pioggiaPrevista48h = futuri.slice(0, 2).reduce((s, g) => s + g.pioggia, 0)
const consumo = soglia > 0 ? Math.min((deficitOggi / soglia) * 100, 130) : 0
let stato: Stato
if (kc < 0.3) stato = "riposo"
else if (consumo >= 100) stato = "critico"
else if (consumo >= 70) stato = "attenzione"
else stato = "ottimale"
// Decisione. La pioggia in arrivo può farci risparmiare un turno, ma solo se
// la pianta non è già oltre la soglia di stress.
const pioggiaSufficiente = pioggiaEfficace(pioggiaPrevista48h, aiuola.serra) >= deficitOggi * 0.8
const irrigareOggi = stato !== "riposo" && consumo >= 90 && !(pioggiaSufficiente && consumo < 110)
// Non ha senso proporre in un colpo solo più acqua di quanta il terreno ne
// trattenga senza perderla in profondità: oltre la riserva utile si irriga in
// due riprese, e il giorno dopo il calcolo lo ricorda da solo.
const terrenoMoltoAsciutto = deficitOggi > raw
const doseMm = irrigareOggi ? Math.min(Math.max(deficitOggi, 0), Math.max(soglia, raw * 0.55)) : 0
// I litri si riferiscono alla superficie che questa coltura occupa davvero, non
// a tutta l'aiuola: in un cassone con insalata, pomodori e un melo le tre dosi
// sono diverse e vanno date in punti diversi.
const areaBagnata = inVaso
? areaVaso * Math.max(p.quantita, 1)
: Math.min(Math.max(p.quantita, 1) * areaPerPianta(c), aiuola.superficie)
const doseLitri = (doseMm / efficienza) * areaBagnata
const minuti = minutiPerDose(aiuola, doseMm / efficienza)
const litriGiornoPerPianta = etcOggi * (inVaso ? areaVaso : areaPerPianta(c))
let motivazione: string
if (stato === "riposo") {
motivazione = "Pianta in riposo vegetativo: consumo minimo, non serve irrigare."
} else if (irrigareOggi && terrenoMoltoAsciutto) {
motivazione = `Il terreno è ben oltre la soglia di stress: mancano ${deficitOggi.toFixed(0)} mm. Dai questi ${doseMm.toFixed(0)} mm in due riprese a distanza di un paio d'ore — tutti insieme scorrerebbero via invece di penetrare — e ripeti domani per finire di ricaricare.`
} else if (irrigareOggi) {
motivazione = `Il terreno è arrivato alla soglia in cui conviene intervenire: mancano ${doseMm.toFixed(0)} mm per riportarlo a posto.`
} else if (pioggiaSufficiente && pioggiaPrevista48h > 2) {
motivazione = `Attesi ${pioggiaPrevista48h.toFixed(0)} mm di pioggia entro 48 ore: coprono il fabbisogno, salta il turno e risparmia acqua.`
} else if (giorniAllaProssima !== null) {
motivazione = `Riserva ancora al ${Math.round(100 - consumo)}%. Con le previsioni attuali la prossima irrigazione servirà tra ${giorniAllaProssima} ${giorniAllaProssima === 1 ? "giorno" : "giorni"}.`
} else {
motivazione = `Riserva ancora al ${Math.round(100 - consumo)}%: nei prossimi 10 giorni, tra pioggia prevista e consumi, non dovrebbe servire irrigare.`
}
return {
piantagioneId: p.id,
coltura: c,
aiuola,
eta: etaOggi,
fase,
kc,
etcOggi,
litriGiornoPerPianta,
taw,
raw,
soglia,
deficit: deficitOggi,
consumo,
stato,
irrigareOggi,
doseMm,
doseLitri,
minuti,
giorniAllaProssima,
pioggiaPrevista48h,
areaBagnata,
stimaProvvisoria: ultimaIrrigazione === null && etaOggi > 21,
motivazione,
serie: [...serie, ...seriePrevista],
}
}
export function areaPerPianta(c: Coltura): number {
return 1 / Math.max(c.piantePerMq, 0.01)
}
/** Momento migliore per bagnare, in base alla temperatura del giorno. */
export function orarioConsigliato(tMax: number): string {
if (tMax >= 28) return "Prima delle 8 del mattino: a metà giornata perderesti fino al 40% per evaporazione, e l'acqua fredda su terreno rovente stressa le radici."
if (tMax >= 18) return "Mattina presto o dopo il tramonto. In estate meglio il mattino: le foglie asciugano in giornata e i funghi non attecchiscono."
return "Metà mattina, quando il terreno si è scaldato un po'. Mai la sera nei mesi freddi: l'acqua ferma di notte favorisce i marciumi."
}
export const COLORI_STATO: Record<Stato, string> = {
ottimale: "var(--ok)",
attenzione: "var(--warn)",
critico: "var(--danger)",
eccesso: "var(--info)",
riposo: "var(--muted-fg)",
}
export const ETICHETTA_STATO: Record<Stato, string> = {
ottimale: "Ben irrigata",
attenzione: "Da tenere d'occhio",
critico: "Irriga oggi",
eccesso: "Acqua in eccesso",
riposo: "In riposo",
}
/** I turni lunghi si leggono meglio in ore: "3 h 20" invece di "200 min". */
export function formattaDurata(minuti: number): string {
if (minuti < 90) return `${minuti} min`
const ore = Math.floor(minuti / 60)
const resto = minuti % 60
return resto < 5 ? `${ore} h` : `${ore} h ${String(resto).padStart(2, "0")}`
}
/**
* Da millimetri a minuti di valvola aperta. La portata si può dichiarare in due
* modi: per metro quadro (la si ricava dai gocciolatori) o come portata totale
* del settore, che si misura in un minuto con un secchio ed è quasi sempre il
* dato più affidabile.
*/
export function minutiPerDose(a: Aiuola, doseMmLorda: number): number | null {
if (a.portataSettoreLh && a.portataSettoreLh > 0) {
return Math.round(((doseMmLorda * a.superficie) / a.portataSettoreLh) * 60)
}
if (a.portataLh && a.portataLh > 0) return Math.round((doseMmLorda / a.portataLh) * 60)
return null
}
+152
View File
@@ -0,0 +1,152 @@
import type { Luogo } from "./types"
/**
* Client Open-Meteo: nessuna chiave API, nessun account.
* Scarichiamo 21 giorni passati (per ricostruire il bilancio idrico reale del
* terreno) e 10 giorni di previsione (per anticipare la prossima irrigazione).
* Il risultato è messo in cache: l'app resta usabile anche offline in giardino.
*/
export interface GiornoMeteo {
data: string
tMax: number
tMin: number
/** Pioggia caduta o prevista, mm. */
pioggia: number
/** Probabilità di pioggia, % (solo previsione). */
probPioggia: number | null
/** Evapotraspirazione di riferimento FAO-56, mm: quanta acqua evapora da un prato standard. */
et0: number
codice: number
vento: number
umidita: number
previsione: boolean
}
export interface DatiMeteo {
luogo: Luogo
aggiornato: number
giorni: GiornoMeteo[]
}
const CACHE_KEY = "pollice-verde:meteo"
const VALIDITA_MS = 3 * 60 * 60 * 1000
export interface RisultatoGeocoding {
nome: string
regione: string
lat: number
lon: number
}
export async function cercaLuogo(query: string): Promise<RisultatoGeocoding[]> {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search")
url.searchParams.set("name", query)
url.searchParams.set("count", "8")
url.searchParams.set("language", "it")
url.searchParams.set("format", "json")
const r = await fetch(url)
if (!r.ok) throw new Error("Ricerca non riuscita")
const j = (await r.json()) as { results?: Array<Record<string, unknown>> }
return (j.results ?? []).map((x) => ({
nome: String(x.name),
regione: [x.admin1, x.country].filter(Boolean).join(", "),
lat: Number(x.latitude),
lon: Number(x.longitude),
}))
}
function leggiCache(): DatiMeteo | null {
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as DatiMeteo) : null
} catch {
return null
}
}
export function meteoInCache(luogo: Luogo | null): DatiMeteo | null {
const c = leggiCache()
if (!c || !luogo) return null
if (Math.abs(c.luogo.lat - luogo.lat) > 0.05 || Math.abs(c.luogo.lon - luogo.lon) > 0.05) return null
return c
}
/** Scarica il meteo, ricadendo sulla cache se la rete non c'è. */
export async function caricaMeteo(luogo: Luogo, forza = false): Promise<DatiMeteo> {
const cache = meteoInCache(luogo)
if (cache && !forza && Date.now() - cache.aggiornato < VALIDITA_MS) return cache
const url = new URL("https://api.open-meteo.com/v1/forecast")
url.searchParams.set("latitude", String(luogo.lat))
url.searchParams.set("longitude", String(luogo.lon))
url.searchParams.set(
"daily",
[
"temperature_2m_max",
"temperature_2m_min",
"precipitation_sum",
"precipitation_probability_max",
"et0_fao_evapotranspiration",
"weather_code",
"wind_speed_10m_max",
"relative_humidity_2m_mean",
].join(",")
)
url.searchParams.set("past_days", "21")
url.searchParams.set("forecast_days", "10")
url.searchParams.set("timezone", "auto")
try {
const r = await fetch(url)
if (!r.ok) throw new Error("Meteo non disponibile")
const j = (await r.json()) as { daily: Record<string, (number | null)[]> & { time: string[] } }
const d = j.daily
const oggi = isoOggi()
const giorni: GiornoMeteo[] = d.time.map((data, i) => ({
data,
tMax: num(d.temperature_2m_max[i]),
tMin: num(d.temperature_2m_min[i]),
pioggia: num(d.precipitation_sum[i]),
probPioggia: d.precipitation_probability_max[i] === null ? null : num(d.precipitation_probability_max[i]),
et0: num(d.et0_fao_evapotranspiration[i]),
codice: num(d.weather_code[i]),
vento: num(d.wind_speed_10m_max[i]),
umidita: num(d.relative_humidity_2m_mean[i]),
previsione: data > oggi,
}))
const dati: DatiMeteo = { luogo, aggiornato: Date.now(), giorni }
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(dati))
} catch {
/* quota piena: pazienza, i dati restano in memoria */
}
return dati
} catch (e) {
if (cache) return cache
throw e
}
}
function num(v: number | null | undefined): number {
return typeof v === "number" && Number.isFinite(v) ? v : 0
}
export function isoOggi(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Descrizione testuale del codice meteo WMO. */
export function descrizioneMeteo(codice: number): { testo: string; icona: string } {
if (codice === 0) return { testo: "Sereno", icona: "☀️" }
if (codice <= 2) return { testo: "Poco nuvoloso", icona: "🌤️" }
if (codice === 3) return { testo: "Coperto", icona: "☁️" }
if (codice <= 48) return { testo: "Nebbia", icona: "🌫️" }
if (codice <= 57) return { testo: "Pioviggine", icona: "🌦️" }
if (codice <= 67) return { testo: "Pioggia", icona: "🌧️" }
if (codice <= 77) return { testo: "Neve", icona: "🌨️" }
if (codice <= 82) return { testo: "Rovesci", icona: "🌧️" }
if (codice <= 86) return { testo: "Rovesci di neve", icona: "🌨️" }
return { testo: "Temporale", icona: "⛈️" }
}
+191
View File
@@ -0,0 +1,191 @@
import type { Aiuola, Impostazioni } from "./types"
import type { Consiglio } from "./irrigazione"
import { minutiPerDose, orarioConsigliato } from "./irrigazione"
import type { GiornoMeteo } from "./meteo"
import { isoOggi } from "./meteo"
/**
* Programma settimanale per una centralina a settori.
*
* Una centralina non ragiona per coltura ma per valvola: tutto ciò che sta sullo
* stesso settore riceve gli stessi millimetri negli stessi giorni. Quindi per
* ogni aiuola scegliamo la coltura che si asciuga prima — quella con meno giorni
* di autonomia — e programmiamo su di lei: le altre riceveranno un po' più
* acqua del necessario, che è l'errore giusto da fare. Il contrario, tarare
* sulla pianta più resistente, farebbe soffrire tutte le altre.
*
* I turni non sono a intervallo fisso: vengono simulati giorno per giorno sulle
* previsioni, così una pioggia in arrivo sposta il turno invece di sprecarlo.
*/
export interface Turno {
data: string
mm: number
litri: number
minuti: number | null
pioggiaQuelGiorno: number
}
export interface ProgrammaSettore {
aiuola: Aiuola
/** La coltura su cui è tarato il programma: la più esigente del settore. */
guida: Consiglio
turni: Turno[]
/** Giorni fra un turno e l'altro, arrotondato. */
intervallo: number | null
minutiTipici: number | null
mmTipici: number
orario: string
/** Pioggia prevista nei 10 giorni, mm. */
pioggiaPrevista: number
/** Colture del settore che avrebbero bisogno di un ritmo molto diverso. */
disallineate: { nome: string; autonomia: number }[]
avvisi: string[]
}
/** Giorni di autonomia residua: quanto manca prima di toccare la soglia di stress. */
function autonomia(c: Consiglio): number {
if (c.etcOggi <= 0.01) return 99
return Math.max((c.soglia - c.deficit) / c.etcOggi, 0)
}
function pioggiaEfficace(mm: number, serra: boolean): number {
if (serra) return 0
if (mm <= 2) return 0
return Math.min((mm - 2) * 0.85, 40)
}
export function programmaSettore(
aiuola: Aiuola,
consigli: Consiglio[],
meteo: GiornoMeteo[],
imp: Impostazioni,
giorni = 10
): ProgrammaSettore | null {
const attive = consigli.filter((c) => c.aiuola.id === aiuola.id && c.stato !== "riposo")
if (attive.length === 0) return null
// La coltura guida è quella che si asciuga per prima.
const guida = [...attive].sort((a, b) => autonomia(a) - autonomia(b))[0]
const oggi = isoOggi()
const futuri = meteo.filter((g) => g.data >= oggi).slice(0, giorni)
const efficienza = imp.efficienza[aiuola.metodo] ?? 0.8
const riduzione = (aiuola.pacciamatura ? 1 - imp.risparmioPacciamatura : 1) * (aiuola.serra ? 0.75 : 1)
/** Simula i turni sui prossimi giorni partendo da un certo deficit iniziale. */
const simula = (deficitIniziale: number, conPioggia: boolean): Turno[] => {
const out: Turno[] = []
let deficit = deficitIniziale
for (const g of futuri) {
const etc = g.et0 * guida.kc * riduzione
deficit = Math.max(deficit + etc - (conPioggia ? pioggiaEfficace(g.pioggia, aiuola.serra) : 0), 0)
if (deficit >= guida.soglia) {
// Il turno riporta il terreno alla soglia, non oltre: dosi più grandi
// finirebbero sotto le radici senza essere usate.
const mm = Math.min(deficit, guida.soglia)
const lorda = mm / efficienza
out.push({
data: g.data,
mm: Math.round(mm * 10) / 10,
litri: Math.round(lorda * aiuola.superficie),
minuti: minutiPerDose(aiuola, lorda),
pioggiaQuelGiorno: g.pioggia,
})
deficit -= mm
}
}
return out
}
// Due domande diverse, due simulazioni. I turni veri partono dall'acqua che c'è
// adesso nel terreno e includono il recupero dell'arretrato e le piogge
// previste. Il programma fisso da lasciare sulla centralina è invece il ritmo
// di regime: terreno in equilibrio e tempo asciutto, cioè il caso peggiore.
const turni = simula(guida.deficit, true)
const regime = simula(0, false)
const intervallo =
regime.length >= 2
? Math.max(Math.round(giorniTra(regime[0].data, regime[regime.length - 1].data) / (regime.length - 1)), 1)
: regime.length === 1
? Math.max(Math.round(guida.soglia / Math.max(guida.etcOggi * riduzione, 0.1)), 1)
: null
const base = regime.length ? regime : turni
const mmTipici = base.length ? base.reduce((s, t) => s + t.mm, 0) / base.length : 0
const minutiTipici = base.length
? Math.round(base.reduce((s, t) => s + (t.minuti ?? 0), 0) / base.length) || null
: null
const tMax = Math.max(...futuri.map((g) => g.tMax), 0)
const pioggiaPrevista = futuri.reduce((s, g) => s + g.pioggia, 0)
// Colture che su questo settore ricevono un ritmo molto diverso dal loro.
const autGuida = autonomia(guida)
const disallineate = attive
.filter((c) => c.piantagioneId !== guida.piantagioneId && autonomia(c) > Math.max(autGuida * 2.5, autGuida + 3))
.map((c) => ({ nome: c.coltura.nome, autonomia: Math.round(autonomia(c)) }))
const avvisi: string[] = []
if (!aiuola.portataLh && !aiuola.portataSettoreLh) {
avvisi.push(
"Manca la portata del settore, quindi posso darti i litri ma non i minuti. Misurala così: apri la valvola per un minuto esatto raccogliendo l'acqua in un secchio graduato da uno dei gocciolatori terminali, moltiplica per il numero di gocciolatori e per 60. Oppure, più semplice: se il settore riempie un secchio da 10 litri in 40 secondi, sono 900 litri/ora."
)
}
if (disallineate.length) {
avvisi.push(
`Su questo settore ${disallineate.map((d) => d.nome.toLowerCase()).join(" e ")} ${disallineate.length === 1 ? "avrebbe" : "avrebbero"} bisogno di turni molto più radi (${disallineate.map((d) => `${d.autonomia} giorni`).join(", ")} contro ${autGuida < 1 ? "meno di uno" : Math.round(autGuida)}). Il programma è tarato su ${guida.coltura.nome.toLowerCase()}, quindi ${disallineate.length === 1 ? "riceverà" : "riceveranno"} più acqua del necessario. Se puoi, spostale su un'altra valvola.`
)
}
if (minutiTipici && minutiTipici > 180) {
// Quasi tutte le centraline domestiche permettono al massimo 3-4 orari di
// avvio per programma: oltre, il problema è la portata, non la programmazione.
const parti = Math.min(Math.ceil(minutiTipici / 120), 4)
const perParte = Math.round(minutiTipici / parti)
avvisi.push(
`Un turno da ${Math.round(minutiTipici / 60)} ore tutto di fila, su ${aiuola.superficie} m², rischia di scorrere via o di finire sotto le radici invece di restare dove serve. Spezzalo in ${parti} partenze da circa ${perParte} minuti, distanziate di un'ora — quasi tutte le centraline accettano più orari di avvio sullo stesso programma.` +
(perParte > 120
? " Restano comunque turni lunghissimi: il vero problema qui è la portata del settore, troppo bassa per la superficie. Valuta di sdoppiare il settore o di usare ala gocciolante con portata maggiore."
: "")
)
}
if (guida.stimaProvvisoria) {
avvisi.push(
`Il primo turno è più abbondante degli altri perché recupera l'arretrato: non essendoci ancora irrigazioni registrate per ${guida.coltura.nome.toLowerCase()}, il calcolo presume tre settimane di sola pioggia. Registra la prima irrigazione e dal turno successivo le dosi si assestano.`
)
}
if (aiuola.metodo === "aspersione") {
avvisi.push(
"Con l'aspersione un terzo dell'acqua evapora prima di arrivare a terra e le foglie restano bagnate: se puoi convertire questo settore a goccia, risparmi acqua e ti togli metà dei problemi fungini."
)
}
if (pioggiaPrevista >= 15) {
avvisi.push(
`Nei prossimi ${giorni} giorni sono previsti ${pioggiaPrevista.toFixed(0)} mm di pioggia: i turni qui sotto ne tengono già conto. Se la centralina ha un sensore pioggia, lascialo attivo come rete di sicurezza.`
)
}
return {
aiuola,
guida,
turni,
intervallo,
minutiTipici,
mmTipici: Math.round(mmTipici * 10) / 10,
orario: orarioConsigliato(tMax),
pioggiaPrevista,
disallineate,
avvisi,
}
}
function giorniTra(a: string, b: string): number {
return Math.round((Date.parse(b) - Date.parse(a)) / 86400000)
}
/** Etichetta breve del giorno: "lun 8 set". */
export function giornoBreve(iso: string): string {
return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { weekday: "short", day: "numeric", month: "short" })
}
+76
View File
@@ -0,0 +1,76 @@
import type { Aiuola, LetturaSonda, TipoSuolo } from "./types"
/**
* Sonde Watermark (Irrometer 200SS).
*
* Misurano la **tensione idrica** in kPa, cioè la fatica che la radice fa per
* estrarre l'acqua: 0 significa terreno zuppo, 200 significa che l'acqua c'è
* ancora ma è trattenuta così forte che la pianta non la prende. È il parametro
* giusto, molto più dell'umidità percentuale, che senza conoscere il terreno non
* vuol dire niente: il 20% di umidità è fango in una sabbia e siccità in
* un'argilla, mentre 40 kPa vogliono dire la stessa cosa ovunque.
*/
/** Soglie di intervento per tipo di terreno, in kPa. */
export const SOGLIA_KPA: Record<TipoSuolo, number> = {
sabbioso: 25,
"franco-sabbioso": 30,
franco: 40,
"franco-argilloso": 50,
argilloso: 60,
}
/** Sotto questa tensione il terreno è alla capacità di campo: il serbatoio è pieno. */
export const KPA_CAPACITA_DI_CAMPO = 15
export function sogliaDi(a: Aiuola): number {
return a.sogliaKpa ?? SOGLIA_KPA[a.suolo]
}
export type LivelloSonda = "zuppo" | "umido" | "da-irrigare" | "secco" | "guasta"
export function livello(lettura: LetturaSonda | undefined, soglia: number): LivelloSonda {
if (!lettura || lettura.salute !== "ok") return "guasta"
if (lettura.kpa <= 8) return "zuppo"
if (lettura.kpa < soglia) return "umido"
if (lettura.kpa < soglia * 1.6) return "da-irrigare"
return "secco"
}
export const DESCRIZIONE_LIVELLO: Record<LivelloSonda, string> = {
zuppo: "Terreno saturo: c'è più acqua di quanta le radici possano usare. Se dura più di un giorno o due è un problema di drenaggio.",
umido: "Riserva ancora sufficiente: non serve irrigare.",
"da-irrigare": "Il terreno ha raggiunto la soglia: è il momento di dare acqua.",
secco: "Ben oltre la soglia: la pianta sta già chiudendo gli stomi e ha smesso di crescere.",
guasta: "Nessuna lettura valida. Controlla il cavo e il collegamento della sonda.",
}
export const COLORE_LIVELLO: Record<LivelloSonda, string> = {
zuppo: "var(--info)",
umido: "var(--ok)",
"da-irrigare": "var(--warn)",
secco: "var(--danger)",
guasta: "var(--muted-fg)",
}
/**
* Stima grossolana di quanta riserva è stata consumata, per confrontare la sonda
* col bilancio calcolato. È un'interpolazione lineare fra capacità di campo e
* soglia di stress: la curva vera del terreno non è lineare, quindi serve a
* capire se modello e realtà vanno d'accordo, non a sostituire il modello.
*/
export function consumoStimato(kpa: number, soglia: number): number {
if (kpa <= KPA_CAPACITA_DI_CAMPO) return 0
return Math.min(((kpa - KPA_CAPACITA_DI_CAMPO) / (soglia - KPA_CAPACITA_DI_CAMPO)) * 100, 150)
}
/** Quanto sonda e calcolo sono d'accordo, e cosa fare se non lo sono. */
export function confronta(consumoCalcolato: number, kpa: number, soglia: number): string | null {
const misurato = consumoStimato(kpa, soglia)
const scarto = misurato - consumoCalcolato
if (Math.abs(scarto) < 35) return null
if (scarto > 0) {
return `La sonda legge ${kpa.toFixed(0)} kPa: il terreno è più asciutto di quanto il calcolo preveda. Se succede spesso, il consumo reale è più alto della stima — prova ad alzare l'efficienza del metodo in Impostazioni, o verifica che l'impianto eroghi davvero la portata dichiarata.`
}
return `La sonda legge ${kpa.toFixed(0)} kPa: il terreno è più umido di quanto il calcolo preveda. Può essere una falda, una zona d'ombra, o semplicemente la sonda piazzata proprio sotto un gocciolatore. Controlla dov'è infilata prima di correggere i parametri.`
}
+216
View File
@@ -0,0 +1,216 @@
/** Modello dati di Pollice Verde. Tutto è persistito in locale (localStorage). */
export type Categoria = 'ortaggio' | 'frutta' | 'aromatica'
export type Esposizione = 'pieno sole' | 'mezz\'ombra' | 'ombra luminosa'
export type Sensibilita = 'bassa' | 'media' | 'alta'
/** Coefficienti colturali FAO-56: quanta acqua consuma la pianta rispetto al prato di riferimento. */
export interface Kc {
iniziale: number
sviluppo: number
piena: number
finale: number
}
/** Durata in giorni delle quattro fasi (solo colture annuali). */
export interface FasiGiorni {
iniziale: number
sviluppo: number
piena: number
finale: number
}
export interface Problema {
nome: string
sintomo: string
rimedio: string
}
export interface Coltura {
id: string
nome: string
/** Altri nomi con cui la stessa pianta è conosciuta in giro per l'Italia. */
sinonimi?: string[]
categoria: Categoria
/** Categorie aggiuntive in cui ha senso trovarla: l'anguria si coltiva nell'orto ma la si cerca fra la frutta. */
ancheIn?: Categoria[]
famiglia: string
emoji: string
perenne: boolean
/** Mesi 1-12. */
seminaSemenzaio?: number[]
seminaDiretta?: number[]
trapianto?: number[]
raccolta: number[]
potatura?: number[]
/** Solo perenni: mesi di attività vegetativa [inizio, fine]. */
stagioneVegetativa?: [number, number]
kc: Kc
fasiGiorni?: FasiGiorni
/** Profondità delle radici attive, in metri: definisce il "serbatoio" di suolo utile. */
profonditaRadici: number
/** Frazione di acqua disponibile consumabile prima di stressare la pianta (p FAO-56). */
fattoreDeplezione: number
sensibilitaIdrica: Sensibilita
/** Fasi in cui la carenza d'acqua costa di più in resa. */
fasiCritiche: string
sesto: string
piantePerMq: number
esposizione: Esposizione
phIdeale: [number, number]
consociazioni: string[]
daEvitare: string[]
concimazione: string
cure: string[]
problemi: Problema[]
note?: string
}
export type TipoSuolo = 'sabbioso' | 'franco-sabbioso' | 'franco' | 'franco-argilloso' | 'argilloso'
export type Metodo = 'goccia' | 'aspersione' | 'manuale' | 'subirrigazione'
export type Contenitore = 'piena terra' | 'vaso' | 'cassone rialzato'
export interface Aiuola {
id: string
nome: string
suolo: TipoSuolo
metodo: Metodo
contenitore: Contenitore
/** m² della superficie coltivata. */
superficie: number
pacciamatura: boolean
serra: boolean
/** Solo vaso: litri di terriccio per pianta. */
litriVaso?: number
/** Portata dell'impianto in litri/ora per m² (goccia) — per stimare i minuti. */
portataLh?: number
/** In alternativa: litri/ora erogati dall'intero settore. Più facile da misurare col secchio. */
portataSettoreLh?: number
/** Ingresso della sonda Watermark che sorveglia questo settore (0, 1, 2…). */
sensore?: number
/** Tensione idrica (kPa) oltre la quale il terreno è da irrigare. Se manca si usa il valore del tipo di suolo. */
sogliaKpa?: number
/** Nome o numero della stazione sulla centralina, per ritrovarla quando programmi. */
settore?: string
/** Uscita fisica sulla centralina (relè 1, 2, 3…). Se manca, il settore non viene mai aperto. */
canale?: number
note?: string
}
export interface Piantagione {
id: string
aiuolaId: string
colturaId: string
/** Data di semina o trapianto in campo (ISO yyyy-mm-dd). */
dataMessaADimora: string
quantita: number
varieta?: string
/** Deficit idrico del suolo in mm, aggiornato dal motore. */
note?: string
archiviata?: boolean
}
export type TipoEvento =
| 'irrigazione'
| 'automazione'
| 'sonda'
| 'pioggia'
| 'concimazione'
| 'trattamento'
| 'potatura'
| 'raccolta'
| 'semina'
| 'nota'
export interface Evento {
id: string
data: string
tipo: TipoEvento
aiuolaId?: string
piantagioneId?: string
/** Millimetri d'acqua (= litri/m²) per irrigazione e pioggia. */
mm?: number
/** Vero se l'evento è stato registrato dall'agente della centralina, non da te. */
automatico?: boolean
quantita?: number
unita?: string
testo?: string
}
/** Una lettura di sonda, come arriva dal dispositivo. */
export interface LetturaSonda {
/** Ingresso fisico sul dispositivo. */
sensore: number
/** Tensione idrica in kPa (= centibar). 0 = zuppo, 200 = secchissimo. */
kpa: number
/** Resistenza grezza in ohm, utile per accorgersi di un cavo staccato. */
ohm: number
/** Momento della lettura (ISO). */
quando: string
/** Diagnostica: "ok", "scollegata", "in corto", "fuori scala". */
salute: string
}
export interface Luogo {
nome: string
lat: number
lon: number
}
export type DriverCentralina = 'simulato' | 'esp32' | 'shelly-gen2' | 'shelly-gen1' | 'tasmota'
/** Blocchi che impediscono a un'automazione di fare danni mentre non guardi. */
export interface Sicurezze {
/** Minuti massimi per singola apertura: oltre, il turno viene troncato. */
minutiMassimiPerTurno: number
/** Litri massimi erogabili in un giorno su tutto il giardino. */
litriMassimiAlGiorno: number
/** Sotto questa minima prevista non si irriga: acqua e gelo non vanno d'accordo. */
bloccoGeloSotto: number
/** Sopra questo vento (km/h) si saltano i settori ad aspersione: l'acqua finisce altrove. */
bloccoVentoSopra: number
/** Millimetri di pioggia prevista oggi oltre i quali si salta comunque il turno. */
bloccoPioggiaSopra: number
/** Se una sonda dice che il terreno è ancora umido, il turno salta. */
vetoSonde: boolean
}
export interface Centralina {
/** Indirizzo dell'agente che comanda le valvole, es. http://192.168.1.50:8088 */
urlAgente: string
/** Quando è false l'agente calcola e registra ma non apre nulla. */
automazioneAttiva: boolean
driver: DriverCentralina
/** Indirizzo del dispositivo relè, es. 192.168.1.60 */
host: string
/** Orario di partenza del programma, formato HH:MM. */
orarioPartenza: string
/** Pausa fra un settore e il successivo, in minuti: la pressione deve riprendersi. */
pausaFraSettori: number
/** Dispositivo che espone le sonde: se vuoto si usa lo stesso della centralina. */
hostSensori: string
/** Ogni quanti minuti interrogare le sonde. I Watermark non vanno letti di continuo. */
intervalloLetturaMinuti: number
sicurezze: Sicurezze
}
export interface Impostazioni {
luogo: Luogo | null
/** Riduzione dell'evapotraspirazione sotto pacciamatura (0-1). */
risparmioPacciamatura: number
/** Efficienza del metodo irriguo: quanta dell'acqua erogata arriva alle radici. */
efficienza: Record<Metodo, number>
temaScuro: boolean | null
centralina: Centralina
}
export interface Stato {
versione: number
impostazioni: Impostazioni
aiuole: Aiuola[]
piantagioni: Piantagione[]
eventi: Evento[]
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)
// Service worker: l'app resta apribile e consultabile anche senza rete in giardino.
if ("serviceWorker" in navigator && location.protocol !== "file:") {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
/* niente offline, ma l'app funziona lo stesso */
})
})
}
+260
View File
@@ -0,0 +1,260 @@
import { useState } from "react"
import { useApp, id, oggi } from "../state/store"
import { useConsigli } from "../state/consigli"
import { COLORI_STATO, ETICHETTA_STATO, formattaDurata, orarioConsigliato } from "../lib/irrigazione"
import { Barra, GraficoBilancio, Modale, Vuoto } from "../components/comuni"
import { isoOggi } from "../lib/meteo"
import { confronta, sogliaDi } from "../lib/sonde"
export default function Acqua() {
const { stato, meteo, dispatch } = useApp()
const consigli = useConsigli()
const [aperto, setAperto] = useState<string | null>(null)
const [registra, setRegistra] = useState<string | null>(null)
const meteoOggi = meteo?.giorni.find((g) => g.data === isoOggi())
const totaleOggi = consigli.filter((c) => c.irrigareOggi).reduce((s, c) => s + c.doseLitri, 0)
if (consigli.length === 0) {
return (
<Vuoto
icona="💧"
titolo="Ancora nessun calcolo"
testo="Appena aggiungi un'aiuola con dentro qualche pianta, qui trovi per ognuna quanta acqua c'è nel terreno, quanto ne consuma al giorno e quando toccherà bagnare."
/>
)
}
return (
<>
<div>
<h1>Acqua</h1>
<p className="sommario">
{totaleOggi > 0
? `Oggi servono circa ${Math.round(totaleOggi)} litri in tutto.`
: "Oggi non serve irrigare: il terreno ha ancora riserva."}
</p>
</div>
{meteoOggi && (
<div className="scheda">
<div className="riga avvolgi" style={{ gap: 22 }}>
<div className="metrica">
<div className="nome">Evapotraspirazione oggi</div>
<div className="valore">{meteoOggi.et0.toFixed(1)}<span className="unita">mm</span></div>
</div>
<div className="metrica">
<div className="nome">Pioggia ultimi 7 giorni</div>
<div className="valore">
{(meteo?.giorni.filter((g) => !g.previsione).slice(-7).reduce((s, g) => s + g.pioggia, 0) ?? 0).toFixed(0)}
<span className="unita">mm</span>
</div>
</div>
<div className="metrica">
<div className="nome">Prevista 7 giorni</div>
<div className="valore">
{(meteo?.giorni.filter((g) => g.previsione).slice(0, 7).reduce((s, g) => s + g.pioggia, 0) ?? 0).toFixed(0)}
<span className="unita">mm</span>
</div>
</div>
</div>
<p className="sottile" style={{ marginTop: 12 }}>
L'evapotraspirazione è quanta acqua un prato di riferimento perde oggi per evaporazione e traspirazione:
{" "}{meteoOggi.et0.toFixed(1)} mm equivalgono a {meteoOggi.et0.toFixed(1)} litri per ogni metro quadro. Ogni coltura consuma
una frazione di questo valore, più o meno grande a seconda di quanto è sviluppata.
</p>
<p className="sottile" style={{ marginTop: 8 }}>
<span className="grassetto">Orario: </span>{orarioConsigliato(meteoOggi.tMax)}
</p>
</div>
)}
{consigli.map((c) => (
<section key={c.piantagioneId} className="scheda">
<div className="riga spazia">
<div className="riga">
<span style={{ fontSize: "1.5rem" }}>{c.coltura.emoji}</span>
<div>
<h2>{c.coltura.nome}</h2>
<p className="sottile">{c.aiuola.nome} · {c.fase} · {c.eta} giorni</p>
</div>
</div>
<span className={`etichetta ${c.stato === "critico" ? "critico" : c.stato === "attenzione" ? "attenzione" : c.stato === "riposo" ? "neutro" : ""}`}>
{ETICHETTA_STATO[c.stato]}
</span>
</div>
<div style={{ margin: "14px 0 6px" }}>
<div className="riga spazia sottile" style={{ marginBottom: 5 }}>
<span>Riserva prima del prossimo turno</span>
<span className="grassetto" style={{ color: COLORI_STATO[c.stato] }}>{Math.max(Math.round(100 - c.consumo), 0)}%</span>
</div>
<Barra percentuale={Math.max(100 - c.consumo, 0)} colore={COLORI_STATO[c.stato]} />
</div>
<p className="sottile" style={{ marginTop: 10 }}>{c.motivazione}</p>
{(() => {
// Se questo settore ha una sonda, confrontiamo la misura col calcolo:
// quando i due non vanno d'accordo, è un'informazione da dare, non da
// nascondere dietro una media.
if (c.aiuola.sensore === undefined) return null
const lettura = stato.eventi
.filter((e) => e.tipo === "sonda" && e.aiuolaId === c.aiuola.id && e.unita === "kPa")
.sort((a, b) => b.data.localeCompare(a.data))[0]
if (!lettura?.quantita) return null
const soglia = sogliaDi(c.aiuola)
const nota = confronta(c.consumo, lettura.quantita, soglia)
return (
<p className="sottile" style={{ marginTop: 8, color: nota ? "var(--warn)" : "var(--muted-fg)" }}>
🌡️ Sonda: {lettura.quantita} kPa su una soglia di {soglia}.{" "}
{nota ?? "La misura conferma il calcolo."}
</p>
)
})()}
{c.stimaProvvisoria && (
<p className="sottile" style={{ marginTop: 8, color: "var(--warn)" }}>
Stima provvisoria: non c'è ancora nessuna irrigazione registrata per questa coltura, quindi il calcolo
presume tre settimane senza acqua a parte la pioggia. Registra la prima irrigazione e da lì in poi il
bilancio parte da un dato certo.
</p>
)}
<div className="riga avvolgi" style={{ gap: 20, marginTop: 14 }}>
<div className="metrica">
<div className="nome">Consumo al giorno</div>
<div className="valore">{c.etcOggi.toFixed(1)}<span className="unita">mm</span></div>
</div>
<div className="metrica">
<div className="nome">Per pianta</div>
<div className="valore">{c.litriGiornoPerPianta.toFixed(1)}<span className="unita">l/g</span></div>
</div>
{c.irrigareOggi && c.minuti !== null && (
<div className="metrica">
<div className="nome">Impianto aperto</div>
<div className="valore" style={{ fontSize: "1.15rem" }}>{formattaDurata(c.minuti)}</div>
</div>
)}
<div className="metrica">
<div className="nome">{c.irrigareOggi ? "Dose ora" : "Prossimo turno"}</div>
<div className="valore">
{c.irrigareOggi
? <>{Math.round(c.doseLitri)}<span className="unita">litri</span></>
: c.giorniAllaProssima !== null
? <>{c.giorniAllaProssima}<span className="unita">{c.giorniAllaProssima === 1 ? "giorno" : "giorni"}</span></>
: <span className="unita">oltre 10 giorni</span>}
</div>
</div>
</div>
<div className="riga" style={{ gap: 8, marginTop: 14 }}>
<button className="pulsante piccolo" onClick={() => setRegistra(c.piantagioneId)}>💧 Registra irrigazione</button>
<button className="pulsante secondario piccolo" onClick={() => setAperto(aperto === c.piantagioneId ? null : c.piantagioneId)}>
{aperto === c.piantagioneId ? "Nascondi andamento" : "Andamento"}
</button>
</div>
{aperto === c.piantagioneId && (
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--bordo)" }}>
<GraficoBilancio serie={c.serie} raw={c.soglia} taw={c.taw} />
<p className="sottile" style={{ marginTop: 10 }}>
La linea è l'acqua mancante nel terreno: sale quando la pianta consuma, scende quando piove o irrighi.
Sopra la linea tratteggiata rossa la pianta è in stress. I puntini in alto sono piogge (blu) e
irrigazioni registrate (verde).
</p>
<table className="tabellina" style={{ marginTop: 12 }}>
<tbody>
<tr><td>Serbatoio utile del terreno</td><td>{c.taw.toFixed(0)} mm ({(c.taw * c.aiuola.superficie).toFixed(0)} litri su tutta l'aiuola)</td></tr>
<tr><td>Riserva utile</td><td>{c.raw.toFixed(0)} mm — oltre questa soglia la pianta va in stress ({Math.round(c.coltura.fattoreDeplezione * 100)}% dell'acqua del terreno)</td></tr>
<tr><td>Soglia di intervento</td><td>a {c.soglia.toFixed(0)} mm di consumo — {c.aiuola.metodo === "goccia" || c.aiuola.metodo === "subirrigazione" ? "con la goccia conviene bagnare prima e meno per volta, tenendo il bulbo umido" : c.aiuola.metodo === "aspersione" ? "con l'aspersione si lascia un piccolo margine di sicurezza" : "irrigando a mano si sfrutta tutta la riserva, con turni radi e abbondanti"}</td></tr>
<tr><td>Superficie occupata</td><td>{c.areaBagnata.toFixed(1)} m² dei {c.aiuola.superficie} m² dell'aiuola — è a questa che si riferiscono i litri</td></tr>
<tr><td>Profondità radici</td><td>{c.coltura.profonditaRadici.toFixed(1)} m</td></tr>
<tr><td>Coefficiente colturale kc</td><td>{c.kc.toFixed(2)}</td></tr>
<tr><td>Fase critica</td><td>{c.coltura.fasiCritiche}</td></tr>
</tbody>
</table>
</div>
)}
</section>
))}
<section className="scheda">
<h2>Come si irriga bene</h2>
<ul className="lista-punti sottile" style={{ marginTop: 10 }}>
<li><span className="grassetto">Poco e spesso è quasi sempre sbagliato.</span> Bagnature superficiali tengono le radici nei primi 5 cm, dove il terreno si asciuga per primo. Meglio dosi abbondanti e turni distanziati: le radici scendono, e la pianta diventa autonoma.</li>
<li><span className="grassetto">Bagna il terreno, non la pianta.</span> L'acqua sulle foglie fa evaporare metà di quello che dai e apre la porta a peronospora, oidio e botrite.</li>
<li><span className="grassetto">Verifica con il dito.</span> Infila l'indice per due falangi: se esce asciutto e pulito è ora, se esce fresco e sporco puoi aspettare. Il calcolo dell'app è una stima molto buona, ma il terreno è lì.</li>
<li><span className="grassetto">Pacciama.</span> Cinque centimetri di paglia o sfalcio secco riducono di un quarto l'acqua necessaria, tengono la temperatura del suolo stabile e soffocano le infestanti.</li>
<li><span className="grassetto">L'appassimento di mezzogiorno non è sete.</span> Molte piante a foglia larga si afflosciano nelle ore calde e si riprendono al tramonto: è un meccanismo di difesa. Se sono ancora mosce la mattina presto, allora sì.</li>
<li><span className="grassetto">Troppa acqua uccide più della poca.</span> Radici sott'acqua non respirano e marciscono. Foglie gialle e mosce con terreno bagnato = asfissia radicale, non sete.</li>
</ul>
</section>
{registra && (
<FormIrrigazione
consiglio={consigli.find((c) => c.piantagioneId === registra)!}
onChiudi={() => setRegistra(null)}
onSalva={(mm, litri) => {
const c = consigli.find((x) => x.piantagioneId === registra)!
dispatch({
tipo: "evento/aggiungi",
evento: {
id: id(), data: oggi(), tipo: "irrigazione", piantagioneId: registra, aiuolaId: c.aiuola.id,
mm, quantita: litri, unita: "litri",
},
})
setRegistra(null)
}}
/>
)}
</>
)
}
function FormIrrigazione({
consiglio,
onChiudi,
onSalva,
}: {
consiglio: ReturnType<typeof useConsigli>[number]
onChiudi: () => void
onSalva: (mm: number, litri: number) => void
}) {
const suggerita = Math.max(Math.round(Math.min(consiglio.deficit, consiglio.raw)), 1)
const [litri, setLitri] = useState(Math.round((suggerita * consiglio.areaBagnata) / 0.9))
const mm = litri / consiglio.areaBagnata
return (
<Modale titolo={`Irrigazione — ${consiglio.coltura.nome}`} onChiudi={onChiudi}>
<p className="sottile">
Quanti litri hai dato (o stai per dare) alla {consiglio.coltura.nome.toLowerCase()} in {consiglio.aiuola.nome}?
Il valore proposto ricarica la riserva utile sui {consiglio.areaBagnata.toFixed(1)} m² che occupa.
</p>
<div className="griglia due">
<label className="campo">
<span style={{ fontSize: ".8rem", fontWeight: 650, color: "var(--muted-fg)" }}>Litri erogati</span>
<input type="number" min="1" step="1" value={litri} onChange={(e) => setLitri(Number(e.target.value))} autoFocus />
</label>
<div className="campo">
<span style={{ fontSize: ".8rem", fontWeight: 650, color: "var(--muted-fg)" }}>Corrispondono a</span>
<div style={{ paddingTop: 10, fontSize: "1.1rem", fontWeight: 700 }}>
{mm.toFixed(1)} <span style={{ fontSize: ".85rem", color: "var(--muted-fg)", fontWeight: 600 }}>mm</span>
</div>
</div>
</div>
<div className="chip-riga">
{[0.5, 1, 1.5].map((f) => (
<button
key={f}
className="chip"
onClick={() => setLitri(Math.round((suggerita * consiglio.areaBagnata * f) / 0.9))}
>
{f === 1 ? "Dose consigliata" : f < 1 ? "Metà dose" : "Dose abbondante"}
</button>
))}
</div>
<button className="pulsante" onClick={() => onSalva(Math.round(mm * 10) / 10, litri)}>Registra</button>
</Modale>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useMemo, useState } from "react"
import { useApp, id, oggi } from "../state/store"
import type { Evento, TipoEvento } from "../lib/types"
import { coltura } from "../lib/colture"
import { Campo, Modale, Vuoto } from "../components/comuni"
import { dataCompleta } from "../components/comuni"
const TIPI: { v: TipoEvento; e: string; l: string }[] = [
{ v: "irrigazione", e: "💧", l: "Irrigazione" },
{ v: "pioggia", e: "🌧️", l: "Pioggia" },
{ v: "concimazione", e: "🌾", l: "Concimazione" },
{ v: "trattamento", e: "🧪", l: "Trattamento" },
{ v: "potatura", e: "✂️", l: "Potatura" },
{ v: "raccolta", e: "🧺", l: "Raccolta" },
{ v: "semina", e: "🌱", l: "Semina" },
{ v: "nota", e: "📝", l: "Nota" },
]
export default function Diario() {
const { stato, dispatch } = useApp()
const [nuovo, setNuovo] = useState(false)
const [filtro, setFiltro] = useState<TipoEvento | "tutti">("tutti")
const eventi = useMemo(
() => [...stato.eventi].filter((e) => filtro === "tutti" || e.tipo === filtro).sort((a, b) => b.data.localeCompare(a.data)),
[stato.eventi, filtro]
)
const raggruppati = useMemo(() => {
const m = new Map<string, Evento[]>()
for (const e of eventi) {
const arr = m.get(e.data) ?? []
arr.push(e)
m.set(e.data, arr)
}
return [...m.entries()]
}, [eventi])
const acquaMese = stato.eventi
.filter((e) => e.tipo === "irrigazione" && e.data.slice(0, 7) === oggi().slice(0, 7))
.reduce((s, e) => {
if (e.unita === "litri" && e.quantita) return s + e.quantita
// Registrazioni vecchie o inserite a mano: risaliamo ai litri dai millimetri.
const a = stato.aiuole.find((x) => x.id === e.aiuolaId)
return s + (e.mm ?? 0) * (a?.superficie ?? 1)
}, 0)
return (
<>
<div className="riga spazia">
<div>
<h1>Diario</h1>
<p className="sommario">
{stato.eventi.length} {stato.eventi.length === 1 ? "registrazione" : "registrazioni"}
{acquaMese > 0 && ` · ${Math.round(acquaMese)} litri erogati questo mese`}
</p>
</div>
<button className="pulsante piccolo" onClick={() => setNuovo(true)}>+ Registra</button>
</div>
<div className="chip-riga">
<button className="chip" aria-pressed={filtro === "tutti"} onClick={() => setFiltro("tutti")}>Tutto</button>
{TIPI.map((t) => (
<button key={t.v} className="chip" aria-pressed={filtro === t.v} onClick={() => setFiltro(t.v)}>
{t.e} {t.l}
</button>
))}
</div>
{raggruppati.length === 0 ? (
<Vuoto
icona="📔"
titolo="Il diario è vuoto"
testo="Annotare irrigazioni, concimazioni e raccolti serve a due cose: rendere più preciso il calcolo dell'acqua e, l'anno prossimo, sapere esattamente cosa è andato bene e quando."
azione={<button className="pulsante" onClick={() => setNuovo(true)}>Registra qualcosa</button>}
/>
) : (
raggruppati.map(([data, lista]) => (
<section key={data} className="scheda">
<h3 style={{ textTransform: "capitalize", color: "var(--muted-fg)", fontSize: ".85rem" }}>{dataCompleta(data)}</h3>
<ul className="lista-pulita" style={{ marginTop: 10 }}>
{lista.map((e) => {
const t = TIPI.find((x) => x.v === e.tipo)!
const p = e.piantagioneId ? stato.piantagioni.find((x) => x.id === e.piantagioneId) : null
const c = p ? coltura(p.colturaId) : null
const a = stato.aiuole.find((x) => x.id === e.aiuolaId)
return (
<li key={e.id} className="riga spazia">
<div className="riga" style={{ alignItems: "flex-start" }}>
<span style={{ fontSize: "1.2rem" }}>{t.e}</span>
<div>
<div className="grassetto" style={{ fontSize: ".92rem" }}>
{t.l}
{e.mm !== undefined && ` · ${e.mm} mm`}
{e.quantita !== undefined && ` · ${e.quantita} ${e.unita ?? ""}`}
</div>
<div className="sottile">
{[c?.nome, a?.nome].filter(Boolean).join(" · ") || "tutto il giardino"}
{e.testo && ` — ${e.testo}`}
</div>
</div>
</div>
<button
className="chiudi"
title="Elimina"
onClick={() => dispatch({ tipo: "evento/elimina", id: e.id })}
>
✕
</button>
</li>
)
})}
</ul>
</section>
))
)}
{nuovo && <FormEvento onChiudi={() => setNuovo(false)} />}
</>
)
}
function FormEvento({ onChiudi }: { onChiudi: () => void }) {
const { stato, dispatch } = useApp()
const [tipo, setTipo] = useState<TipoEvento>("raccolta")
const [data, setData] = useState(oggi())
const [aiuolaId, setAiuolaId] = useState<string>(stato.aiuole[0]?.id ?? "")
const [piantagioneId, setPiantagioneId] = useState<string>("")
const [mm, setMm] = useState<number>(10)
const [quantita, setQuantita] = useState<number>(1)
const [unita, setUnita] = useState("kg")
const [testo, setTesto] = useState("")
const piante = stato.piantagioni.filter((p) => p.aiuolaId === aiuolaId && !p.archiviata)
const conAcqua = tipo === "irrigazione" || tipo === "pioggia"
const conQuantita = tipo === "raccolta"
return (
<Modale titolo="Nuova registrazione" onChiudi={onChiudi}>
<div className="chip-riga">
{TIPI.map((t) => (
<button key={t.v} className="chip" aria-pressed={tipo === t.v} onClick={() => setTipo(t.v)}>
{t.e} {t.l}
</button>
))}
</div>
<Campo label="Data">
<input type="date" value={data} onChange={(e) => setData(e.target.value)} />
</Campo>
{tipo !== "pioggia" && stato.aiuole.length > 0 && (
<>
<Campo label="Aiuola">
<select value={aiuolaId} onChange={(e) => { setAiuolaId(e.target.value); setPiantagioneId("") }}>
{stato.aiuole.map((a) => <option key={a.id} value={a.id}>{a.nome}</option>)}
</select>
</Campo>
{piante.length > 0 && (
<Campo label="Coltura (facoltativa)">
<select value={piantagioneId} onChange={(e) => setPiantagioneId(e.target.value)}>
<option value="">tutta l'aiuola</option>
{piante.map((p) => {
const c = coltura(p.colturaId)
return <option key={p.id} value={p.id}>{c?.nome}{p.varieta ? ` (${p.varieta})` : ""}</option>
})}
</select>
</Campo>
)}
</>
)}
{conAcqua && (
<Campo
label="Millimetri (= litri per m²)"
aiuto={
tipo === "pioggia"
? "Solo se hai un pluviometro e vuoi correggere il dato meteo: il valore inserito sostituisce quello scaricato."
: "Se hai contato i litri, dividili per i m² dell'aiuola."
}
>
<input type="number" min="0" step="1" value={mm} onChange={(e) => setMm(Number(e.target.value))} />
</Campo>
)}
{conQuantita && (
<div className="griglia due">
<Campo label="Quantità">
<input type="number" min="0" step="0.1" value={quantita} onChange={(e) => setQuantita(Number(e.target.value))} />
</Campo>
<Campo label="Unità">
<select value={unita} onChange={(e) => setUnita(e.target.value)}>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="pezzi">pezzi</option>
<option value="cassette">cassette</option>
</select>
</Campo>
</div>
)}
<Campo label="Note">
<textarea value={testo} onChange={(e) => setTesto(e.target.value)} placeholder="Cosa è successo, cosa hai usato, com'era la pianta…" />
</Campo>
<button
className="pulsante"
onClick={() => {
dispatch({
tipo: "evento/aggiungi",
evento: {
id: id(),
data,
tipo,
aiuolaId: tipo === "pioggia" ? undefined : aiuolaId || undefined,
piantagioneId: piantagioneId || undefined,
mm: conAcqua ? mm : undefined,
quantita: conQuantita ? quantita : undefined,
unita: conQuantita ? unita : undefined,
testo: testo.trim() || undefined,
},
})
onChiudi()
}}
>
Salva nel diario
</button>
</Modale>
)
}
+486
View File
@@ -0,0 +1,486 @@
import { useState } from "react"
import { useApp, id, oggi } from "../state/store"
import type { Aiuola, Contenitore, Metodo, Piantagione, TipoSuolo } from "../lib/types"
import { COLTURE, coltura, corrisponde } from "../lib/colture"
import { DESCRIZIONE_SUOLO } from "../lib/irrigazione"
import { SOGLIA_KPA } from "../lib/sonde"
import { rotazione } from "../lib/attivita"
import { Campo, Modale, Vuoto } from "../components/comuni"
const SUOLI: TipoSuolo[] = ["sabbioso", "franco-sabbioso", "franco", "franco-argilloso", "argilloso"]
const METODI: Metodo[] = ["goccia", "manuale", "aspersione", "subirrigazione"]
const CONTENITORI: Contenitore[] = ["piena terra", "cassone rialzato", "vaso"]
export default function Giardino() {
const { stato, dispatch } = useApp()
const [modaleAiuola, setModaleAiuola] = useState<Aiuola | "nuova" | null>(null)
const [modalePianta, setModalePianta] = useState<string | null>(null)
return (
<>
<div className="riga spazia">
<div>
<h1>Il giardino</h1>
<p className="sommario">
{stato.aiuole.length} {stato.aiuole.length === 1 ? "aiuola" : "aiuole"} ·{" "}
{stato.piantagioni.filter((p) => !p.archiviata).length} colture in campo
</p>
</div>
<button className="pulsante piccolo" onClick={() => setModaleAiuola("nuova")}>+ Aiuola</button>
</div>
{stato.aiuole.length === 0 ? (
<Vuoto
icona="🪴"
titolo="Nessuna aiuola"
testo="Un'aiuola è una porzione di terreno con lo stesso terreno e lo stesso impianto di irrigazione: un filare, un cassone, una fila di vasi. Dividere il giardino in aiuole è quello che permette all'app di calcolare dosi diverse per situazioni diverse."
azione={<button className="pulsante" onClick={() => setModaleAiuola("nuova")}>Crea la prima aiuola</button>}
/>
) : (
stato.aiuole.map((a) => {
const piante = stato.piantagioni.filter((p) => p.aiuolaId === a.id && !p.archiviata)
const rot = rotazione(a.id, stato.piantagioni)
return (
<section key={a.id} className="scheda">
<div className="riga spazia">
<div>
<h2>{a.nome}</h2>
<p className="sottile">
{a.settore ? `${a.settore} · ` : ""}
{a.canale !== undefined ? `canale ${a.canale} · ` : ""}
{a.superficie} m² · {a.contenitore} · terreno {a.suolo} · irrigazione {a.metodo}
{a.pacciamatura && " · pacciamata"}
{a.serra && " · in serra"}
</p>
</div>
<button className="pulsante secondario piccolo" onClick={() => setModaleAiuola(a)}>Modifica</button>
</div>
<p className="sottile" style={{ marginTop: 8 }}>{DESCRIZIONE_SUOLO[a.suolo]}</p>
<div className="separatore" style={{ margin: "14px 0" }} />
{piante.length === 0 ? (
<p className="sommario">Aiuola vuota.</p>
) : (
<ul className="lista-pulita">
{piante.map((p) => {
const c = coltura(p.colturaId)
if (!c) return null
const giorni = Math.round((Date.now() - Date.parse(p.dataMessaADimora)) / 86400000)
return (
<li key={p.id} className="riga spazia">
<div className="riga">
<span style={{ fontSize: "1.4rem" }}>{c.emoji}</span>
<div>
<div className="grassetto">
{c.nome} {p.varieta && <span style={{ fontWeight: 400, color: "var(--muted-fg)" }}>· {p.varieta}</span>}
</div>
<div className="sottile">
{p.quantita} {p.quantita === 1 ? "pianta" : "piante"} ·{" "}
{giorni >= 0 ? `${giorni} giorni dalla messa a dimora` : "da mettere a dimora"}
</div>
</div>
</div>
<button
className="pulsante secondario piccolo"
onClick={() => {
if (confirm(`Togliere ${c.nome} da ${a.nome}? Resterà nello storico per le rotazioni.`)) {
dispatch({ tipo: "piantagione/modifica", piantagione: { ...p, archiviata: true } })
}
}}
>
Estirpa
</button>
</li>
)
})}
</ul>
)}
<div className="riga" style={{ marginTop: 14, gap: 8 }}>
<button className="pulsante piccolo" onClick={() => setModalePianta(a.id)}>+ Pianta qui</button>
</div>
{rot.famiglie.length > 0 && (
<p className="sottile" style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--bordo)" }}>
<span className="grassetto">Rotazione: </span>{rot.nota}
</p>
)}
</section>
)
})
)}
{modaleAiuola && (
<FormAiuola
aiuola={modaleAiuola === "nuova" ? null : modaleAiuola}
onChiudi={() => setModaleAiuola(null)}
onSalva={(a) => {
dispatch(modaleAiuola === "nuova" ? { tipo: "aiuola/aggiungi", aiuola: a } : { tipo: "aiuola/modifica", aiuola: a })
setModaleAiuola(null)
}}
onElimina={
modaleAiuola === "nuova"
? undefined
: () => {
if (confirm(`Eliminare ${modaleAiuola.nome} e tutto ciò che contiene?`)) {
dispatch({ tipo: "aiuola/elimina", id: modaleAiuola.id })
setModaleAiuola(null)
}
}
}
/>
)}
{modalePianta && (
<FormPianta
aiuolaId={modalePianta}
onChiudi={() => setModalePianta(null)}
onSalva={(p) => {
dispatch({ tipo: "piantagione/aggiungi", piantagione: p })
setModalePianta(null)
}}
/>
)}
</>
)
}
function FormAiuola({
aiuola,
onChiudi,
onSalva,
onElimina,
}: {
aiuola: Aiuola | null
onChiudi: () => void
onSalva: (a: Aiuola) => void
onElimina?: () => void
}) {
const [f, setF] = useState<Aiuola>(
aiuola ?? {
id: id(),
nome: "",
suolo: "franco",
metodo: "goccia",
contenitore: "piena terra",
superficie: 5,
pacciamatura: false,
serra: false,
}
)
const agg = (patch: Partial<Aiuola>) => setF({ ...f, ...patch })
return (
<Modale titolo={aiuola ? "Modifica aiuola" : "Nuova aiuola"} onChiudi={onChiudi}>
<Campo label="Nome" aiuto="Come la chiami tu: «filare dei pomodori», «cassone accanto al muro», «vasi sul terrazzo».">
<input type="text" value={f.nome} onChange={(e) => agg({ nome: e.target.value })} placeholder="Orto sud" autoFocus />
</Campo>
<div className="griglia due">
<Campo label="Superficie (m²)" aiuto="Serve a convertire i millimetri in litri.">
<input type="number" min="0.1" step="0.5" value={f.superficie} onChange={(e) => agg({ superficie: Number(e.target.value) })} />
</Campo>
<Campo label="Dove">
<select value={f.contenitore} onChange={(e) => agg({ contenitore: e.target.value as Contenitore })}>
{CONTENITORI.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Campo>
</div>
{f.contenitore === "vaso" && (
<Campo label="Litri di terriccio per vaso" aiuto="In vaso il serbatoio d'acqua è piccolissimo: è il volume, non il terreno, a decidere ogni quanto bagnare.">
<input type="number" min="1" step="1" value={f.litriVaso ?? 20} onChange={(e) => agg({ litriVaso: Number(e.target.value) })} />
</Campo>
)}
<Campo label="Tipo di terreno" aiuto={DESCRIZIONE_SUOLO[f.suolo]}>
<select value={f.suolo} onChange={(e) => agg({ suolo: e.target.value as TipoSuolo })}>
{SUOLI.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Campo>
<p className="aiuto" style={{ marginTop: -8 }}>
<span className="grassetto">Come capirlo senza analisi:</span> inumidisci una manciata di terra e prova a
farne un salsicciotto. Se si sbriciola subito è sabbioso; se si modella ma si spezza piegandolo è franco; se
fai un anello senza crepe è argilloso.
</p>
<Campo label="Metodo di irrigazione" aiuto="Cambia quanta acqua arriva davvero alle radici: a goccia se ne perde il 10%, con l'aspersore anche il 35%.">
<select value={f.metodo} onChange={(e) => agg({ metodo: e.target.value as Metodo })}>
{METODI.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</Campo>
{f.metodo !== "manuale" && (
<>
<Campo
label="Settore sulla centralina"
aiuto="Il numero o il nome della stazione a cui è collegata l'elettrovalvola di quest'aiuola. Serve solo a te, per ritrovarla quando programmi."
>
<input
type="text"
value={f.settore ?? ""}
onChange={(e) => agg({ settore: e.target.value || undefined })}
placeholder="es. Stazione 2"
/>
</Campo>
<Campo
label="Canale sulla centralina automatica"
aiuto="Il numero dell'uscita a relè che comanda questa valvola. Si conta da 0: il primo relè è 0, il secondo 1, e così via. Lascialo vuoto se questo settore non è collegato all'automazione: non verrà mai aperto da solo."
>
<input
type="number"
min="0"
step="1"
value={f.canale ?? ""}
onChange={(e) => agg({ canale: e.target.value === "" ? undefined : Number(e.target.value) })}
placeholder="es. 0"
/>
</Campo>
<div className="griglia due">
<Campo
label="Sonda Watermark"
aiuto="L'ingresso a cui è collegata la sonda che sorveglia questo settore. Vuoto se non ce n'è una."
>
<input
type="number"
min="0"
step="1"
value={f.sensore ?? ""}
onChange={(e) => agg({ sensore: e.target.value === "" ? undefined : Number(e.target.value) })}
placeholder="es. 0"
/>
</Campo>
<Campo
label="Soglia di intervento (kPa)"
aiuto={`Sopra questo valore il terreno è da irrigare. Per un terreno ${f.suolo} il valore tipico è ${SOGLIA_KPA[f.suolo]} kPa.`}
>
<input
type="number"
min="10"
max="150"
step="5"
value={f.sogliaKpa ?? ""}
onChange={(e) => agg({ sogliaKpa: e.target.value === "" ? undefined : Number(e.target.value) })}
placeholder={String(SOGLIA_KPA[f.suolo])}
/>
</Campo>
</div>
<Campo
label="Portata del settore (litri/ora)"
aiuto="È il dato che trasforma i millimetri in minuti di valvola aperta. Misurarlo è più facile che calcolarlo: apri solo questa valvola, raccogli l'acqua di un gocciolatore per un minuto, moltiplica per il numero di gocciolatori e per 60. Se non la sai, lascia vuoto: avrai comunque i litri."
>
<input
type="number"
min="0"
step="10"
value={f.portataSettoreLh ?? ""}
onChange={(e) => agg({ portataSettoreLh: e.target.value ? Number(e.target.value) : undefined })}
placeholder="es. 240"
/>
</Campo>
<Campo
label="Oppure: portata per m² (litri/ora)"
aiuto="Alternativa alla precedente, se conosci la scheda tecnica dell'ala gocciolante: portata del gocciolatore × gocciolatori per metro ÷ distanza fra le ali in metri."
>
<input
type="number"
min="0"
step="0.5"
value={f.portataLh ?? ""}
onChange={(e) => agg({ portataLh: e.target.value ? Number(e.target.value) : undefined })}
placeholder="es. 4"
/>
</Campo>
</>
)}
<label className="interruttore">
<input type="checkbox" checked={f.pacciamatura} onChange={(e) => agg({ pacciamatura: e.target.checked })} />
<span>
<span className="grassetto">Pacciamatura</span>
<span className="aiuto" style={{ display: "block" }}>Paglia, sfalcio secco, telo. Taglia circa un quarto dell'acqua che serve.</span>
</span>
</label>
<label className="interruttore">
<input type="checkbox" checked={f.serra} onChange={(e) => agg({ serra: e.target.checked })} />
<span>
<span className="grassetto">Serra o tunnel</span>
<span className="aiuto" style={{ display: "block" }}>Meno vento e meno evaporazione, ma la pioggia non entra: qui l'irrigazione è tutto.</span>
</span>
</label>
<div className="riga" style={{ gap: 8 }}>
<button className="pulsante" disabled={!f.nome.trim()} onClick={() => onSalva({ ...f, nome: f.nome.trim() })}>
Salva
</button>
{onElimina && <button className="pulsante pericolo piccolo" onClick={onElimina}>Elimina</button>}
</div>
</Modale>
)
}
function FormPianta({
aiuolaId,
onChiudi,
onSalva,
}: {
aiuolaId: string
onChiudi: () => void
onSalva: (p: Piantagione) => void
}) {
const mese = new Date().getMonth() + 1
const [cerca, setCerca] = useState("")
const [scelta, setScelta] = useState<string | null>(null)
const [data, setData] = useState(oggi())
const [quantita, setQuantita] = useState(1)
// Scegliendo una perenne il caso più frequente è la pianta che c'era già:
// proponiamo quello, invece di far credere all'app che sia appena stata messa.
const scegli = (colturaId: string) => {
setScelta(colturaId)
setData(coltura(colturaId)?.perenne ? giorniFa(1095) : oggi())
}
const [varieta, setVarieta] = useState("")
const filtrate = COLTURE.filter((c) => corrisponde(c, cerca))
const stagionali = filtrate.filter(
(c) => c.trapianto?.includes(mese) || c.seminaDiretta?.includes(mese) || c.seminaSemenzaio?.includes(mese)
)
const altre = filtrate.filter((c) => !stagionali.includes(c))
const c = scelta ? coltura(scelta) : null
if (c) {
return (
<Modale titolo={`Pianta ${c.nome.toLowerCase()}`} onChiudi={onChiudi}>
<div className="riga">
<span style={{ fontSize: "2rem" }}>{c.emoji}</span>
<div>
<div className="grassetto">{c.nome}</div>
<div className="sottile">{c.sesto}</div>
</div>
</div>
<Campo
label={c.perenne ? "Da quanto è nel giardino?" : "Data di semina o trapianto in campo"}
aiuto={
c.perenne
? "Per una pianta perenne il consumo d'acqua dipende dalla stagione, non dall'età: se c'era già prima di te, indica una data qualsiasi nel passato. L'importante è non lasciare oggi, altrimenti l'app crede che il terreno sia stato appena bagnato all'impianto e per qualche giorno ti dirà che va tutto bene anche se è secco."
: "È da questa data che l'app calcola la fase della pianta e quindi quanto beve. Se l'hai seminata o trapiantata qualche settimana fa, correggila: cambia parecchio."
}
>
<input type="date" value={data} onChange={(e) => setData(e.target.value)} />
</Campo>
<div className="chip-riga">
{(c.perenne
? [
{ l: "C'era già", g: 1095 },
{ l: "Piantata quest'anno", g: mesiDaGennaio() },
{ l: "Oggi", g: 0 },
]
: [
{ l: "Oggi", g: 0 },
{ l: "Una settimana fa", g: 7 },
{ l: "Due settimane fa", g: 14 },
{ l: "Un mese fa", g: 30 },
]
).map((o) => (
<button key={o.l} className="chip" aria-pressed={data === giorniFa(o.g)} onClick={() => setData(giorniFa(o.g))}>
{o.l}
</button>
))}
</div>
<div className="griglia due">
<Campo label="Quante piante">
<input type="number" min="1" step="1" value={quantita} onChange={(e) => setQuantita(Number(e.target.value))} />
</Campo>
<Campo label="Varietà (facoltativa)">
<input type="text" value={varieta} onChange={(e) => setVarieta(e.target.value)} placeholder="es. San Marzano" />
</Campo>
</div>
<div className="riga" style={{ gap: 8 }}>
<button
className="pulsante"
onClick={() =>
onSalva({
id: id(),
aiuolaId,
colturaId: c.id,
dataMessaADimora: data,
quantita,
varieta: varieta.trim() || undefined,
})
}
>
Aggiungi al giardino
</button>
<button className="pulsante secondario" onClick={() => setScelta(null)}>Cambia coltura</button>
</div>
</Modale>
)
}
return (
<Modale titolo="Cosa pianti?" onChiudi={onChiudi}>
<div className="cerca">
<span>🔍</span>
<input type="text" value={cerca} onChange={(e) => setCerca(e.target.value)} placeholder="Cerca una coltura (anche col nome di casa tua)" autoFocus />
</div>
{stagionali.length > 0 && (
<>
<h3 style={{ fontSize: ".85rem", color: "var(--muted-fg)" }}>DI STAGIONE ADESSO</h3>
<div className="lista-pulita">
{stagionali.map((x) => <VoceColtura key={x.id} c={x} onClick={() => scegli(x.id)} />)}
</div>
</>
)}
{altre.length > 0 && (
<>
<h3 style={{ fontSize: ".85rem", color: "var(--muted-fg)" }}>FUORI STAGIONE</h3>
<div className="lista-pulita">
{altre.slice(0, 40).map((x) => <VoceColtura key={x.id} c={x} onClick={() => scegli(x.id)} />)}
</div>
</>
)}
</Modale>
)
}
function VoceColtura({ c, onClick }: { c: (typeof COLTURE)[number]; onClick: () => void }) {
return (
<button className="voce" onClick={onClick}>
<span className="em">{c.emoji}</span>
<span className="corpo">
<span className="t" style={{ display: "block" }}>{c.nome}</span>
<span className="s">
{c.sinonimi?.length ? `detta anche ${c.sinonimi[0]} · ` : ""}
{c.famiglia} · acqua: {c.sensibilitaIdrica}
</span>
</span>
<span style={{ color: "var(--muted-fg)" }}>›</span>
</button>
)
}
/** Data ISO di N giorni fa: serve alle scorciatoie del form. */
function giorniFa(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Giorni trascorsi dal 1° gennaio, per la scorciatoia "piantata quest'anno". */
function mesiDaGennaio(): number {
const ora = new Date()
return Math.round((ora.getTime() - new Date(ora.getFullYear(), 0, 1).getTime()) / 86400000)
}
+169
View File
@@ -0,0 +1,169 @@
import { useMemo, useState } from "react"
import { COLTURE, MESI, MESI_BREVI, corrisponde, nellaCategoria } from "../lib/colture"
import type { Categoria, Coltura } from "../lib/types"
import { Modale } from "../components/comuni"
const CATEGORIE: (Categoria | "tutte")[] = ["tutte", "ortaggio", "frutta", "aromatica"]
const ETICHETTE: Record<string, string> = {
tutte: "Tutte",
ortaggio: "Ortaggi",
frutta: "Frutta",
aromatica: "Aromatiche",
}
export default function Guida() {
const [cerca, setCerca] = useState("")
const [categoria, setCategoria] = useState<Categoria | "tutte">("tutte")
const [soloStagione, setSoloStagione] = useState(false)
const [aperta, setAperta] = useState<Coltura | null>(null)
const mese = new Date().getMonth() + 1
const elenco = useMemo(
() =>
COLTURE.filter((c) => {
if (!nellaCategoria(c, categoria)) return false
if (!corrisponde(c, cerca)) return false
if (soloStagione) {
const attiva =
c.seminaDiretta?.includes(mese) || c.seminaSemenzaio?.includes(mese) || c.trapianto?.includes(mese) || c.raccolta.includes(mese)
if (!attiva) return false
}
return true
}),
[cerca, categoria, soloStagione, mese]
)
return (
<>
<div>
<h1>Guida alle colture</h1>
<p className="sommario">{COLTURE.length} schede con calendario, sesti d'impianto, consociazioni e problemi tipici. La ricerca capisce anche i nomi dialettali.</p>
</div>
<div className="cerca">
<span>🔍</span>
<input type="text" value={cerca} onChange={(e) => setCerca(e.target.value)} placeholder="Cerca pomodoro, cocomero, ramerino…" />
</div>
<div className="chip-riga">
{CATEGORIE.map((c) => (
<button key={c} className="chip" aria-pressed={categoria === c} onClick={() => setCategoria(c)}>
{ETICHETTE[c]}
</button>
))}
<button className="chip" aria-pressed={soloStagione} onClick={() => setSoloStagione(!soloStagione)}>
Attive a {MESI[mese - 1]}
</button>
</div>
<div className="lista-pulita">
{elenco.map((c) => (
<button key={c.id} className="voce" onClick={() => setAperta(c)}>
<span className="em">{c.emoji}</span>
<span className="corpo">
<span className="t" style={{ display: "block" }}>{c.nome}</span>
<span className="s">
{c.sinonimi?.length ? `detta anche ${c.sinonimi[0]} · ` : ""}
{c.famiglia} · {c.perenne ? "perenne" : "annuale"} · fabbisogno idrico {c.sensibilitaIdrica}
</span>
</span>
<span style={{ color: "var(--muted-fg)" }}>›</span>
</button>
))}
{elenco.length === 0 && <p className="sommario centrato">Nessuna coltura trovata.</p>}
</div>
{aperta && <SchedaColtura c={aperta} onChiudi={() => setAperta(null)} />}
</>
)
}
function SchedaColtura({ c, onChiudi }: { c: Coltura; onChiudi: () => void }) {
return (
<Modale titolo={c.nome} onChiudi={onChiudi}>
<div className="riga">
<span style={{ fontSize: "2.2rem" }}>{c.emoji}</span>
<div>
<div className="sottile">{c.famiglia} · {c.perenne ? "pianta perenne" : "ciclo annuale"}</div>
{c.sinonimi?.length ? <div className="sottile">Detta anche {c.sinonimi.slice(0, 4).join(", ")}.</div> : null}
<div className="riga avvolgi" style={{ gap: 6, marginTop: 5 }}>
<span className="etichetta acqua">acqua: {c.sensibilitaIdrica}</span>
<span className="etichetta neutro">{c.esposizione}</span>
<span className="etichetta neutro">pH {c.phIdeale[0]}–{c.phIdeale[1]}</span>
</div>
</div>
</div>
<div>
<h3 style={{ marginBottom: 7 }}>Calendario</h3>
<div className="calendario">
{MESI_BREVI.map((m, i) => {
const mese = i + 1
const semina = c.seminaDiretta?.includes(mese) || c.seminaSemenzaio?.includes(mese)
const trap = c.trapianto?.includes(mese)
const racc = c.raccolta.includes(mese)
const cls = racc ? "raccolta" : trap ? "trapianto" : semina ? "semina" : ""
return <i key={i} className={cls}>{m}</i>
})}
</div>
<div className="legenda" style={{ marginTop: 7 }}>
<span><b style={{ background: "var(--sole)" }} /> semina</span>
<span><b style={{ background: "var(--verde-500)" }} /> trapianto</span>
<span><b style={{ background: "var(--danger)" }} /> raccolta</span>
</div>
</div>
<div>
<h3 style={{ marginBottom: 7 }}>Acqua</h3>
<table className="tabellina">
<tbody>
<tr><td>Fase critica</td><td>{c.fasiCritiche}</td></tr>
<tr><td>Profondità radici</td><td>{c.profonditaRadici.toFixed(1)} m — {c.profonditaRadici < 0.4 ? "radici corte: turni brevi e dosi piccole" : c.profonditaRadici > 1 ? "radici profonde: dosi abbondanti e turni lunghi" : "profondità media"}</td></tr>
<tr><td>Soglia di stress</td><td>quando è stato consumato il {Math.round(c.fattoreDeplezione * 100)}% dell'acqua disponibile</td></tr>
<tr><td>Kc (piena vegetazione)</td><td>{c.kc.piena.toFixed(2)} volte l'evapotraspirazione di riferimento</td></tr>
</tbody>
</table>
</div>
<div>
<h3 style={{ marginBottom: 7 }}>Come si coltiva</h3>
<table className="tabellina">
<tbody>
<tr><td>Sesto d'impianto</td><td>{c.sesto}</td></tr>
<tr><td>Densità</td><td>circa {c.piantePerMq < 1 ? `1 pianta ogni ${Math.round(1 / c.piantePerMq)} m²` : `${Math.round(c.piantePerMq)} piante per m²`}</td></tr>
<tr><td>Esposizione</td><td>{c.esposizione}</td></tr>
<tr><td>Concimazione</td><td>{c.concimazione}</td></tr>
{c.consociazioni.length > 0 && <tr><td>Sta bene con</td><td>{c.consociazioni.join(", ")}</td></tr>}
{c.daEvitare.length > 0 && <tr><td>Da evitare vicino</td><td>{c.daEvitare.join(", ")}</td></tr>}
</tbody>
</table>
</div>
{c.cure.length > 0 && (
<div>
<h3 style={{ marginBottom: 7 }}>Cure e accorgimenti</h3>
<ul className="lista-punti sottile">
{c.cure.map((x, i) => <li key={i}>{x}</li>)}
</ul>
</div>
)}
{c.problemi.length > 0 && (
<div>
<h3 style={{ marginBottom: 7 }}>Se qualcosa va storto</h3>
<div className="lista-pulita">
{c.problemi.map((p) => (
<div key={p.nome} className="scheda stretta" style={{ boxShadow: "none" }}>
<div className="grassetto" style={{ fontSize: ".92rem" }}>{p.nome}</div>
<p className="sottile" style={{ marginTop: 3 }}><span style={{ color: "var(--muted-fg)" }}>Sintomo: </span>{p.sintomo}</p>
<p className="sottile" style={{ marginTop: 2 }}><span style={{ color: "var(--muted-fg)" }}>Rimedio: </span>{p.rimedio}</p>
</div>
))}
</div>
</div>
)}
{c.note && <p className="sottile">{c.note}</p>}
</Modale>
)
}
+483
View File
@@ -0,0 +1,483 @@
import { useState } from "react"
import { useApp, esporta } from "../state/store"
import { cercaLuogo, type RisultatoGeocoding } from "../lib/meteo"
import { verificaDispositivo } from "../lib/agente"
import type { DriverCentralina } from "../lib/types"
import { Campo } from "../components/comuni"
import type { Stato } from "../lib/types"
export default function Impostazioni() {
const { stato, dispatch, meteo, aggiornaMeteo, caricamentoMeteo, sync, erroreSync, sincronizza } = useApp()
const [query, setQuery] = useState("")
const [risultati, setRisultati] = useState<RisultatoGeocoding[]>([])
const [ricerca, setRicerca] = useState(false)
const [errore, setErrore] = useState<string | null>(null)
async function cerca() {
if (!query.trim()) return
setRicerca(true)
setErrore(null)
try {
const r = await cercaLuogo(query.trim())
setRisultati(r)
if (r.length === 0) setErrore("Nessun luogo trovato con questo nome.")
} catch {
setErrore("Ricerca non riuscita: serve connessione a internet.")
} finally {
setRicerca(false)
}
}
function posizioneAttuale() {
if (!navigator.geolocation) {
setErrore("Il browser non espone la geolocalizzazione.")
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
dispatch({
tipo: "impostazioni",
patch: {
luogo: {
nome: "Posizione attuale",
lat: Math.round(pos.coords.latitude * 1000) / 1000,
lon: Math.round(pos.coords.longitude * 1000) / 1000,
},
},
})
setRisultati([])
},
() => setErrore("Permesso di geolocalizzazione negato.")
)
}
function importa(file: File) {
const lettore = new FileReader()
lettore.onload = () => {
try {
const s = JSON.parse(String(lettore.result)) as Stato
if (!Array.isArray(s.aiuole)) throw new Error("formato")
dispatch({ tipo: "carica", stato: s })
alert("Giardino importato.")
} catch {
alert("File non valido: dev'essere un backup esportato da Pollice Verde.")
}
}
lettore.readAsText(file)
}
return (
<>
<h1>Impostazioni</h1>
<section className="scheda">
<h2>📍 Dove si trova il giardino</h2>
<p className="sottile" style={{ marginTop: 6 }}>
Da qui l'app scarica pioggia, temperature ed evapotraspirazione reali della tua zona. Sono questi tre dati a
rendere il calcolo dell'irrigazione diverso da un calendario fisso. Il servizio è Open-Meteo: gratuito, senza
account e senza tracciamento.
</p>
{stato.impostazioni.luogo && (
<div className="avviso info" style={{ marginTop: 12 }}>
<span className="em">✓</span>
<div>
<div className="grassetto">{stato.impostazioni.luogo.nome}</div>
<p className="sottile">
{stato.impostazioni.luogo.lat.toFixed(3)}, {stato.impostazioni.luogo.lon.toFixed(3)}
{meteo && ` · dati aggiornati ${new Date(meteo.aggiornato).toLocaleString("it-IT", { hour: "2-digit", minute: "2-digit", day: "numeric", month: "short" })}`}
</p>
</div>
</div>
)}
<div className="riga" style={{ marginTop: 12, gap: 8 }}>
<div className="cerca" style={{ flex: 1 }}>
<span>🔍</span>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && cerca()}
placeholder="Città o paese"
/>
</div>
<button className="pulsante piccolo" onClick={cerca} disabled={ricerca}>
{ricerca ? "…" : "Cerca"}
</button>
</div>
<button className="pulsante secondario piccolo" style={{ marginTop: 8 }} onClick={posizioneAttuale}>
Usa la posizione attuale
</button>
{errore && <p className="sottile" style={{ color: "var(--danger)", marginTop: 8 }}>{errore}</p>}
{risultati.length > 0 && (
<div className="lista-pulita" style={{ marginTop: 12 }}>
{risultati.map((r) => (
<button
key={`${r.lat},${r.lon}`}
className="voce"
onClick={() => {
dispatch({ tipo: "impostazioni", patch: { luogo: { nome: r.nome, lat: r.lat, lon: r.lon } } })
setRisultati([])
setQuery("")
}}
>
<span className="em">📍</span>
<span className="corpo">
<span className="t" style={{ display: "block" }}>{r.nome}</span>
<span className="s">{r.regione}</span>
</span>
</button>
))}
</div>
)}
{stato.impostazioni.luogo && (
<button className="pulsante secondario piccolo" style={{ marginTop: 12 }} onClick={aggiornaMeteo} disabled={caricamentoMeteo}>
{caricamentoMeteo ? "Aggiorno…" : "🔄 Aggiorna previsioni"}
</button>
)}
</section>
<section className="scheda">
<h2>💧 Taratura del calcolo</h2>
<p className="sottile" style={{ marginTop: 6 }}>
Se noti che l'app propone sempre troppa o troppo poca acqua, questi due valori sono le manopole giuste.
</p>
<div style={{ marginTop: 14 }}>
<Campo
label={`Risparmio della pacciamatura: ${Math.round(stato.impostazioni.risparmioPacciamatura * 100)}%`}
aiuto="Quanto riduce i consumi uno strato di paglia o telo. Il valore predefinito (25%) è realistico per 5-8 cm di paglia."
>
<input
type="range"
min="0"
max="0.45"
step="0.05"
value={stato.impostazioni.risparmioPacciamatura}
onChange={(e) => dispatch({ tipo: "impostazioni", patch: { risparmioPacciamatura: Number(e.target.value) } })}
/>
</Campo>
</div>
<h3 style={{ marginTop: 16, marginBottom: 8 }}>Efficienza dei metodi</h3>
<p className="sottile" style={{ marginBottom: 10 }}>
Quanta dell'acqua erogata arriva davvero alle radici. Il resto evapora, scorre via o finisce fuori bersaglio.
</p>
{(Object.keys(stato.impostazioni.efficienza) as (keyof typeof stato.impostazioni.efficienza)[]).map((m) => (
<div key={m} style={{ marginBottom: 10 }}>
<div className="riga spazia sottile">
<span style={{ textTransform: "capitalize" }}>{m}</span>
<span className="grassetto">{Math.round(stato.impostazioni.efficienza[m] * 100)}%</span>
</div>
<input
type="range"
min="0.4"
max="1"
step="0.05"
style={{ width: "100%" }}
value={stato.impostazioni.efficienza[m]}
onChange={(e) =>
dispatch({
tipo: "impostazioni",
patch: { efficienza: { ...stato.impostazioni.efficienza, [m]: Number(e.target.value) } },
})
}
/>
</div>
))}
</section>
<SezioneCentralina sync={sync} erroreSync={erroreSync} sincronizza={sincronizza} />
<section className="scheda">
<h2>🎨 Aspetto</h2>
<div className="chip-riga" style={{ marginTop: 10 }}>
{[
{ v: null, l: "Come il sistema" },
{ v: false, l: "Chiaro" },
{ v: true, l: "Scuro" },
].map((o) => (
<button
key={String(o.v)}
className="chip"
aria-pressed={stato.impostazioni.temaScuro === o.v}
onClick={() => dispatch({ tipo: "impostazioni", patch: { temaScuro: o.v } })}
>
{o.l}
</button>
))}
</div>
</section>
<section className="scheda">
<h2>💾 I tuoi dati</h2>
<p className="sottile" style={{ marginTop: 6 }}>
Tutto resta nel browser di questo dispositivo: nessun account, nessun server, niente che esca da qui tranne la
richiesta del meteo. Se svuoti i dati del browser, il giardino sparisce — quindi conviene esportare un backup
ogni tanto, e usarlo per passare da un dispositivo all'altro.
</p>
<div className="riga avvolgi" style={{ gap: 8, marginTop: 12 }}>
<button className="pulsante secondario piccolo" onClick={() => esporta(stato)}>⬇ Esporta backup</button>
<label className="pulsante secondario piccolo" style={{ cursor: "pointer" }}>
⬆ Importa backup
<input
type="file"
accept="application/json"
style={{ display: "none" }}
onChange={(e) => e.target.files?.[0] && importa(e.target.files[0])}
/>
</label>
<button
className="pulsante pericolo piccolo"
onClick={() => {
if (confirm("Cancellare aiuole, colture e diario? Le impostazioni restano.")) dispatch({ tipo: "reset" })
}}
>
Svuota il giardino
</button>
</div>
</section>
<section className="scheda">
<h2>Come funziona il calcolo</h2>
<p className="sottile" style={{ marginTop: 8 }}>
L'app tratta il terreno come un serbatoio. La sua capacità dipende dal tipo di suolo e da quanto in profondità
arrivano le radici di quella coltura: un pomodoro su terreno franco ha a disposizione circa 128 mm d'acqua,
un'insalata su sabbia appena 24.
</p>
<p className="sottile" style={{ marginTop: 8 }}>
Ogni giorno la pianta ne preleva una quota pari a <span className="grassetto">ET0 × Kc</span>: ET0 è
l'evapotraspirazione di riferimento misurata dal servizio meteo, Kc è un coefficiente che dipende dalla specie
e da quanto è sviluppata in quel momento. Pioggia e irrigazioni ricaricano il serbatoio, al netto di quello
che evapora subito o scorre via.
</p>
<p className="sottile" style={{ marginTop: 8 }}>
Quando il consumo raggiunge la <span className="grassetto">soglia di stress</span> — una frazione della riserva
che varia da specie a specie: il sedano soffre già al 20%, l'olivo regge fino al 65% — è il momento di
irrigare, e la dose è esattamente l'acqua che manca. È il metodo FAO-56, lo stesso usato dai consorzi irrigui,
applicato alla scala di un orto familiare.
</p>
<p className="sottile" style={{ marginTop: 8 }}>
Resta una stima: non conosce il tuo microclima, l'ombra del muro a nord o quella zona che resta sempre umida.
Usala come guida e verifica col dito nel terreno — dopo qualche settimana saprai di quanto correggerla.
</p>
</section>
<p className="sottile centrato" style={{ padding: "8px 0 4px" }}>
Pollice Verde · dati meteo <a href="https://open-meteo.com" target="_blank" rel="noreferrer" style={{ color: "var(--verde-700)" }}>Open-Meteo</a> ·
coefficienti colturali FAO-56
</p>
</>
)
}
const DRIVER: { v: DriverCentralina; l: string; nota: string }[] = [
{ v: "simulato", l: "Simulato", nota: "Nessun dispositivo: l'agente scrive soltanto cosa farebbe. Parti sempre da qui." },
{ v: "esp32", l: "ESP32 Pollice Verde", nota: "La scheda fai-da-te con il firmware incluso nel progetto (cartella firmware/)." },
{ v: "shelly-gen2", l: "Shelly Gen 2", nota: "Shelly Plus 1, Plus 2PM, Pro: usano l'API RPC e chiudono da soli a fine tempo." },
{ v: "shelly-gen1", l: "Shelly Gen 1", nota: "Shelly 1, 1PM, 2.5: API classica con timer integrato." },
{ v: "tasmota", l: "Tasmota", nota: "Qualsiasi relè con firmware Tasmota, comandato con PulseTime." },
]
function SezioneCentralina({
sync,
erroreSync,
sincronizza,
}: {
sync: string
erroreSync: string | null
sincronizza: () => void
}) {
const { stato, dispatch } = useApp()
const c = stato.impostazioni.centralina
const [prova, setProva] = useState<string | null>(null)
const [inProva, setInProva] = useState(false)
const agg = (patch: Partial<typeof c>) =>
dispatch({ tipo: "impostazioni", patch: { centralina: { ...c, ...patch } } })
const aggSicurezze = (patch: Partial<typeof c.sicurezze>) =>
agg({ sicurezze: { ...c.sicurezze, ...patch } })
const senzaCanale = stato.aiuole.filter((a) => a.canale === undefined)
const driverScelto = DRIVER.find((d) => d.v === c.driver)
return (
<section className="scheda">
<h2>🎛️ Centralina automatica</h2>
<p className="sottile" style={{ marginTop: 6 }}>
Un piccolo programma (l&apos;«agente») gira su qualcosa di sempre acceso — un Raspberry Pi, un NAS, un vecchio
portatile — e all&apos;orario stabilito apre le valvole per i minuti che il calcolo ha deciso. L&apos;app da sola non
può farlo: il browser la spegne quando chiudi la scheda.
</p>
<Campo
label="Indirizzo dell'agente"
aiuto="Come lo vedi sulla rete di casa, per esempio http://192.168.1.50:8088. Lascialo vuoto per usare l'app senza automazione."
>
<input
type="text"
value={c.urlAgente}
onChange={(e) => agg({ urlAgente: e.target.value.trim() })}
placeholder="http://192.168.1.50:8088"
/>
</Campo>
{c.urlAgente && (
<div className="riga avvolgi" style={{ gap: 8 }}>
<span className={`etichetta ${sync === "collegato" ? "" : sync === "errore" ? "critico" : "neutro"}`}>
{sync === "collegato" ? "✓ sincronizzato" : sync === "in-corso" ? "sincronizzo…" : sync === "errore" ? "non collegato" : "non configurato"}
</span>
<button className="pulsante secondario piccolo" onClick={sincronizza}>Risincronizza</button>
</div>
)}
{erroreSync && <p className="sottile" style={{ color: "var(--danger)" }}>{erroreSync}</p>}
<div className="separatore" />
<Campo label="Tipo di dispositivo" aiuto={driverScelto?.nota}>
<select value={c.driver} onChange={(e) => agg({ driver: e.target.value as DriverCentralina })}>
{DRIVER.map((d) => (
<option key={d.v} value={d.v}>{d.l}</option>
))}
</select>
</Campo>
{c.driver !== "simulato" && (
<Campo label="Indirizzo del dispositivo" aiuto="IP o nome sulla rete locale della scheda a relè. Assegnale un IP fisso dal router, o prima o poi cambierà da solo.">
<input type="text" value={c.host} onChange={(e) => agg({ host: e.target.value.trim() })} placeholder="192.168.1.60" />
</Campo>
)}
<Campo
label="Dispositivo delle sonde"
aiuto="Lascia vuoto se le sonde Watermark sono collegate alla stessa scheda dei relè, che è il caso normale."
>
<input type="text" value={c.hostSensori} onChange={(e) => agg({ hostSensori: e.target.value.trim() })} placeholder="come la centralina" />
</Campo>
<div className="griglia due">
<Campo label="Lettura sonde ogni (minuti)" aiuto="I Watermark non vanno letti di continuo: ogni lettura consuma un po' di gesso, e la tensione idrica si muove in ore.">
<input type="number" min="5" step="5" value={c.intervalloLetturaMinuti} onChange={(e) => agg({ intervalloLetturaMinuti: Number(e.target.value) })} />
</Campo>
<Campo label="Orario di partenza" aiuto="Prima dell'alba: meno evaporazione e foglie che asciugano col sole.">
<input type="text" value={c.orarioPartenza} onChange={(e) => agg({ orarioPartenza: e.target.value })} placeholder="06:00" />
</Campo>
<Campo label="Pausa fra settori (minuti)" aiuto="Il tempo che serve alla pressione per riprendersi fra una valvola e l'altra.">
<input type="number" min="0" step="1" value={c.pausaFraSettori} onChange={(e) => agg({ pausaFraSettori: Number(e.target.value) })} />
</Campo>
</div>
{c.urlAgente && (
<button
className="pulsante secondario piccolo"
disabled={inProva}
onClick={async () => {
setInProva(true)
setProva(null)
try {
const r = await verificaDispositivo(c.urlAgente)
setProva(r.ok ? `✓ ${r.driver} risponde.` : `✗ ${r.driver}: ${r.errore}`)
} catch (e) {
setProva(`✗ ${(e as Error).message}`)
} finally {
setInProva(false)
}
}}
>
{inProva ? "Verifico…" : "Verifica il dispositivo"}
</button>
)}
{prova && <p className="sottile">{prova}</p>}
<div className="separatore" />
<h3 style={{ marginBottom: 8 }}>Sicurezze</h3>
<p className="sottile" style={{ marginBottom: 12 }}>
Un&apos;automazione che sbaglia non fa un errore di calcolo: allaga un giardino per una notte intera. Questi limiti
sono il freno a mano.
</p>
<div className="griglia due">
<Campo label="Minuti massimi per turno" aiuto="Oltre questa durata il turno viene troncato, qualunque cosa dica il calcolo.">
<input type="number" min="1" step="5" value={c.sicurezze.minutiMassimiPerTurno} onChange={(e) => aggSicurezze({ minutiMassimiPerTurno: Number(e.target.value) })} />
</Campo>
<Campo label="Litri massimi al giorno" aiuto="Tetto su tutto il giardino, sommando tutti i settori.">
<input type="number" min="10" step="50" value={c.sicurezze.litriMassimiAlGiorno} onChange={(e) => aggSicurezze({ litriMassimiAlGiorno: Number(e.target.value) })} />
</Campo>
<Campo label="Blocco gelo sotto (°C)" aiuto="Con la minima prevista sotto questa soglia non si irriga: l'acqua gela nelle tubazioni e il terreno bagnato peggiora il danno.">
<input type="number" step="1" value={c.sicurezze.bloccoGeloSotto} onChange={(e) => aggSicurezze({ bloccoGeloSotto: Number(e.target.value) })} />
</Campo>
<Campo label="Blocco vento sopra (km/h)" aiuto="Vale solo per i settori ad aspersione: sopra questa soglia l'acqua finisce ovunque tranne che sulle piante.">
<input type="number" step="5" value={c.sicurezze.bloccoVentoSopra} onChange={(e) => aggSicurezze({ bloccoVentoSopra: Number(e.target.value) })} />
</Campo>
</div>
<Campo label="Blocco pioggia sopra (mm previsti oggi)" aiuto="Il calcolo tiene già conto della pioggia; questo è un secondo controllo che annulla il turno a prescindere.">
<input type="number" step="1" value={c.sicurezze.bloccoPioggiaSopra} onChange={(e) => aggSicurezze({ bloccoPioggiaSopra: Number(e.target.value) })} />
</Campo>
<label className="interruttore">
<input
type="checkbox"
checked={c.sicurezze.vetoSonde}
onChange={(e) => aggSicurezze({ vetoSonde: e.target.checked })}
/>
<span>
<span className="grassetto">Le sonde possono annullare un turno</span>
<span className="aiuto" style={{ display: "block" }}>
Se la sonda misura il terreno ancora umido, il turno salta anche quando il calcolo dice di irrigare: la
misura batte la stima. Con questa spunta attiva, una sonda guasta blocca il suo settore invece di
lasciarlo scoperto — è il comportamento prudente.
</span>
</span>
</label>
<div className="separatore" />
{senzaCanale.length > 0 && (
<div className="avviso attenzione">
<span className="em">🔌</span>
<p className="sottile">
{senzaCanale.length === 1 ? "Un'aiuola non ha" : `${senzaCanale.length} aiuole non hanno`} un canale
assegnato ({senzaCanale.map((a) => a.nome).join(", ")}): {senzaCanale.length === 1 ? "non verrà" : "non verranno"} mai
{" "}irrigate dall&apos;automazione. Il canale si imposta nella scheda dell&apos;aiuola, in Orto.
</p>
</div>
)}
<label className="interruttore" style={{ marginTop: 8 }}>
<input
type="checkbox"
checked={c.automazioneAttiva}
onChange={(e) => {
if (e.target.checked) {
const ok = confirm(
"Attivando l'automazione le valvole si apriranno da sole, anche mentre non ci sei.\n\n" +
"Prima di procedere verifica di aver fatto tutte e tre queste cose:\n" +
"• ogni canale è stato provato a mano e apre la valvola giusta\n" +
"• le sicurezze (minuti e litri massimi) sono tarate sul tuo impianto\n" +
"• la prova a secco dà risultati sensati\n\n" +
"Attivo l'automazione?"
)
if (!ok) return
}
agg({ automazioneAttiva: e.target.checked })
}}
/>
<span>
<span className="grassetto">Automazione attiva</span>
<span className="aiuto" style={{ display: "block" }}>
{c.automazioneAttiva
? "Le valvole si aprono da sole all'orario stabilito. Puoi fermare tutto dal pannello nella sezione Turni."
: "Spenta: l'agente calcola e registra ma non apre nulla. È la modalità con cui conviene iniziare."}
</span>
</span>
</label>
</section>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { useMemo, useState } from "react"
import { useApp, id, oggi as dataOggi } from "../state/store"
import { useConsigli } from "../state/consigli"
import { descrizioneMeteo, isoOggi } from "../lib/meteo"
import { allerteMeteo, attivitaDelMese } from "../lib/attivita"
import { COLORI_STATO, ETICHETTA_STATO, formattaDurata, orarioConsigliato } from "../lib/irrigazione"
import { Barra, Modale, Vuoto } from "../components/comuni"
import { MESI } from "../lib/colture"
export default function Oggi({ vaiA }: { vaiA: (p: string) => void }) {
const { stato, meteo, caricamentoMeteo, erroreMeteo, dispatch } = useApp()
const consigli = useConsigli()
const [dettaglio, setDettaglio] = useState<string | null>(null)
const mese = new Date().getMonth() + 1
const attivita = useMemo(
() => attivitaDelMese(mese, stato.aiuole, stato.piantagioni, stato.eventi),
[mese, stato.aiuole, stato.piantagioni, stato.eventi]
)
const allerte = useMemo(() => (meteo ? allerteMeteo(meteo.giorni) : []), [meteo])
const daBagnare = consigli.filter((c) => c.irrigareOggi)
const daControllare = consigli.filter((c) => !c.irrigareOggi && c.stato === "attenzione")
const oggiIso = isoOggi()
const giorniVisibili = meteo?.giorni.filter((g) => g.data >= oggiIso).slice(0, 8) ?? []
const meteoOggi = meteo?.giorni.find((g) => g.data === oggiIso)
function registraIrrigazione(piantagioneId: string, aiuolaId: string, mm: number, litri: number) {
dispatch({
tipo: "evento/aggiungi",
evento: {
id: id(), data: dataOggi(), tipo: "irrigazione", piantagioneId, aiuolaId,
mm: Math.round(mm), quantita: Math.round(litri), unita: "litri",
},
})
}
if (!stato.impostazioni.luogo) {
return (
<Vuoto
icona="📍"
titolo="Dimmi dov'è il giardino"
testo="Serve la posizione per scaricare pioggia, temperature ed evapotraspirazione reali della tua zona: sono questi dati a far calcolare all'app quanto e quando irrigare."
azione={<button className="pulsante" onClick={() => vaiA("impostazioni")}>Imposta la località</button>}
/>
)
}
if (stato.aiuole.length === 0) {
return (
<Vuoto
icona="🌱"
titolo="Iniziamo dal primo pezzo di terra"
testo="Crea un'aiuola — un filare, un cassone, un vaso grande — descrivi il terreno e il metodo di irrigazione, poi mettici dentro le tue piante. Da lì l'app inizia a fare i conti."
azione={<button className="pulsante" onClick={() => vaiA("giardino")}>Crea la prima aiuola</button>}
/>
)
}
const c = dettaglio ? consigli.find((x) => x.piantagioneId === dettaglio) : null
return (
<>
<div>
<h1>
{saluto()}
{stato.impostazioni.luogo ? `, ${stato.impostazioni.luogo.nome}` : ""}
</h1>
<p className="sommario" style={{ marginTop: 4 }}>
{new Date().toLocaleDateString("it-IT", { weekday: "long", day: "numeric", month: "long" })} ·{" "}
{meteoOggi
? `${descrizioneMeteo(meteoOggi.codice).testo.toLowerCase()}, ${meteoOggi.tMin.toFixed(0)}-${meteoOggi.tMax.toFixed(0)} °C`
: caricamentoMeteo
? "carico il meteo…"
: "meteo non disponibile"}
</p>
</div>
{erroreMeteo && !meteo && (
<div className="avviso attenzione">
<span className="em">📡</span>
<div>
<div className="grassetto">Meteo non raggiungibile</div>
<p className="sottile">
{erroreMeteo}. Senza dati meteo l'app non può calcolare il bilancio idrico. Riprova quando hai
connessione: i dati vengono poi salvati e restano consultabili anche offline.
</p>
</div>
</div>
)}
{giorniVisibili.length > 0 && (
<div className="strisciaMeteo">
{giorniVisibili.map((g) => {
const d = descrizioneMeteo(g.codice)
return (
<div key={g.data} className={g.data === oggiIso ? "oggi" : undefined}>
<div className="g">
{g.data === oggiIso ? "oggi" : new Date(g.data + "T12:00:00").toLocaleDateString("it-IT", { weekday: "short" })}
</div>
<div className="i">{d.icona}</div>
<div className="t">{g.tMax.toFixed(0)}°</div>
<div className="p">{g.pioggia >= 0.5 ? `${g.pioggia.toFixed(0)} mm` : ""}</div>
</div>
)
})}
</div>
)}
{allerte.map((a) => (
<div key={a.titolo} className={`avviso ${a.livello}`}>
<span className="em">{a.emoji}</span>
<div>
<div className="grassetto">{a.titolo}</div>
<p className="sottile" style={{ marginTop: 3 }}>{a.testo}</p>
</div>
</div>
))}
<section className="scheda">
<div className="titolo-sezione">
<h2>💧 Irrigazione di oggi</h2>
{daBagnare.length > 0 && <span className="conta">{daBagnare.length}</span>}
</div>
{consigli.length === 0 ? (
<p className="sommario" style={{ marginTop: 8 }}>
Non hai ancora colture in campo. Aggiungile dalla sezione Giardino e qui comparirà cosa bagnare, quando e
quanto.
</p>
) : daBagnare.length === 0 ? (
<div style={{ marginTop: 10 }}>
<p className="grassetto" style={{ color: "var(--ok)" }}>Oggi non serve irrigare nulla. ✓</p>
<p className="sottile" style={{ marginTop: 4 }}>
{daControllare.length > 0
? `${daControllare.length} ${daControllare.length === 1 ? "coltura si avvicina" : "colture si avvicinano"} alla soglia: probabilmente tocca tra un giorno o due.`
: "Il terreno ha ancora riserva sufficiente in tutte le aiuole."}
</p>
{meteoOggi && (
<p className="sottile" style={{ marginTop: 8, paddingTop: 8, borderTop: "1px solid var(--bordo)" }}>
<span className="grassetto">Quando bagnare, quando serve: </span>
{orarioConsigliato(meteoOggi.tMax)}
</p>
)}
</div>
) : (
<ul className="lista-pulita" style={{ marginTop: 12 }}>
{daBagnare.map((c) => (
<li key={c.piantagioneId} className="scheda stretta" style={{ boxShadow: "none", borderColor: "var(--bordo-forte)" }}>
<div className="riga spazia">
<div className="riga">
<span style={{ fontSize: "1.4rem" }}>{c.coltura.emoji}</span>
<div>
<div className="grassetto">{c.coltura.nome}</div>
<div className="sottile">{c.aiuola.nome} · {c.fase}</div>
</div>
</div>
<span className="etichetta critico">{ETICHETTA_STATO[c.stato]}</span>
</div>
<div className="riga avvolgi" style={{ margin: "12px 0 10px", gap: 18 }}>
<div>
<div className="nome" style={{ fontSize: ".7rem", color: "var(--muted-fg)", fontWeight: 700 }}>DOSE</div>
<div style={{ fontSize: "1.15rem", fontWeight: 700 }}>
{Math.round(c.doseLitri)} <span style={{ fontSize: ".8rem", fontWeight: 600, color: "var(--muted-fg)" }}>litri</span>
</div>
</div>
<div>
<div className="nome" style={{ fontSize: ".7rem", color: "var(--muted-fg)", fontWeight: 700 }}>EQUIVALE A</div>
<div style={{ fontSize: "1.15rem", fontWeight: 700 }}>
{c.doseMm.toFixed(0)} <span style={{ fontSize: ".8rem", fontWeight: 600, color: "var(--muted-fg)" }}>mm</span>
</div>
</div>
{c.minuti !== null && (
<div>
<div className="nome" style={{ fontSize: ".7rem", color: "var(--muted-fg)", fontWeight: 700 }}>IMPIANTO</div>
<div style={{ fontSize: "1.15rem", fontWeight: 700 }}>{formattaDurata(c.minuti)}</div>
</div>
)}
</div>
<p className="sottile">{c.motivazione}</p>
<div className="riga" style={{ marginTop: 12, gap: 8 }}>
<button className="pulsante piccolo" onClick={() => registraIrrigazione(c.piantagioneId, c.aiuola.id, c.doseMm, c.doseLitri)}>
✓ Fatto, registra
</button>
<button className="pulsante secondario piccolo" onClick={() => setDettaglio(c.piantagioneId)}>
Perché?
</button>
</div>
</li>
))}
</ul>
)}
</section>
{consigli.length > 0 && (
<section className="scheda">
<div className="titolo-sezione">
<h2>🌿 Stato delle colture</h2>
</div>
<ul className="lista-pulita" style={{ marginTop: 12 }}>
{consigli.map((c) => (
<li key={c.piantagioneId}>
<div className="riga spazia" style={{ marginBottom: 5 }}>
<span className="sottile" style={{ color: "var(--fg)" }}>
{c.coltura.emoji} {c.coltura.nome} <span style={{ color: "var(--muted-fg)" }}>· {c.aiuola.nome}</span>
</span>
<span className="sottile grassetto" style={{ color: COLORI_STATO[c.stato] }}>
{c.stato === "riposo" ? "in riposo" : `riserva ${Math.max(Math.round(100 - c.consumo), 0)}%`}
</span>
</div>
<Barra percentuale={Math.max(100 - c.consumo, 0)} colore={COLORI_STATO[c.stato]} />
</li>
))}
</ul>
<p className="sottile" style={{ marginTop: 12 }}>
La barra è l'acqua ancora disponibile nel terreno esplorato dalle radici. Sotto lo zero la pianta comincia a
soffrire: chiude gli stomi, smette di crescere e — se ha frutti — li fa cadere.
</p>
</section>
)}
<section className="scheda">
<div className="titolo-sezione">
<h2>📋 Da fare a {MESI[mese - 1]}</h2>
{attivita.length > 0 && <span className="conta">{attivita.length}</span>}
</div>
{attivita.length === 0 ? (
<p className="sommario" style={{ marginTop: 8 }}>Nessuna operazione in agenda questo mese.</p>
) : (
<ul className="lista-pulita" style={{ marginTop: 12 }}>
{attivita.slice(0, 7).map((a) => (
<li key={a.id} className="riga" style={{ alignItems: "flex-start", gap: 11 }}>
<span style={{ fontSize: "1.2rem" }}>{a.emoji}</span>
<div>
<div className="grassetto" style={{ fontSize: ".92rem" }}>{a.titolo}</div>
<p className="sottile">{a.dettaglio}</p>
</div>
</li>
))}
</ul>
)}
{attivita.length > 7 && (
<button className="pulsante secondario piccolo" style={{ marginTop: 12 }} onClick={() => vaiA("guida")}>
Vedi tutte le colture del mese
</button>
)}
</section>
{c && (
<Modale titolo={`${c.coltura.nome} — perché irrigare`} onChiudi={() => setDettaglio(null)}>
<p className="sottile">{c.motivazione}</p>
<table className="tabellina">
<tbody>
<tr><td>Serbatoio del terreno</td><td>{c.taw.toFixed(0)} mm su {c.coltura.profonditaRadici.toFixed(1)} m di radici</td></tr>
<tr><td>Riserva utile</td><td>{c.raw.toFixed(0)} mm prima dello stress</td></tr>
<tr><td>Soglia di intervento</td><td>{c.soglia.toFixed(0)} mm consumati, per il metodo {c.aiuola.metodo}</td></tr>
<tr><td>Acqua mancante ora</td><td>{c.deficit.toFixed(0)} mm</td></tr>
<tr><td>Consumo di oggi</td><td>{c.etcOggi.toFixed(1)} mm ({c.litriGiornoPerPianta.toFixed(1)} litri per pianta)</td></tr>
<tr><td>Coefficiente colturale</td><td>{c.kc.toFixed(2)} — fase di {c.fase}</td></tr>
<tr><td>Superficie occupata</td><td>{c.areaBagnata.toFixed(1)} m² dei {c.aiuola.superficie} m² dell'aiuola</td></tr>
<tr><td>Metodo</td><td>{c.aiuola.metodo}, efficienza {Math.round((stato.impostazioni.efficienza[c.aiuola.metodo] ?? 0.8) * 100)}%</td></tr>
<tr><td>Fase critica</td><td>{c.coltura.fasiCritiche}</td></tr>
</tbody>
</table>
<p className="sottile">
La dose di {Math.round(c.doseLitri)} litri è già corretta per l'efficienza del tuo metodo: parte dell'acqua
erogata non arriva mai alle radici, e il conto ne tiene conto.
</p>
{c.stimaProvvisoria && (
<p className="sottile" style={{ color: "var(--warn)" }}>
Non hai ancora registrato nessuna irrigazione per questa coltura: il bilancio parte quindi da tre
settimane fa presumendo che nel frattempo abbia bevuto solo pioggia. Se l'hai già bagnata, registrala —
il calcolo si riallinea subito.
</p>
)}
</Modale>
)}
</>
)
}
function saluto(): string {
const h = new Date().getHours()
if (h < 12) return "Buongiorno"
if (h < 18) return "Buon pomeriggio"
return "Buonasera"
}
+156
View File
@@ -0,0 +1,156 @@
import { useMemo } from "react"
import { useApp } from "../state/store"
import { useConsigli } from "../state/consigli"
import { giornoBreve, programmaSettore, type ProgrammaSettore } from "../lib/programma"
import { formattaDurata } from "../lib/irrigazione"
import { Vuoto } from "../components/comuni"
import Centralina from "../components/Centralina"
export default function Programma() {
const { stato, meteo } = useApp()
const consigli = useConsigli()
const programmi = useMemo(() => {
if (!meteo) return []
return stato.aiuole
.map((a) => programmaSettore(a, consigli, meteo.giorni, stato.impostazioni))
.filter((x): x is ProgrammaSettore => x !== null)
}, [stato.aiuole, stato.impostazioni, consigli, meteo])
if (programmi.length === 0) {
return (
<Vuoto
icona="📅"
titolo="Niente da programmare"
testo="Il programma si costruisce sulle colture che hai in campo. Aggiungi almeno un'aiuola con qualcosa dentro e qui troverai giorni, minuti e litri da impostare sulla centralina."
/>
)
}
return (
<>
<div>
<h1>Programma per la centralina</h1>
<p className="sommario">
Rifatti i conti sulle previsioni dei prossimi 10 giorni. Ricontrollali una volta a settimana: quando cambia il
meteo cambiano i turni.
</p>
</div>
<Centralina />
{programmi.map((p) => (
<section key={p.aiuola.id} className="scheda">
<div className="riga spazia">
<div>
<h2>{p.aiuola.settore ?? p.aiuola.nome}</h2>
<p className="sottile">
{p.aiuola.settore ? `${p.aiuola.nome} · ` : ""}
{p.aiuola.superficie} m² · tarato su {p.guida.coltura.nome.toLowerCase()} {p.guida.coltura.emoji}
</p>
</div>
</div>
{p.turni.length === 0 ? (
<div className="avviso info" style={{ marginTop: 14 }}>
<span className="em">✓</span>
<div>
<div className="grassetto">Nessun turno nei prossimi 10 giorni</div>
<p className="sottile">
{p.pioggiaPrevista >= 10
? `Fra la riserva già nel terreno e i ${p.pioggiaPrevista.toFixed(0)} mm di pioggia previsti, questo settore non ha bisogno di niente. Tieni il programma spento.`
: "Il terreno ha riserva sufficiente per tutto il periodo. Tieni il programma spento e ricontrolla fra qualche giorno."}
</p>
</div>
</div>
) : (
<>
<div className="scheda stretta" style={{ marginTop: 14, boxShadow: "none", background: "var(--verde-50)", borderColor: "transparent" }}>
<div className="nome" style={{ fontSize: ".7rem", color: "var(--muted-fg)", fontWeight: 700, letterSpacing: ".05em" }}>
DA IMPOSTARE SULLA CENTRALINA
</div>
<div style={{ fontSize: "1.05rem", fontWeight: 650, marginTop: 5, lineHeight: 1.4 }}>
{p.minutiTipici
? `${formattaDurata(p.minutiTipici)} di irrigazione`
: `${Math.round(p.turni[0].litri)} litri`}
{p.intervallo ? (p.intervallo === 1 ? ", ogni giorno" : `, ogni ${p.intervallo} giorni`) : ", un turno solo"}
</div>
<p className="sottile" style={{ marginTop: 6 }}>
Dose di {p.mmTipici} mm a turno. {p.orario}
</p>
</div>
<h3 style={{ marginTop: 18, marginBottom: 8, fontSize: ".85rem", color: "var(--muted-fg)" }}>
GIORNI ESATTI, SE VUOI SEGUIRLI
</h3>
<table className="tabellina">
<tbody>
{p.turni.map((t) => (
<tr key={t.data}>
<td style={{ textTransform: "capitalize", color: "var(--fg)", fontWeight: 600 }}>{giornoBreve(t.data)}</td>
<td>
{t.minuti !== null && <span className="grassetto">{formattaDurata(t.minuti)} · </span>}
{t.litri} litri ({t.mm} mm)
{t.pioggiaQuelGiorno >= 1 && (
<span style={{ color: "var(--acqua)" }}> · {t.pioggiaQuelGiorno.toFixed(0)} mm di pioggia già scontati</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="sottile" style={{ marginTop: 12 }}>
L'intervallo fisso qui sopra è la media: comodo da impostare una volta e lasciar andare. I giorni esatti
sono più precisi perché tengono conto della pioggia in arrivo — se la centralina ha un programma
stagionale in percentuale, la scorciatoia è impostare l'intervallo fisso e correggere la percentuale
quando cambia il tempo.
</p>
</>
)}
{p.avvisi.map((a, i) => (
<div key={i} className="avviso attenzione" style={{ marginTop: 12 }}>
<span className="em">⚠️</span>
<p className="sottile">{a}</p>
</div>
))}
</section>
))}
<section className="scheda">
<h2>Come impostarlo bene</h2>
<ul className="lista-punti sottile" style={{ marginTop: 10 }}>
<li>
<span className="grassetto">Un settore per gruppo di piante simili.</span> Insalata e olivo sulla stessa
valvola non possono funzionare: una vuole acqua ogni due giorni, l'altro ogni tre settimane. Se il tuo
impianto li mescola, l'app te lo segnala qui sopra.
</li>
<li>
<span className="grassetto">Turni lunghi e radi, non brevi e frequenti.</span> Se la dose calcolata richiede
più di un'ora e il terreno è argilloso, spezzala in due partenze a distanza di un'ora: l'acqua ha il tempo
di penetrare invece di scorrere via in superficie.
</li>
<li>
<span className="grassetto">Parti prima dell'alba.</span> Le centraline le programmano quasi tutte alle 6:
l'evaporazione è minima e le foglie asciugano col sole.
</li>
<li>
<span className="grassetto">Il sensore pioggia non basta.</span> Sa solo se sta piovendo adesso, non quanta
acqua c'è nel terreno né quanta ne è evaporata la settimana scorsa. Tienilo come rete di sicurezza, ma è
questo calcolo a decidere.
</li>
<li>
<span className="grassetto">Verifica una volta col secchio.</span> La portata dichiarata dai produttori è a
pressione nominale; la tua è quasi sempre diversa. Dieci minuti di misura valgono tutta la stagione.
</li>
<li>
<span className="grassetto">Ricontrolla ogni domenica.</span> Fra una settimana calda e una piovosa il
fabbisogno cambia del triplo: un programma impostato a maggio e lasciato fino a settembre spreca molta acqua
in primavera e ne dà troppo poca a luglio.
</li>
</ul>
</section>
</>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { useMemo } from "react"
import { useApp } from "./store"
import { coltura } from "../lib/colture"
import { calcolaConsiglio, type Consiglio } from "../lib/irrigazione"
/** Calcola il bilancio idrico di ogni piantagione attiva. */
export function useConsigli(): Consiglio[] {
const { stato, meteo } = useApp()
return useMemo(() => {
if (!meteo) return []
const out: Consiglio[] = []
for (const p of stato.piantagioni) {
if (p.archiviata) continue
const c = coltura(p.colturaId)
const a = stato.aiuole.find((x) => x.id === p.aiuolaId)
if (!c || !a) continue
out.push(calcolaConsiglio(p, c, a, stato.eventi, meteo.giorni, stato.impostazioni))
}
return out.sort((x, y) => y.consumo - x.consumo)
}, [stato.piantagioni, stato.aiuole, stato.eventi, stato.impostazioni, meteo])
}
+271
View File
@@ -0,0 +1,271 @@
import { createContext, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react"
import type { Aiuola, Evento, Impostazioni, Piantagione, Stato } from "../lib/types"
import { EFFICIENZA_PREDEFINITA } from "../lib/irrigazione"
import { caricaMeteo, meteoInCache, type DatiMeteo } from "../lib/meteo"
import { inviaStato, leggiStatoRemoto } from "../lib/agente"
const CHIAVE = "pollice-verde:stato"
const VERSIONE = 1
const statoIniziale: Stato = {
versione: VERSIONE,
impostazioni: {
luogo: null,
risparmioPacciamatura: 0.25,
efficienza: { ...EFFICIENZA_PREDEFINITA },
temaScuro: null,
centralina: {
urlAgente: "",
// Si parte sempre in simulazione: l'automazione va accesa a mano, dopo
// aver verificato che ogni canale apra la valvola giusta.
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 type Azione =
| { tipo: "carica"; stato: Stato }
| { tipo: "impostazioni"; patch: Partial<Impostazioni> }
| { tipo: "aiuola/aggiungi"; aiuola: Aiuola }
| { tipo: "aiuola/modifica"; aiuola: Aiuola }
| { tipo: "aiuola/elimina"; id: string }
| { tipo: "piantagione/aggiungi"; piantagione: Piantagione }
| { tipo: "piantagione/modifica"; piantagione: Piantagione }
| { tipo: "piantagione/elimina"; id: string }
| { tipo: "evento/aggiungi"; evento: Evento }
| { tipo: "evento/elimina"; id: string }
| { tipo: "reset" }
function riduttore(stato: Stato, azione: Azione): Stato {
switch (azione.tipo) {
case "carica":
return azione.stato
case "impostazioni":
return { ...stato, impostazioni: { ...stato.impostazioni, ...azione.patch } }
case "aiuola/aggiungi":
return { ...stato, aiuole: [...stato.aiuole, azione.aiuola] }
case "aiuola/modifica":
return { ...stato, aiuole: stato.aiuole.map((a) => (a.id === azione.aiuola.id ? azione.aiuola : a)) }
case "aiuola/elimina":
return {
...stato,
aiuole: stato.aiuole.filter((a) => a.id !== azione.id),
piantagioni: stato.piantagioni.filter((p) => p.aiuolaId !== azione.id),
eventi: stato.eventi.filter((e) => e.aiuolaId !== azione.id),
}
case "piantagione/aggiungi":
return { ...stato, piantagioni: [...stato.piantagioni, azione.piantagione] }
case "piantagione/modifica":
return {
...stato,
piantagioni: stato.piantagioni.map((p) => (p.id === azione.piantagione.id ? azione.piantagione : p)),
}
case "piantagione/elimina":
return {
...stato,
piantagioni: stato.piantagioni.filter((p) => p.id !== azione.id),
eventi: stato.eventi.filter((e) => e.piantagioneId !== azione.id),
}
case "evento/aggiungi":
return { ...stato, eventi: [azione.evento, ...stato.eventi] }
case "evento/elimina":
return { ...stato, eventi: stato.eventi.filter((e) => e.id !== azione.id) }
case "reset":
return { ...statoIniziale, impostazioni: stato.impostazioni }
}
}
function leggi(): Stato {
try {
const raw = localStorage.getItem(CHIAVE)
if (!raw) return statoIniziale
const s = JSON.parse(raw) as Stato
return {
...statoIniziale,
...s,
impostazioni: {
...statoIniziale.impostazioni,
...s.impostazioni,
centralina: {
...statoIniziale.impostazioni.centralina,
...s.impostazioni?.centralina,
sicurezze: {
...statoIniziale.impostazioni.centralina.sicurezze,
...s.impostazioni?.centralina?.sicurezze,
},
},
},
}
} catch {
return statoIniziale
}
}
export type StatoSync = "assente" | "collegato" | "in-corso" | "errore"
interface Contesto {
stato: Stato
dispatch: (a: Azione) => void
meteo: DatiMeteo | null
caricamentoMeteo: boolean
erroreMeteo: string | null
aggiornaMeteo: () => void
sync: StatoSync
erroreSync: string | null
sincronizza: () => void
}
const Ctx = createContext<Contesto | null>(null)
export function Provider({ children }: { children: ReactNode }) {
const [stato, dispatch] = useReducer(riduttore, undefined, leggi)
const [meteo, setMeteo] = useState<DatiMeteo | null>(() => meteoInCache(leggi().impostazioni.luogo))
const [caricamentoMeteo, setCaricamento] = useState(false)
const [erroreMeteo, setErrore] = useState<string | null>(null)
const [tick, setTick] = useState(0)
const [sync, setSync] = useState<StatoSync>("assente")
const [erroreSync, setErroreSync] = useState<string | null>(null)
const [tickSync, setTickSync] = useState(0)
const primaLettura = useRef(true)
useEffect(() => {
try {
localStorage.setItem(CHIAVE, JSON.stringify(stato))
} catch {
/* spazio esaurito: l'app continua a funzionare in memoria */
}
}, [stato])
const urlAgente = stato.impostazioni.centralina.urlAgente
// All'avvio (e su richiesta) si prende lo stato dall'agente: è lui ad avere
// gli eventi delle irrigazioni automatiche che il telefono non ha mai visto.
useEffect(() => {
if (!urlAgente) {
setSync("assente")
return
}
let annullato = false
setSync("in-corso")
leggiStatoRemoto(urlAgente)
.then((remoto) => {
if (annullato) return
// Se l'agente è appena stato installato ed è vuoto, vince quello locale.
if (remoto.aiuole.length > 0 || remoto.eventi.length > 0) {
dispatch({ tipo: "carica", stato: { ...remoto, impostazioni: { ...remoto.impostazioni, centralina: stato.impostazioni.centralina } } })
}
primaLettura.current = false
setSync("collegato")
setErroreSync(null)
})
.catch((e: Error) => {
if (annullato) return
primaLettura.current = false
setSync("errore")
setErroreSync(e.message)
})
return () => {
annullato = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlAgente, tickSync])
// Ogni modifica locale viene rimandata all'agente, con un piccolo ritardo per
// non inondarlo mentre si compila un form.
useEffect(() => {
if (!urlAgente || primaLettura.current) return
const timer = setTimeout(() => {
inviaStato(urlAgente, stato)
.then(() => {
setSync("collegato")
setErroreSync(null)
})
.catch((e: Error) => {
setSync("errore")
setErroreSync(e.message)
})
}, 1200)
return () => clearTimeout(timer)
}, [stato, urlAgente])
const luogo = stato.impostazioni.luogo
useEffect(() => {
if (!luogo) return
let annullato = false
setCaricamento(true)
setErrore(null)
caricaMeteo(luogo, tick > 0)
.then((d) => {
if (!annullato) setMeteo(d)
})
.catch((e: Error) => {
if (!annullato) setErrore(e.message || "Meteo non raggiungibile")
})
.finally(() => {
if (!annullato) setCaricamento(false)
})
return () => {
annullato = true
}
}, [luogo?.lat, luogo?.lon, tick])
const valore = useMemo<Contesto>(
() => ({
stato,
dispatch,
meteo,
caricamentoMeteo,
erroreMeteo,
aggiornaMeteo: () => setTick((t) => t + 1),
sync,
erroreSync,
sincronizza: () => setTickSync((t) => t + 1),
}),
[stato, meteo, caricamentoMeteo, erroreMeteo, sync, erroreSync]
)
return <Ctx.Provider value={valore}>{children}</Ctx.Provider>
}
export function useApp(): Contesto {
const c = useContext(Ctx)
if (!c) throw new Error("useApp fuori dal Provider")
return c
}
export function id(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
}
export function oggi(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Esporta tutto il giardino in un file JSON, per backup o per cambiare telefono. */
export function esporta(stato: Stato): void {
const blob = new Blob([JSON.stringify(stato, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `pollice-verde-${oggi()}.json`
a.click()
URL.revokeObjectURL(url)
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { host: true, port: 5173 },
build: { target: 'es2020' },
})