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
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user