Pollice Verde: gestione dell'orto e del frutteto con irrigazione calcolata
App PWA che dice cosa fare nell'orto e, soprattutto, quanta acqua dare a ogni coltura, calcolandolo sul meteo reale invece che su un calendario fisso. Motore idrico (metodo FAO-56) - il terreno come serbatoio: capacità dal tipo di suolo per la profondità radicale, prelievo giornaliero pari a ET0 × Kc, ricarica da pioggia efficace e irrigazioni al netto dell'efficienza del metodo - bilancio ricostruito su 21 giorni reali e proiettato su 10 di previsione, così si sa anche fra quanti giorni servirà il prossimo turno e quando saltarlo - soglia di intervento dipendente dal metodo: con la goccia si bagna al 55% della riserva utile, con l'aspersione all'85%, a mano si sfrutta tutto - correzioni per pacciamatura, serra e coltura in vaso Dati - 58 schede fra ortaggi, frutta e aromatiche con coefficienti colturali, profondità radicali e fattori di deplezione da FAO-56, più calendario, sesti, consociazioni, cure e problemi tipici - ricerca che capisce i nomi dialettali: cocomero, popone, petonciano, ramerino - meteo ed evapotraspirazione da Open-Meteo, senza chiave API, con cache offline Automazione - generatore di programma per centraline a settori: ritmo di regime da impostare e giorni esatti con le piogge già scontate - agente Node che custodisce lo stato, esegue i turni un settore alla volta e registra nel diario quello che ha davvero erogato - driver per ESP32, Shelly Gen1/Gen2, Tasmota, più modalità simulata predefinita - sonde Watermark: veto sui turni, riancoraggio del bilancio su misura reale, confronto fra sonda e calcolo - sicurezze: tetti su minuti e litri, blocchi gelo/vento/pioggia, chiusura all'avvio e all'uscita, doppia chiusura a tempo lato dispositivo, un settore per volta, sospensione dei settori con sonda muta o guasta Firmware ESP32 incluso, con schema di collegamento e istruzioni di posa. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFnqupGt8T5HNNhPPHCjKs
This commit is contained in:
commit
b412d51658
46 files changed
+10608
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
tsconfig.tsbuildinfo
|
||||
agente/dist
|
||||
agente/dati/*
|
||||
!agente/dati/.gitkeep
|
||||
@@ -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.
|
||||
Whitespace-only changes.
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2217
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "⛈️" }
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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 */
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'«agente») gira su qualcosa di sempre acceso — un Raspberry Pi, un NAS, un vecchio
|
||||
portatile — e all'orario stabilito apre le valvole per i minuti che il calcolo ha deciso. L'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'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'automazione. Il canale si imposta nella scheda dell'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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
Reference in new issue
Block a user