Pipeline IoT per Agricoltura di Precisione con Python e MQTT
Un campo di grano in Puglia. Tremila ettari, quaranta sensori interrati a diverse profondità, dati di temperatura, umidita del suolo, pH e conducibilita elettrica che arrivano ogni trenta secondi. Senza una pipeline dati strutturata, questo e solo rumore digitale. Con la pipeline giusta, diventa il motore di decisioni che riducono il consumo idrico del 40%, aumentano la resa del 15% e abbattono i costi dei fertilizzanti di un quarto.
L'agricoltura di precisione non e una promessa futura: e una realta industriale che nel 2025 vale 14.77 miliardi di dollari a livello globale, con proiezioni che portano il mercato a 26.86 miliardi entro il 2030 a un CAGR del 12.7%. In Italia, il 28.5% delle aziende agricole utilizza già tecniche di agricoltura di precisione secondo i dati ISTAT 2024, con punte del 41.1% nelle aziende con oltre 100 ettari di SAU. Il settore agricolo italiano ha generato un valore aggiunto di 42.4 miliardi di euro nel 2024, confermando l'Italia prima in Europa, e la digitalizzazione e il motore principale di questa performance.
Ma tra il sensore nel campo e la decisione agronomica corretta, c'è un percorso tecnico complesso: protocolli wireless a bassa potenza, broker MQTT, validazione dello schema, pipeline di arricchimento, architettura medallion su data lake, dashboard in tempo reale e sistema di alerting. Questo articolo copre ogni passo di questa catena con codice Python funzionante, architetture reali e best practice validate in produzione.
Cosa Imparerai in Questo Articolo
- Architettura end-to-end di un sistema IoT per agricoltura di precisione
- Tipologie di sensori agricoli e protocolli wireless: MQTT, LoRaWAN, Zigbee a confronto
- MQTT deep dive: QoS 0/1/2, retained messages, last will, topic design
- Implementazione Python completa con paho-mqtt: publisher sensore e consumer pipeline
- Validazione e schema enforcement dei dati IoT con Pydantic
- Integrazione con InfluxDB per time-series e Apache Kafka per stream processing
- Architettura Medallion (Bronze/Silver/Gold) per dati agricoli
- Dashboard Grafana e sistema di alerting soglie critiche
- Contesto italiano: PAC, PNRR Transizione 5.0, incentivi AgriTech 2025
Serie FoodTech - Tutti gli Articoli
| # | Articolo | Livello | Stato |
|---|---|---|---|
| 1 | Pipeline IoT per Agricoltura di Precisione (sei qui) | Advanced | Corrente |
| 2 | ML Edge per Monitoraggio Colture: Computer Vision sui Campi | Advanced | In arrivo |
| 3 | Satellite API e Indici Vegetativi: NDVI con Python e Sentinel-2 | Intermediate | In arrivo |
| 4 | Tracciabilita Blockchain nel Food: dal Campo al Supermercato | Intermediate | In arrivo |
| 5 | Computer Vision per Quality Control nell'Industria Alimentare | Advanced | In arrivo |
| 6 | FSMA e Compliance Digitale: Automazione dei Processi Normativi | Intermediate | In arrivo |
| 7 | Vertical Farming: Controllo Ambientale con IoT e ML | Advanced | In arrivo |
| 8 | Demand Forecasting per il Food Retail con Prophet e LightGBM | Intermediate | In arrivo |
| 9 | Dashboard Farm Intelligence: Real-Time Analytics con Grafana | Intermediate | In arrivo |
| 10 | Supply Chain Food Optimization: ML per Riduzione degli Sprechi | Intermediate | In arrivo |
Il Mercato AgriTech nel 2025: Numeri e Tendenze
L'agricoltura di precisione e passata in pochi anni da tecnologia di nicchia a driver strategico per la competitivita del settore primario. I numeri lo confermano con chiarezza: il mercato globale del precision farming vale 14.77 miliardi di dollari nel 2025 e crescera fino a 26.86 miliardi entro il 2030. Ma il quadro complessivo dell'AgriTech e ancora più ampio: il segmento allargato, che include software gestionali, droni agricoli, robotica e bio-input digitali, supera i 30 miliardi nel 2025 con un CAGR previsto del 16-23% fino al 2031 secondo le diverse fonti di ricerca.
In Italia, il 2024 ha segnato un punto di svolta: secondo ISTAT, l'agricoltura italiana ha raggiunto il primo posto in Europa per valore aggiunto con 42.4 miliardi di euro (+9% rispetto al 2023). La produzione agricola e cresciuta in volume del 1.4% e il valore aggiunto del 3.5%. Parallelamente, il 28.5% delle aziende agricole italiane utilizza già tecniche di precisione, con concentrazione maggiore al Nord-est (33%) e Nord-ovest (32.1%) e nei grandi operatori (41.1% nelle aziende con SAU superiore a 100 ettari).
Tecnologie Abilitanti dell'Agricoltura di Precisione nel 2025
| Tecnologia | Applicazione Principale | Adozione Italia | ROI Medio |
|---|---|---|---|
| Sensori suolo IoT | Irrigazione variabile, fertilizzazione | Alta (Nord Italia) | 15-25% riduzione costi input |
| Droni agricoli | Mappatura, trattamenti, analisi fogliare | Media | 30-40% risparmio fitofarmaci |
| Immagini satellitari | NDVI, stress idrico, rese predette | Media-alta | 5-10% ottimizzazione rese |
| Stazioni meteo IoT | Modelli previsione malattie, irrigazione | Alta | 10-20% riduzione trattamenti |
| Variable Rate Technology | Semina, fertilizzazione variabile | Bassa-media | 8-15% risparmio input |
| Machine learning su dati field | Previsione rese, ottimizzazione agronomica | Bassa | +10-20% resa, -15% input |
Il PNRR ha giocato un ruolo determinante nell'accelerazione: la Missione 2 "Rivoluzione Verde e Transizione Ecologica" ha stanziato 400 milioni di euro per l'ammodernamento del parco macchine agricole verso tecnologie 4.0. Il Piano Transizione 5.0, con una dotazione complessiva di 12.7 miliardi di euro nel biennio 2024-2025 (6.3 miliardi specificamente per Transizione 5.0), e applicabile anche all'agricoltura: la Legge di Bilancio 2025 (L. 207/2024) ha ampliato l'ambito applicativo, rendendo le imprese agricole ammissibili ai crediti d'imposta per investimenti in tecnologie digitali ed energeticamente efficienti.
Architettura IoT per Agricoltura: Dal Sensore al Data Lake
Prima di scrivere una sola riga di codice, e fondamentale comprendere l'architettura di sistema completa. Un errore di progettazione all'inizio porta a riscritture costose quando si scala da 10 sensori a 10.000. L'architettura che descriviamo qui e quella adottata dai principali operatori del settore e validata in ambienti produttivi reali.
Architettura End-to-End: Livelli e Componenti
┌─────────────────────────────────────────────────────────────────────┐
│ FIELD LAYER (Campo) │
│ [Sensore Suolo] [Stazione Meteo] [Sensore pH] [Drone Mapping] │
│ │ │ │ │ │
│ └────────────────┴────────────────┘ │ │
│ │ LoRaWAN / Zigbee / RS-485 │
└────────────────────────┼────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────────────────┐
│ GATEWAY LAYER (Edge) │
│ [Gateway LoRaWAN / Raspberry Pi 4] │
│ - Aggregazione dati multi-sensore │
│ - Pre-elaborazione e filtro outlier │
│ - Buffer locale (offline tolerance) │
│ - Protocollo: MQTT publish su broker locale │
└────────────────────────┼────────────────────────────────────────────┘
│ MQTT / TLS
┌────────────────────────┼────────────────────────────────────────────┐
│ BROKER LAYER (Fog/Cloud) │
│ [EMQX / HiveMQ / Eclipse Mosquitto] │
│ - Topic management gerarchico │
│ - Autenticazione mTLS / JWT │
│ - QoS management e message persistence │
│ - Bridge verso cloud (AWS IoT / Azure IoT Hub) │
└────────────────────────┼────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────────────────┐
│ PROCESSING LAYER (Cloud) │
│ [Apache Kafka] ──► [Stream Processor] ──► [InfluxDB] │
│ - Ingestion stream - Validazione schema - Time-series store │
│ - Partitioning - Enrichment - Retention policy │
│ - Consumer groups - Alerting real-time - Downsampling │
└────────────────────────┼────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────────────────┐
│ DATA LAKE (Medallion Architecture) │
│ [Bronze: Raw S3] ──► [Silver: Cleaned] ──► [Gold: Analytics] │
│ - Dati grezzi MQTT - Schema validato - Aggregazioni │
│ - Immutabile - Outlier rimossi - ML features │
│ - Formato: Parquet - Formato: Delta/Iceberg - Formato: Parquet │
└────────────────────────┼────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────────────────┐
│ SERVING LAYER (Applicazioni) │
│ [Grafana Dashboard] [Alert Manager] [ML Models] [Mobile App] │
│ - Real-time monitoring - SMS/Email/Push - Previsioni rese │
│ - Mappa campo - Soglie critiche - Ottimizzazione input │
└─────────────────────────────────────────────────────────────────────┘
Ogni livello ha responsabilità distinte e disaccoppiate. Questa separazione e critica: permette di sostituire un componente (ad esempio passare da Mosquitto a EMQX quando si scala) senza toccare gli altri. E permette di implementare offline tolerance: se la connessione al cloud cade, il gateway continua a raccogliere e bufferizzare dati localmente.
Sensori Agricoli: Tipologie, Protocolli e Deployment
Principali Categorie di Sensori per il Campo
La scelta dei sensori giusti dipende dalla coltura, dalle condizioni pedologiche e dagli obiettivi agronomici. Ecco le categorie principali con caratteristiche tecniche e costi indicativi per il 2025:
Sensori per Agricoltura di Precisione
| Categoria | Parametro | Tecnologia | Costo Unitario | Protocollo |
|---|---|---|---|---|
| Umidita suolo (VWC) | Contenuto volumetrico d'acqua | FDR, TDR, Capacitivo | 30-150 EUR | SDI-12, RS-485, LoRa |
| Temperatura suolo | T suolo a 10/30/50cm | PT100, NTC | 20-80 EUR | SDI-12, I2C, 1-Wire |
| pH suolo | Acidita in situ | Elettrodo ISE | 80-300 EUR | RS-485, Modbus |
| Conducibilita elettrica (EC) | Salinita, fertilita | Induttivo, a contatto | 60-200 EUR | SDI-12, RS-485 |
| Stazione meteo | T, HR, vento, pioggia, radiazione | Multi-sensore integrato | 200-800 EUR | RS-485, WiFi, LoRa |
| Sensore fogliare | Umidita foglia, temperatura | Capacitivo, IR | 40-120 EUR | SDI-12, I2C |
| Flow meter irrigazione | Portata acqua | Ultrasonico, ad elica | 80-350 EUR | Pulse, RS-485 |
| Sensore NDVI portatile | Indice vegetativo | Multispettrale | 300-1500 EUR | Bluetooth, WiFi |
Protocolli di Comunicazione Wireless: Il Confronto Definitivo
La scelta del protocollo wireless e forse la decisione più critica nell'architettura di un sistema IoT agricolo. I campi hanno caratteristiche estreme: distanze fino a 10km, ostacoli fisici (filari, alberi, edifici rurali), assenza di alimentazione di rete, temperature da -20 a +60 gradi centigradi.
Confronto Protocolli Wireless per IoT Agricolo
| Protocollo | Range | Banda | Batteria | Costo Infra | Caso d'uso |
|---|---|---|---|---|---|
| LoRaWAN | 3-15 km | 0.3-50 kbps | 5-10 anni | Medio (gateway) | Sensori suolo, meteo campi remoti |
| NB-IoT | 10+ km | 20-250 kbps | 3-8 anni | Basso (SIM card) | Aree con copertura 4G/5G |
| Zigbee | 10-100 m | 250 kbps | 1-3 anni | Basso (mesh) | Serre, impianti irrigazione automatica |
| WiFi 6 | 100-200 m | Alta (Gbps) | Ore/giorni | Medio (AP) | Sistemi camera, video analisi qualità |
| 4G/LTE | Illimitato | Alta | 1-5 anni | Medio (SIM) | Macchine agricole, gateway mobile |
| RS-485 (cablato) | 1200 m | 10 Mbps | N/A | Basso (cavi) | Serre controllate, impianti fissi |
Per un'azienda agricola tipo italiana (50-500 ettari in campo aperto), la soluzione più comune nel 2025 e una architettura ibrida: LoRaWAN per i sensori suolo nei campi remoti, NB-IoT per le macchine agricole in movimento, e WiFi/cablato per le serre dove la precisione e la frequenza di campionamento sono massime. Il gateway centrale (spesso un Raspberry Pi 4 o un gateway industriale Dragino) aggrega tutto e pubblica via MQTT verso il cloud.
MQTT Deep Dive: Architettura, QoS e Best Practice
MQTT (Message Queuing Telemetry Transport) e il protocollo di facto per l'IoT. Creato da IBM negli anni '90 per monitorare oleodotti via satellite, e diventato lo standard ISO/IEC 20922 e il cuore di qualsiasi sistema IoT serio. La sua semplicità e potenza lo rendono ideale per ambienti con banda limitata e dispositivi a basso consumo energetico.
Il Modello Publish/Subscribe
A differenza del modello request/response tipico di HTTP, MQTT usa il paradigma publish/subscribe: i produttori di dati (publisher) non sanno chi li legge, e i consumatori (subscriber) non sanno chi pubblica. Il disaccoppiamento e totale e mediato da un componente centrale chiamato broker.
I dati sono organizzati in topic, stringhe gerarchiche separate da slash che descrivono la natura del dato. Un topic ben progettato e la chiave di una architettura scalabile. Per un'azienda agricola con più appezzamenti, si consiglia la struttura seguente:
# Struttura topic MQTT consigliata per agricoltura di precisione
# Pattern: azienda/appezzamento/dispositivo/tipo-sensore/metrica
# Esempi concreti:
farm/campo-nord/sensor-001/soil/moisture # Umidita suolo sensore 001
farm/campo-nord/sensor-001/soil/temperature # Temperatura suolo sensore 001
farm/campo-nord/sensor-001/soil/ph # pH suolo sensore 001
farm/campo-nord/sensor-001/soil/ec # Conducibilita elettrica
farm/campo-sud/weather-station/air/temperature # Temperatura aria stazione meteo
farm/campo-sud/weather-station/air/humidity # Umidita aria
farm/campo-sud/weather-station/wind/speed # Velocita vento
farm/campo-sud/weather-station/rain/mm # Precipitazioni
farm/+/+/soil/moisture # Wildcard: umidita suolo da TUTTI i campi e sensori
farm/campo-nord/# # Wildcard: TUTTI i dati dal campo nord
farm/# # Wildcard: TUTTI i dati dell'azienda
# Topic di sistema (prefisso $)
$SYS/brokers/emqx/connections/count # Statistiche broker
farm/campo-nord/sensor-001/$status # Status device (LWT)
farm/campo-nord/sensor-001/$command # Comandi al dispositivo
Quality of Service (QoS): I Tre Livelli
Il QoS di MQTT definisce la garanzia di consegna dei messaggi tra client e broker. La scelta del livello giusto impatta direttamente su batteria, banda e affidabilità del sistema:
MQTT QoS: Confronto Dettagliato
| Livello | Nome | Garanzia | Overhead | Uso in Agricoltura |
|---|---|---|---|---|
| QoS 0 | At Most Once | Nessuna (fire-and-forget) | Minimo (1 pacchetto) | Telemetria ad alta frequenza (T ogni 5 sec), perdita accettabile |
| QoS 1 | At Least Once | Almeno una consegna (possibili duplicati) | Basso (2 pacchetti, ACK) | Letture sensori umidita/pH, log irrigazione |
| QoS 2 | Exactly Once | Consegna garantita una sola volta | Alto (4 pacchetti) | Comandi valvole irrigazione, allarmi critici, dosaggio fertilizzanti |
Retained Messages e Last Will Testament
Due feature di MQTT particolarmente utili in agricoltura:
- Retained Messages: Il broker memorizza l'ultimo messaggio su un topic e lo consegna immediatamente a ogni nuovo subscriber. Fondamentale per lo stato corrente dei sensori: una dashboard che si connette riceve subito i valori più recenti senza aspettare il prossimo ciclo di pubblicazione.
- Last Will Testament (LWT): Al momento della connessione, ogni dispositivo può configurare un messaggio "testamento" che il broker pubblichera automaticamente se la connessione cade in modo anomalo. Indispensabile per rilevare sensori offline senza polling attivo: se il sensore non si disconnette correttamente (batteria scarica, interferenze), il broker pubblica automaticamente lo stato "offline" sul topic di status.
Implementazione Python Completa: Publisher Sensore
Implementiamo un publisher Python che simula un nodo sensore agricolo realistico con umidita
suolo, temperatura, pH e EC. Il codice usa paho-mqtt 2.x (API moderna con callbacks
aggiornate) e implementa tutte le best practice: LWT, retained messages, QoS appropriato,
riconnessione automatica e schema JSON strutturato.
# sensor_node.py
# Nodo sensore MQTT per agricoltura di precisione
# Dipendenze: pip install paho-mqtt pydantic
import paho.mqtt.client as mqtt
import json
import time
import random
import math
import logging
from datetime import datetime, timezone
from dataclasses import dataclass, asdict
from typing import Optional
import ssl
# ── Configurazione ────────────────────────────────────────────────────────────
BROKER_HOST = "emqx.azienda-agricola.it"
BROKER_PORT = 8883 # TLS
KEEPALIVE = 60 # secondi
CLIENT_ID = "sensor-campo-nord-001"
FARM_ID = "farm-001"
FIELD_ID = "campo-nord"
SENSOR_ID = "sensor-001"
# Topic base
TOPIC_BASE = f"{FARM_ID}/{FIELD_ID}/{SENSOR_ID}"
TOPIC_SOIL = f"{TOPIC_BASE}/soil"
TOPIC_STATUS = f"{TOPIC_BASE}/$status"
TOPIC_COMMAND = f"{TOPIC_BASE}/$command"
# Intervallo di pubblicazione in secondi
PUBLISH_INTERVAL = 30
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger(__name__)
# ── Modello dati sensore ───────────────────────────────────────────────────────
@dataclass
class SoilReading:
"""Lettura completa da un nodo sensore suolo"""
sensor_id: str
farm_id: str
field_id: str
timestamp: str # ISO 8601 UTC
latitude: float
longitude: float
depth_cm: int # Profondità di installazione
# Misurazioni suolo
moisture_pct: float # Umidita volumetrica (VWC) %
temperature_c: float # Temperatura suolo gradi C
ph: float # pH suolo (4.0 - 9.0)
ec_ds_m: float # Conducibilita elettrica dS/m
# Metadata dispositivo
battery_pct: int # Livello batteria
rssi_dbm: int # Signal strength in dBm
firmware_version: str
# Flag qualità
quality_flag: str # "OK", "WARN", "ERROR"
quality_notes: Optional[str] = None
def read_sensors_from_hardware() -> SoilReading:
"""
In produzione: legge i sensori reali via SDI-12 o RS-485.
Qui: simula dati realistici con variazione temporale.
"""
now = datetime.now(timezone.utc)
# Ciclo circadiano per temperatura (più alta nelle ore centrali)
hour = now.hour
temp_base = 18.0
temp_variation = 6.0 * math.sin(math.pi * (hour - 6) / 12) if 6 <= hour <= 18 else -2.0
temperature = temp_base + temp_variation + random.gauss(0, 0.3)
# Umidita: degrada lentamente senza pioggia, segue ciclo stagionale
base_moisture = 35.0 # % VWC campo (35% = campo saturo irrigazione)
moisture = base_moisture + random.gauss(0, 1.5)
moisture = max(5.0, min(60.0, moisture)) # clamp fisico
# pH relativamente stabile
ph = 6.8 + random.gauss(0, 0.1)
ph = max(4.0, min(9.0, ph))
# EC: correlata alla salinita e fertilizzazione
ec = 1.2 + random.gauss(0, 0.05)
ec = max(0.1, min(5.0, ec))
# Battery che decresce lentamente (simulazione)
battery = max(10, 95 - int(time.time() / 3600) % 85)
# Quality flag automatico
quality = "OK"
notes = None
if moisture < 10.0:
quality = "WARN"
notes = "Umidita sotto soglia minima critica"
elif moisture > 55.0:
quality = "WARN"
notes = "Umidita sopra soglia saturazione"
if battery < 15:
quality = "WARN"
notes = (notes or "") + " | Batteria in esaurimento"
return SoilReading(
sensor_id = SENSOR_ID,
farm_id = FARM_ID,
field_id = FIELD_ID,
timestamp = now.isoformat(),
latitude = 40.4164,
longitude = 17.9308,
depth_cm = 30,
moisture_pct = round(moisture, 2),
temperature_c = round(temperature, 2),
ph = round(ph, 2),
ec_ds_m = round(ec, 3),
battery_pct = battery,
rssi_dbm = random.randint(-95, -45),
firmware_version = "2.4.1",
quality_flag = quality,
quality_notes = notes,
)
# ── Client MQTT ───────────────────────────────────────────────────────────────
class AgriSensorNode:
"""Nodo sensore MQTT con auto-reconnect e LWT"""
def __init__(self):
self.client = mqtt.Client(
client_id=CLIENT_ID,
protocol=mqtt.MQTTv5,
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
)
self._setup_auth()
self._setup_tls()
self._setup_callbacks()
self._setup_lwt()
self.connected = False
def _setup_auth(self):
self.client.username_pw_set(
username="sensor-user",
password="<TOKEN_SEGRETO>"
)
def _setup_tls(self):
"""TLS mutuo con certificato dispositivo"""
self.client.tls_set(
ca_certs = "/certs/ca.crt",
certfile = "/certs/sensor.crt",
keyfile = "/certs/sensor.key",
tls_version = ssl.PROTOCOL_TLS_CLIENT,
)
def _setup_callbacks(self):
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.on_publish = self._on_publish
def _setup_lwt(self):
"""Last Will Testament: pubblicato dal broker se la connessione cade"""
lwt_payload = json.dumps({
"sensor_id": SENSOR_ID,
"status": "offline",
"reason": "connection_lost",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
self.client.will_set(
topic = TOPIC_STATUS,
payload = lwt_payload,
qos = 1,
retain = True, # Retain: dashboard vede subito lo stato offline
)
def _on_connect(self, client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
log.error(f"Connessione fallita: {reason_code}")
return
log.info(f"Connesso al broker: {BROKER_HOST}")
self.connected = True
# Pubblica stato online (retained)
online_payload = json.dumps({
"sensor_id": SENSOR_ID,
"status": "online",
"timestamp": datetime.now(timezone.utc).isoformat(),
"firmware": "2.4.1",
})
client.publish(TOPIC_STATUS, online_payload, qos=1, retain=True)
# Sottoscrivi ai comandi
client.subscribe(TOPIC_COMMAND, qos=2)
log.info(f"Sottoscritto a: {TOPIC_COMMAND}")
def _on_disconnect(self, client, userdata, flags, reason_code, properties):
self.connected = False
log.warning(f"Disconnesso: {reason_code}. Tentativo riconnessione...")
def _on_message(self, client, userdata, message):
"""Gestione comandi ricevuti dal broker (es. cambio intervallo)"""
try:
payload = json.loads(message.payload.decode())
cmd = payload.get("command")
log.info(f"Comando ricevuto: {cmd}")
if cmd == "set_interval":
global PUBLISH_INTERVAL
PUBLISH_INTERVAL = int(payload.get("value", 30))
log.info(f"Intervallo aggiornato a {PUBLISH_INTERVAL}s")
elif cmd == "reboot":
log.warning("Comando reboot ricevuto")
# In produzione: riavvia il sistema
except Exception as e:
log.error(f"Errore parsing comando: {e}")
def _on_publish(self, client, userdata, mid, reason_code, properties):
log.debug(f"Messaggio {mid} pubblicato con successo")
def connect(self):
self.client.connect(
host = BROKER_HOST,
port = BROKER_PORT,
keepalive = KEEPALIVE,
)
self.client.loop_start() # Thread background per I/O
def publish_reading(self, reading: SoilReading):
"""Pubblica lettura sensore su topic appropriati"""
# Payload principale: lettura completa
payload_full = json.dumps(asdict(reading), default=str)
result = self.client.publish(
topic = TOPIC_SOIL,
payload = payload_full,
qos = 1,
retain = True, # Ultimo valore sempre disponibile
)
# Pubblica anche metriche singole per dashboard real-time
metrics = {
"moisture": (reading.moisture_pct, 1),
"temperature": (reading.temperature_c, 0),
"ph": (reading.ph, 1),
"ec": (reading.ec_ds_m, 0),
}
for metric, (value, qos) in metrics.items():
self.client.publish(
topic = f"{TOPIC_SOIL}/{metric}",
payload = str(value),
qos = qos,
retain = True,
)
if result.rc == mqtt.MQTT_ERR_SUCCESS:
log.info(
f"Pubblicato | Moisture: {reading.moisture_pct}% | "
f"Temp: {reading.temperature_c}C | pH: {reading.ph} | "
f"EC: {reading.ec_ds_m} dS/m | Quality: {reading.quality_flag}"
)
else:
log.error(f"Errore pubblicazione: {result.rc}")
def run(self):
"""Loop principale del nodo sensore"""
self.connect()
# Attendi connessione iniziale
timeout = 10
while not self.connected and timeout > 0:
time.sleep(1)
timeout -= 1
if not self.connected:
log.error("Impossibile connettersi al broker")
return
log.info(f"Nodo sensore avviato. Intervallo: {PUBLISH_INTERVAL}s")
try:
while True:
reading = read_sensors_from_hardware()
self.publish_reading(reading)
time.sleep(PUBLISH_INTERVAL)
except KeyboardInterrupt:
log.info("Shutdown richiesto")
finally:
# Disconnessione pulita: il LWT NON viene inviato
offline_payload = json.dumps({
"sensor_id": SENSOR_ID,
"status": "offline",
"reason": "graceful_shutdown",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
self.client.publish(TOPIC_STATUS, offline_payload, qos=1, retain=True)
time.sleep(0.5)
self.client.loop_stop()
self.client.disconnect()
if __name__ == "__main__":
node = AgriSensorNode()
node.run()
Consumer Pipeline: Validazione, Enrichment e Storage
Il consumer e l'altro capo del sistema. Sottoscrive ai topic MQTT, valida i dati ricevuti con Pydantic, arricchisce le letture con dati contestuali (informazioni agronomiche, allerta meteo) e le instrada verso InfluxDB per il time-series storage e verso S3 per il data lake.
# pipeline_consumer.py
# Consumer MQTT + validazione Pydantic + storage InfluxDB
# Dipendenze: pip install paho-mqtt pydantic influxdb-client boto3
import paho.mqtt.client as mqtt
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field, field_validator, ValidationError
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS
import boto3
import io
log = logging.getLogger(__name__)
# ── Schema di validazione Pydantic ────────────────────────────────────────────
class SoilReadingSchema(BaseModel):
"""Schema di validazione per letture sensore suolo"""
sensor_id: str
farm_id: str
field_id: str
timestamp: str
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
depth_cm: int = Field(ge=0, le=200)
moisture_pct: float = Field(ge=0.0, le=100.0)
temperature_c: float = Field(ge=-40.0, le=80.0)
ph: float = Field(ge=0.0, le=14.0)
ec_ds_m: float = Field(ge=0.0, le=20.0)
battery_pct: int = Field(ge=0, le=100)
rssi_dbm: int = Field(ge=-150, le=0)
firmware_version: str
quality_flag: str = Field(pattern="^(OK|WARN|ERROR)$")
quality_notes: Optional[str] = None
@field_validator("timestamp")
@classmethod
def validate_timestamp(cls, v: str) -> str:
"""Verifica che il timestamp sia ISO 8601 valido e non nel futuro"""
try:
ts = datetime.fromisoformat(v)
if ts > datetime.now(timezone.utc):
raise ValueError("Timestamp nel futuro")
except ValueError as e:
raise ValueError(f"Timestamp non valido: {e}")
return v
@field_validator("ph")
@classmethod
def validate_ph_agronomico(cls, v: float) -> float:
"""pH fuori range agronomico (4.5-8.5) e anomalia"""
if v < 4.5 or v > 8.5:
log.warning(f"pH {v} fuori range agronomico tipico [4.5-8.5]")
return v
def has_critical_alert(self) -> bool:
"""Verifica se la lettura richiede un alert critico"""
return (
self.moisture_pct < 10.0 or
self.moisture_pct > 58.0 or
self.ph < 4.5 or
self.ph > 8.5 or
self.ec_ds_m > 4.0 or
self.battery_pct < 10
)
# ── Storage InfluxDB ──────────────────────────────────────────────────────────
class InfluxDBWriter:
"""Writer per time-series su InfluxDB 2.x"""
def __init__(self, url: str, token: str, org: str, bucket: str):
self.client = InfluxDBClient(url=url, token=token, org=org)
self.write_api = self.client.write_api(write_options=SYNCHRONOUS)
self.bucket = bucket
self.org = org
def write_soil_reading(self, reading: SoilReadingSchema):
"""Scrive una lettura suolo su InfluxDB con tags e fields ottimizzati"""
point = (
Point("soil_reading")
# Tags: usati per filtro e group-by (cardinalita limitata)
.tag("sensor_id", reading.sensor_id)
.tag("farm_id", reading.farm_id)
.tag("field_id", reading.field_id)
.tag("depth_cm", str(reading.depth_cm))
.tag("quality", reading.quality_flag)
# Fields: metriche numeriche
.field("moisture_pct", reading.moisture_pct)
.field("temperature_c", reading.temperature_c)
.field("ph", reading.ph)
.field("ec_ds_m", reading.ec_ds_m)
.field("battery_pct", float(reading.battery_pct))
.field("rssi_dbm", float(reading.rssi_dbm))
# Timestamp dalla lettura del sensore (non dall'arrivo)
.time(datetime.fromisoformat(reading.timestamp), WritePrecision.SECONDS)
)
self.write_api.write(bucket=self.bucket, org=self.org, record=point)
log.debug(f"Scritto su InfluxDB: {reading.sensor_id} @ {reading.timestamp}")
def close(self):
self.client.close()
# ── Bronze Layer su S3 ────────────────────────────────────────────────────────
class S3BronzeWriter:
"""Scrive dati grezzi su S3 (Bronze layer Medallion Architecture)"""
def __init__(self, bucket: str, region: str = "eu-south-1"):
self.s3 = boto3.client("s3", region_name=region)
self.bucket = bucket
def write_raw(self, raw_payload: str, topic: str, received_at: datetime):
"""
Salva il payload grezzo in formato NDJSON su S3.
Partitionamento per data: year/month/day/hour/
"""
prefix = received_at.strftime("year=%Y/month=%m/day=%d/hour=%H")
filename = f"{prefix}/{received_at.isoformat()}.json"
envelope = {
"topic": topic,
"received_at": received_at.isoformat(),
"payload": json.loads(raw_payload),
}
self.s3.put_object(
Bucket = self.bucket,
Key = filename,
Body = json.dumps(envelope).encode("utf-8"),
ContentType = "application/json",
)
log.debug(f"Bronze layer: scritto {filename}")
# ── Alert Manager ─────────────────────────────────────────────────────────────
class AlertManager:
"""Gestione alert critici con cooldown per evitare spam"""
def __init__(self):
self._last_alert: dict[str, datetime] = {}
self.cooldown_seconds = 300 # 5 minuti tra un alert e l'altro per sensor
def check_and_alert(self, reading: SoilReadingSchema):
if not reading.has_critical_alert():
return
sensor_key = reading.sensor_id
now = datetime.now(timezone.utc)
last = self._last_alert.get(sensor_key)
if last and (now - last).total_seconds() < self.cooldown_seconds:
return # In cooldown, skip
self._last_alert[sensor_key] = now
self._send_alert(reading)
def _send_alert(self, reading: SoilReadingSchema):
"""In produzione: invia SMS/email/push. Qui: log."""
alerts = []
if reading.moisture_pct < 10.0:
alerts.append(f"STRESS IDRICO: umidita {reading.moisture_pct}% sotto soglia critica 10%")
if reading.moisture_pct > 58.0:
alerts.append(f"SATURAZIONE: umidita {reading.moisture_pct}% sopra saturazione")
if reading.ph < 4.5:
alerts.append(f"pH CRITICO: {reading.ph} - suolo troppo acido")
if reading.ec_ds_m > 4.0:
alerts.append(f"SALINITA CRITICA: EC {reading.ec_ds_m} dS/m")
if reading.battery_pct < 10:
alerts.append(f"BATTERIA: {reading.battery_pct}% - sostituire")
for alert in alerts:
log.critical(f"ALERT [{reading.sensor_id}] {alert}")
# TODO: self.sms_client.send(...)
# TODO: self.email_client.send(...)
# ── Pipeline Consumer principale ──────────────────────────────────────────────
class AgriPipelineConsumer:
"""Consumer MQTT con validazione, storage e alerting integrati"""
def __init__(self):
self.influx = InfluxDBWriter(
url = "https://influxdb.azienda.it:8086",
token = "<INFLUX_TOKEN>",
org = "azienda-agricola",
bucket = "farm-sensors",
)
self.s3_bronze = S3BronzeWriter(bucket="farm-raw-data-bronze")
self.alert_mgr = AlertManager()
self.client = mqtt.Client(
client_id = "pipeline-consumer-001",
protocol = mqtt.MQTTv5,
callback_api_version = mqtt.CallbackAPIVersion.VERSION2,
)
self.client.on_connect = self._on_connect
self.client.on_message = self._on_message
self.stats = {"received": 0, "valid": 0, "errors": 0, "alerts": 0}
def _on_connect(self, client, userdata, flags, rc, props):
log.info("Consumer connesso al broker")
# Sottoscrive a TUTTI i sensori suolo di TUTTE le farm
client.subscribe("farm/+/+/soil", qos=1)
client.subscribe("farm/+/+/$status", qos=1)
log.info("Sottoscritto a: farm/+/+/soil e farm/+/+/$status")
def _on_message(self, client, userdata, message):
received_at = datetime.now(timezone.utc)
self.stats["received"] += 1
raw_payload = message.payload.decode("utf-8")
try:
# 1. Parse JSON
data = json.loads(raw_payload)
# 2. Salva Bronze layer (dato grezzo, prima di qualsiasi trasformazione)
self.s3_bronze.write_raw(raw_payload, message.topic, received_at)
# 3. Valida con Pydantic
reading = SoilReadingSchema(**data)
self.stats["valid"] += 1
# 4. Scrivi su InfluxDB (time-series)
self.influx.write_soil_reading(reading)
# 5. Check alert
self.alert_mgr.check_and_alert(reading)
if reading.has_critical_alert():
self.stats["alerts"] += 1
log.info(
f"Processed | {reading.sensor_id} | "
f"moisture={reading.moisture_pct}% | "
f"quality={reading.quality_flag}"
)
except json.JSONDecodeError as e:
self.stats["errors"] += 1
log.error(f"JSON non valido da {message.topic}: {e}")
except ValidationError as e:
self.stats["errors"] += 1
log.error(f"Validazione fallita per {message.topic}: {e}")
# Salva comunque su Bronze (dato anomalo ma registrato)
except Exception as e:
self.stats["errors"] += 1
log.exception(f"Errore imprevisto: {e}")
def run(self):
self.client.connect("emqx.azienda-agricola.it", 8883)
log.info("Pipeline consumer avviata. Ctrl+C per fermare.")
try:
self.client.loop_forever()
except KeyboardInterrupt:
pass
finally:
self.influx.close()
log.info(
f"Stats finali: {self.stats}"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
consumer = AgriPipelineConsumer()
consumer.run()
Architettura Medallion per Dati Agricoli: Bronze, Silver, Gold
La Medallion Architecture (introdotta da Databricks ma ora uno standard de-facto nel data engineering) organizza i dati in tre strati progressivi di qualità. Applicata ai dati IoT agricoli, risolve problemi reali: sensori che inviano valori anomali, timestamp errati per deriva dell'RTC, letture duplicate per ritrasmissione MQTT con QoS 1, e necessità di aggregazioni diverse per dashboard real-time vs modelli ML di lungo periodo.
Medallion Architecture per IoT Agricolo
| Layer | Formato | Contenuto | Operazioni | Retention |
|---|---|---|---|---|
| Bronze (Raw) | JSON / Parquet | Payload MQTT grezzo, immutabile | Nessuna trasformazione, solo store | 5 anni (regulatory) |
| Silver (Cleaned) | Delta Lake / Iceberg | Schema normalizzato, outlier rimossi | Deduplication, outlier filter, type cast | 3 anni |
| Gold (Analytics) | Parquet | Aggregazioni orarie/giornaliere, features ML | Aggregazione, join con meteo/satellite | 10 anni |
# medallion_pipeline.py
# Pipeline Medallion per dati agricoli IoT con PySpark / pandas
# In produzione: usa Databricks, AWS Glue, o dbt su Spark
# Qui: versione pandas per sviluppo locale e testing
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from pathlib import Path
import json
import hashlib
DATA_ROOT = Path("/data/farm")
# ── BRONZE LAYER ──────────────────────────────────────────────────────────────
def load_bronze(date: str) -> pd.DataFrame:
"""
Carica dati grezzi dal Bronze layer (JSON NDJSON).
Nessuna trasformazione: solo lettura e schema enforcement minimo.
"""
bronze_path = DATA_ROOT / "bronze" / date
records = []
for f in bronze_path.glob("**/*.json"):
with open(f) as fp:
envelope = json.load(fp)
records.append(envelope)
if not records:
return pd.DataFrame()
# DataFrame con schema minimo garantito
df = pd.json_normalize(records, sep="_")
df["_bronze_loaded_at"] = datetime.utcnow().isoformat()
df["_source_file"] = [str(f) for f in bronze_path.glob("**/*.json")]
return df
# ── SILVER LAYER ──────────────────────────────────────────────────────────────
def transform_bronze_to_silver(df_bronze: pd.DataFrame) -> pd.DataFrame:
"""
Trasformazioni Bronze → Silver:
1. Parse e normalizza timestamp
2. Rimuovi duplicati (QoS 1 può consegnare più volte)
3. Filtra outlier fisicamente impossibili
4. Cast tipi corretti
5. Aggiungi colonne derivate
"""
if df_bronze.empty:
return pd.DataFrame()
df = df_bronze.copy()
# 1. Parse timestamp
df["ts"] = pd.to_datetime(df["payload_timestamp"], utc=True, errors="coerce")
df = df.dropna(subset=["ts"])
# 2. Deduplica: stesso sensor_id + timestamp = stesso dato
df["_dedup_key"] = df.apply(
lambda r: hashlib.md5(
f"{r.get('payload_sensor_id', '')}{r.get('payload_timestamp', '')}".encode()
).hexdigest(),
axis=1
)
df = df.drop_duplicates(subset=["_dedup_key"], keep="first")
# 3. Filtro outlier fisici
df = df[df["payload_moisture_pct"].between(0, 100)]
df = df[df["payload_temperature_c"].between(-40, 80)]
df = df[df["payload_ph"].between(0, 14)]
df = df[df["payload_ec_ds_m"].between(0, 20)]
# 4. Flag outlier agronomici (mantieni, ma etichetta)
df["is_agronomic_outlier"] = (
~df["payload_moisture_pct"].between(5, 60) |
~df["payload_ph"].between(4.5, 8.5) |
(df["payload_ec_ds_m"] > 4.0)
)
# 5. Colonne derivate
df["date"] = df["ts"].dt.date
df["hour"] = df["ts"].dt.hour
df["month"] = df["ts"].dt.month
# 6. Rename colonne per schema pulito
df = df.rename(columns={
"payload_sensor_id": "sensor_id",
"payload_farm_id": "farm_id",
"payload_field_id": "field_id",
"payload_moisture_pct": "moisture_pct",
"payload_temperature_c": "temperature_c",
"payload_ph": "ph",
"payload_ec_ds_m": "ec_ds_m",
"payload_battery_pct": "battery_pct",
"payload_quality_flag": "quality_flag",
})
# Seleziona solo colonne rilevanti
cols = [
"sensor_id", "farm_id", "field_id", "ts", "date", "hour", "month",
"moisture_pct", "temperature_c", "ph", "ec_ds_m", "battery_pct",
"quality_flag", "is_agronomic_outlier"
]
df = df[[c for c in cols if c in df.columns]]
df["_silver_processed_at"] = datetime.utcnow().isoformat()
return df
# ── GOLD LAYER ────────────────────────────────────────────────────────────────
def transform_silver_to_gold(df_silver: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""
Trasformazioni Silver → Gold:
Produce più tabelle di aggregazione per usi diversi.
"""
if df_silver.empty:
return {}
# Filtra outlier agronomici per analytics
df = df_silver[~df_silver["is_agronomic_outlier"]].copy()
gold_tables = {}
# ─── Aggregazione oraria per dashboard ───
hourly = df.groupby(["sensor_id", "farm_id", "field_id", "date", "hour"]).agg(
moisture_avg = ("moisture_pct", "mean"),
moisture_min = ("moisture_pct", "min"),
moisture_max = ("moisture_pct", "max"),
temp_avg = ("temperature_c", "mean"),
ph_avg = ("ph", "mean"),
ec_avg = ("ec_ds_m", "mean"),
readings_n = ("moisture_pct", "count"),
).reset_index()
hourly["moisture_avg"] = hourly["moisture_avg"].round(2)
gold_tables["hourly_aggregations"] = hourly
# ─── Aggregazione giornaliera per reporting agronomico ───
daily = df.groupby(["sensor_id", "farm_id", "field_id", "date"]).agg(
moisture_avg = ("moisture_pct", "mean"),
moisture_std = ("moisture_pct", "std"),
temp_min = ("temperature_c", "min"),
temp_max = ("temperature_c", "max"),
temp_avg = ("temperature_c", "mean"),
ph_avg = ("ph", "mean"),
ec_avg = ("ec_ds_m", "mean"),
stress_hours = ("moisture_pct", lambda x: (x < 20).sum()),
readings_n = ("moisture_pct", "count"),
).reset_index()
gold_tables["daily_agronomic"] = daily
# ─── Feature engineering per ML (predizione irrigazione) ───
ml_features = df.copy()
ml_features["moisture_lag_1h"] = ml_features.groupby("sensor_id")["moisture_pct"].shift(2)
ml_features["moisture_lag_3h"] = ml_features.groupby("sensor_id")["moisture_pct"].shift(6)
ml_features["moisture_trend"] = (
ml_features["moisture_pct"] - ml_features["moisture_lag_3h"]
)
ml_features["needs_irrigation"] = (ml_features["moisture_pct"] < 25.0).astype(int)
gold_tables["ml_irrigation_features"] = ml_features.dropna(
subset=["moisture_lag_1h", "moisture_lag_3h"]
)
return gold_tables
def run_medallion_pipeline(date: str):
"""Esegue la pipeline Medallion per una data specifica"""
log_prefix = f"[Medallion {date}]"
print(f"{log_prefix} Caricamento Bronze...")
bronze = load_bronze(date)
print(f"{log_prefix} Bronze: {len(bronze)} record")
print(f"{log_prefix} Trasformazione Silver...")
silver = transform_bronze_to_silver(bronze)
print(f"{log_prefix} Silver: {len(silver)} record (dopo dedup e filtro outlier)")
# Salva Silver
silver_path = DATA_ROOT / "silver" / date / "soil_readings.parquet"
silver_path.parent.mkdir(parents=True, exist_ok=True)
silver.to_parquet(silver_path, index=False)
print(f"{log_prefix} Trasformazione Gold...")
gold_tables = transform_silver_to_gold(silver)
for table_name, df in gold_tables.items():
gold_path = DATA_ROOT / "gold" / date / f"{table_name}.parquet"
gold_path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(gold_path, index=False)
print(f"{log_prefix} Gold {table_name}: {len(df)} record")
print(f"{log_prefix} Pipeline completata.")
if __name__ == "__main__":
today = datetime.utcnow().strftime("%Y-%m-%d")
run_medallion_pipeline(today)
Integrazione con InfluxDB e Apache Kafka
InfluxDB per Time-Series Agricole
InfluxDB e il database più adatto per time-series IoT ad alta frequenza. A differenza di PostgreSQL
o MySQL, e ottimizzato per scritture massive in sequenza temporale, compressione automatica dei dati
storici (downsampling) e query su intervalli temporali con funzioni aggregate native
(mean(), max(), moving_average()).
Per un'azienda agricola con 50 sensori che pubblicano ogni 30 secondi, InfluxDB gestisce comodamente 100 scritture al secondo su hardware consumer, con retention policy automatica: dati raw per 30 giorni, aggregazioni orarie per 1 anno, aggregazioni giornaliere per 10 anni. Il tutto in pochi MB di storage per anno per sensore.
# influxdb_queries.py
# Query Flux per analytics agricole su InfluxDB 2.x
from influxdb_client import InfluxDBClient
from datetime import datetime, timedelta
client = InfluxDBClient(
url = "https://influxdb.azienda.it:8086",
token = "<TOKEN>",
org = "azienda-agricola",
)
query_api = client.query_api()
# ─── Query 1: Media umidita ultime 24 ore per campo ───────────────────────────
QUERY_MOISTURE_24H = """
from(bucket: "farm-sensors")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "moisture_pct")
|> filter(fn: (r) => r.field_id == "campo-nord")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> yield(name: "hourly_moisture")
"""
# ─── Query 2: Alert stress idrico (moisture < 20% nelle ultime 6h) ────────────
QUERY_DROUGHT_ALERT = """
from(bucket: "farm-sensors")
|> range(start: -6h)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "moisture_pct")
|> filter(fn: (r) => r._value < 20.0)
|> group(columns: ["sensor_id", "field_id"])
|> count()
|> filter(fn: (r) => r._value > 3)
|> yield(name: "drought_sensors")
"""
# ─── Query 3: Trend temperatura settimanale con banda statistica ──────────────
QUERY_TEMP_WEEKLY = """
from(bucket: "farm-sensors")
|> range(start: -7d)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "temperature_c")
|> aggregateWindow(every: 6h, fn: mean)
|> movingAverage(n: 4)
|> yield(name: "temp_trend_7d")
"""
# ─── Query 4: Stato batterie sensori (per manutenzione preventiva) ────────────
QUERY_BATTERY_STATUS = """
from(bucket: "farm-sensors")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "battery_pct")
|> last()
|> filter(fn: (r) => r._value < 20)
|> sort(columns: ["_value"])
|> yield(name: "low_battery_sensors")
"""
def run_analytics():
print("=== Dashboard Analytics Agricole ===")
# Esegui query umidita
tables = query_api.query(QUERY_MOISTURE_24H)
for table in tables:
for record in table.records:
print(f" {record.get_time()} | "
f"sensor={record.values.get('sensor_id', 'N/A')} | "
f"moisture={record.get_value():.1f}%")
# Check alert stress idrico
drought_tables = query_api.query(QUERY_DROUGHT_ALERT)
for table in drought_tables:
for record in table.records:
print(f" ALERT STRESS IDRICO: sensor {record.values.get('sensor_id')} "
f"in field {record.values.get('field_id')}")
if __name__ == "__main__":
run_analytics()
Apache Kafka per Stream Processing ad Alta Scala
Quando il numero di sensori supera le centinaia o quando si richiedono latenze sub-secondo, MQTT da solo non basta come sistema di distribuzione. Apache Kafka entra come message bus enterprise tra il broker MQTT e il processing layer. Il pattern standard e: MQTT Broker → Kafka Connect (MQTT Source Connector) → Kafka Topics → Consumer Groups.
Kafka porta benefici chiave in questo contesto: replay dei messaggi (se il consumer ha un bug, si riprocessa tutto), partizionamento per field/zona geografica, consumer multipli independenti (InfluxDB writer, alert engine, ML inference, data lake writer leggono lo stesso dato senza interferirsi), e garanzie exactly-once con Kafka Transactions.
# kafka_consumer.py
# Consumer Kafka per dati sensori agricoli
# Dipendenze: pip install confluent-kafka
from confluent_kafka import Consumer, KafkaError, KafkaException
import json
import logging
log = logging.getLogger(__name__)
KAFKA_CONFIG = {
"bootstrap.servers": "kafka-broker-1:9092,kafka-broker-2:9092",
"group.id": "farm-analytics-consumer",
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # Commit manuale per exactly-once
"security.protocol": "SASL_SSL",
"sasl.mechanism": "PLAIN",
"sasl.username": "farm-consumer",
"sasl.password": "<KAFKA_PASSWORD>",
}
TOPICS = ["farm.soil.readings", "farm.weather.readings"]
def process_soil_message(data: dict) -> bool:
"""Elabora un messaggio sensore suolo"""
sensor_id = data.get("sensor_id", "unknown")
moisture = data.get("moisture_pct", 0)
# Routing logica: stress idrico → alert immediato
if moisture < 15.0:
log.warning(f"STRESS IDRICO critico: sensor {sensor_id} = {moisture}%")
# In produzione: pubblica su topic alert
return True
log.info(f"OK | sensor={sensor_id} | moisture={moisture}%")
return True
def run_kafka_consumer():
consumer = Consumer(KAFKA_CONFIG)
consumer.subscribe(TOPICS)
log.info(f"Consumer Kafka avviato su topics: {TOPICS}")
try:
while True:
msg = consumer.poll(timeout=1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() == KafkaError._PARTITION_EOF:
log.debug(f"Raggiunto EOF: {msg.topic()}[{msg.partition()}]")
else:
raise KafkaException(msg.error())
continue
# Processa messaggio
try:
data = json.loads(msg.value().decode("utf-8"))
success = process_soil_message(data)
if success:
consumer.commit(message=msg) # Commit solo se processato correttamente
except json.JSONDecodeError as e:
log.error(f"JSON non valido: {e}")
consumer.commit(message=msg) # Commit anche errori per non bloccare
except KeyboardInterrupt:
log.info("Consumer fermato")
finally:
consumer.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
run_kafka_consumer()
Dashboard Grafana e Sistema di Alerting
Grafana e lo strumento standard per visualizzare dati IoT in tempo reale. Si integra nativamente con InfluxDB 2.x via datasource e permette di costruire dashboard interattive con mappe geografiche, grafici time-series e pannelli di stato senza scrivere codice frontend.
Per un'azienda agricola, la dashboard standard include:
- Mappa campo: Plugin Geomap con overlay sensori, colori per stress idrico
- Time-series umidita: Grafico 24h con soglie colorate (rosso < 15%, giallo < 25%)
- Gauge batterie: Stato batteria tutti i sensori per pianificazione manutenzione
- Heatmap pH: Mappa di calore pH per campo, per decisioni di calcitazione
- Forecast irrigazione: Output modello ML prossime 48h
# grafana_alert_rules.yaml
# Regole di alerting Grafana per agricoltura di precisione
apiVersion: 1
groups:
- name: farm-critical-alerts
interval: 1m
rules:
# Alert stress idrico critico
- uid: drought-critical
title: "Stress Idrico Critico"
condition: C
data:
- refId: A
datasourceUid: influxdb-farm
model:
query: |
from(bucket: "farm-sensors")
|> range(start: -30m)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "moisture_pct")
|> last()
- refId: C
datasourceUid: __expr__
model:
type: threshold
conditions:
- evaluator:
params: [15.0]
type: lt
query: { params: ["A"] }
noDataState: Alerting
execErrState: Alerting
for: 10m
annotations:
summary: "Sensore {{ $labels.sensor_id }} in stress idrico"
description: "Umidita {{ $values.A }}% sotto soglia critica 15%"
labels:
severity: critical
team: agronomist
# Alert batteria scarica
- uid: battery-low
title: "Batteria Sensore in Esaurimento"
condition: C
data:
- refId: A
datasourceUid: influxdb-farm
model:
query: |
from(bucket: "farm-sensors")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "soil_reading")
|> filter(fn: (r) => r._field == "battery_pct")
|> last()
- refId: C
datasourceUid: __expr__
model:
type: threshold
conditions:
- evaluator:
params: [15]
type: lt
query: { params: ["A"] }
for: 5m
annotations:
summary: "Batteria bassa su {{ $labels.sensor_id }}"
labels:
severity: warning
team: maintenance
Anti-Pattern: Soglie Fisse Universali
Un errore comune nelle prime implementazioni e usare soglie di umidita identiche per tutte le colture e tutti i tipi di suolo. La soglia critica di umidita del 20% e corretta per un suolo argilloso con pomodori, ma completamente sbagliata per un suolo sabbioso con vite o per un vivaio florovivaistico. Le soglie devono essere configurabili per sensore, coltura e fase fenologica. Implementa un sistema di configurazione dinamica delle soglie, non hardcoded nel codice di alerting.
Deployment, Security e Scalabilità
Docker Compose per Sviluppo Locale
Per sviluppare e testare localmente l'intera stack senza dipendenze cloud, un
docker-compose.yml che include broker MQTT, InfluxDB e Grafana:
# docker-compose.yml
# Stack IoT agricolo completo per sviluppo locale
version: "3.9"
services:
# Broker MQTT - Eclipse Mosquitto
mosquitto:
image: eclipse-mosquitto:2.0
ports:
- "1883:1883" # MQTT plain (solo sviluppo)
- "8883:8883" # MQTT TLS
- "9001:9001" # WebSocket
volumes:
- ./config/mosquitto.conf:/mosquitto/config/mosquitto.conf
- ./certs:/mosquitto/certs:ro
- mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log
restart: unless-stopped
# Time-series database
influxdb:
image: influxdb:2.7
ports:
- "8086:8086"
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: admin
DOCKER_INFLUXDB_INIT_PASSWORD: changeme123!
DOCKER_INFLUXDB_INIT_ORG: azienda-agricola
DOCKER_INFLUXDB_INIT_BUCKET: farm-sensors
DOCKER_INFLUXDB_INIT_RETENTION: 30d
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-super-secret-token
volumes:
- influxdb_data:/var/lib/influxdb2
restart: unless-stopped
# Dashboard e alerting
grafana:
image: grafana/grafana:10.4.0
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
GF_INSTALL_PLUGINS: grafana-worldmap-panel,grafana-clock-panel
volumes:
- grafana_data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning
depends_on:
- influxdb
restart: unless-stopped
# Pipeline consumer (il nostro codice Python)
pipeline-consumer:
build:
context: .
dockerfile: Dockerfile.consumer
environment:
MQTT_HOST: mosquitto
MQTT_PORT: "1883"
INFLUX_URL: http://influxdb:8086
INFLUX_TOKEN: my-super-secret-token
INFLUX_ORG: azienda-agricola
INFLUX_BUCKET: farm-sensors
depends_on:
- mosquitto
- influxdb
restart: unless-stopped
volumes:
mosquitto_data:
mosquitto_logs:
influxdb_data:
grafana_data:
Security Best Practice per IoT Agricolo
La sicurezza dei sistemi IoT agricoli e spesso sottovalutata. Un sensore compromesso può alterare letture e causare irrigazioni anomale, sprechi o danni alle colture. Il minimo indispensabile:
Checklist di Sicurezza IoT Agricolo
- TLS mutuo (mTLS): Ogni dispositivo ha un certificato client univoco. Il broker autentica i dispositivi tramite certificato, non solo con username/password.
-
ACL per topic: Ogni sensore può pubblicare SOLO sui propri topic
(
farm/campo-nord/sensor-001/#) e non leggere topic di altri sensori. - Firmware signing: Gli aggiornamenti OTA (over-the-air) devono essere firmati crittograficamente per prevenire firmware malevoli.
- Rotazione credenziali: Token e certificati con scadenza 1 anno, rotazione automatica via ACME o AWS IoT Certificate Rotation.
- Network segmentation: I dispositivi IoT in VLAN dedicata, isolata dalla rete aziendale. Accesso al broker solo dal gateway, mai direttamente da internet.
- Rate limiting: Il broker limita il numero di messaggi per secondo per dispositivo per prevenire flooding accidentale o attacchi.
L'Agricoltura di Precisione in Italia: Opportunità e Incentivi 2025
L'Italia e in una posizione unica nel panorama europeo dell'agritech: ha il settore alimentare più pregiato del mondo (DOP, IGP, DOC), una struttura produttiva frammentata (aziende medie di 11 ettari contro 33 della media UE) ma con fortissima expertise agronomica, ed e prima in Europa per valore aggiunto agricolo con 42.4 miliardi di euro nel 2024 (+9% anno su anno).
La sfida e portare le tecnologie IoT e AI anche alle PMI agricole, che costituiscono il 95% del tessuto produttivo. Ecco gli strumenti di finanziamento disponibili nel 2025:
Incentivi AgriTech e IoT in Italia (2025)
| Misura | Importo/Beneficio | Beneficiari | Scadenza |
|---|---|---|---|
| Piano Transizione 5.0 | Credito d'imposta 35-45% su investimenti digitali + energetici | Tutte le imprese (incluse agricole) | 31/12/2025 |
| PNRR Missione 2.3 - Meccanizzazione | 400M EUR contributi a fondo perduto | Imprese agricole per macchine 4.0 | Esaurito (nuovi bandi previsti) |
| Credito d'Imposta 4.0 (ex Industria 4.0) | 20% su beni strumentali tecnologici | Imprese con ATECO agricolo | Prorogato 2025 |
| INAIL Bando ISI Agricoltura | Fino al 65% a fondo perduto | Imprese e cooperative agricole | Annuale (apertura primavera) |
| PSP (Piano Strategico PAC 2023-2027) | Misure eco-schema con premi per agricoltura di precisione | Aziende con SAU registrata | Annuale (domanda PAC) |
| Bando Agrivoltaico PNRR | 1.5 MLD EUR per impianti fotovoltaici integrati in agricoltura | Imprese agricole | 2025-2026 |
Per un'azienda agricola che investe in un sistema IoT completo (sensori, gateway, software, connettivita) con un costo totale di 50.000 EUR, il Piano Transizione 5.0 può coprire fino a 22.500 EUR (45% del costo) come credito d'imposta, rendendo l'investimento recuperabile in 2-3 anni grazie ai risparmi su acqua, fertilizzanti e manodopera.
Case Study: Masseria Pugliese 500 Ettari - Ulivo e Grano
Un caso reale (dati aggregati e anonimizzati) di un'azienda agricola pugliese che ha implementato una pipeline IoT con 120 sensori di umidita suolo, 8 stazioni meteo e un sistema di irrigazione automatizzata:
- Investimento iniziale: 85.000 EUR (hardware + software + installazione)
- Contributi PNRR e Transizione 4.0: 38.000 EUR (44.7%)
- Investimento netto: 47.000 EUR
- Risparmio idrico anno 1: -42% (-18.000 EUR/anno su bolletta irrigazione)
- Risparmio fertilizzanti: -22% (-8.500 EUR/anno)
- Aumento resa grano: +11% (+15.000 EUR/anno di fatturato)
- Payback period: 2.3 anni
- ROI a 5 anni: 342%
Best Practice e Anti-Pattern da Evitare
Best Practice Consolidate
-
Schema evolution con versionamento: Includi sempre un campo
schema_versionnei payload MQTT. Quando aggiungi nuovi campi, incrementa la versione. Il consumer gestisce versioni diverse senza breaking changes. - Timestamp dal dispositivo, non dal server: Il timestamp deve essere quello del sensore al momento della lettura, non quello di arrivo al broker. La latenza di rete può essere variabile e falsificare le serie temporali.
- QoS 1 per default, QoS 2 solo per comandi critici: QoS 2 quadruplica il traffico di rete. Usarlo solo per comandi di attuazione (apertura valvole, avvio pompe) dove la duplicazione e inaccettabile.
- Buffer locale obbligatorio: Ogni gateway deve avere un buffer locale (SQLite, file NDJSON) per continuare a raccogliere dati durante disconnessioni di rete. In agricoltura le connessioni sono inaffidabili.
- Calibrazione periodica dei sensori: I sensori di umidita suolo derivano nel tempo (tipicamente 2-5% all'anno). Pianifica calibrazioni semestrali e implementa detection automatica della deriva confrontando sensori adiacenti.
- Retention policy InfluxDB: Configura downsampling automatico: dati raw 30 giorni, aggregazioni orarie 1 anno, aggregazioni giornaliere illimitate. Risparmia storage senza perdere la granularità necessaria per analytics di lungo periodo.
Anti-Pattern Critici da Evitare
-
Topic MQTT flat (senza gerarchia): Topic come
sensor_001_moistureinvece difarm/campo-nord/sensor-001/soil/moisturerendono impossibile usare wildcard per subscription aggregate e scalare a centinaia di sensori. - Polling invece di event-driven: Non interrogare il broker MQTT ogni N secondi per "chiedere" nuovi dati. MQTT e push-based: il publisher invia quando ci sono dati, il subscriber riceve immediatamente. Il polling annulla tutti i vantaggi del protocollo.
- Nessuna validazione schema: Accettare qualsiasi JSON dal broker senza validazione schema. Un sensore difettoso che invia null, stringhe al posto di numeri o valori fuori range (temperatura -999) inquina tutto il data lake senza validazione.
- Scrittura diretta Bronze al Gold: Saltare il layer Silver e scrivere direttamente i dati grezzi nelle aggregazioni. Quando si scopre un bug nella logica di trasformazione, si deve riprocessare tutto daccapo perchè i dati grezzi non sono stati preservati.
- Broker pubblico senza autenticazione: Usare broker MQTT pubblici (test.mosquitto.org) o broker privati senza TLS/autenticazione. I dati agricoli sono asset aziendali sensibili; un competitor può sottoscrivere ai tuoi topic e leggere in tempo reale la situazione produttiva della tua azienda.
- Single point of failure sul broker: Un singolo broker MQTT senza clustering o failover. EMQX e HiveMQ supportano clustering nativo; Mosquitto richiede soluzioni esterne per l'HA. Un broker down blocca l'intera raccolta dati.
Scalabilità: Da 10 a 10.000 Sensori
L'architettura descritta scala linearmente fino a diverse migliaia di sensori senza modifiche architetturali. Ecco i numeri tipici per configurazione hardware del broker MQTT:
capacità Broker MQTT per Configurazione
| Configurazione | Broker | Sensori Max | Msg/sec | Costo Mensile |
|---|---|---|---|---|
| Sviluppo/Test | Mosquitto su Raspberry Pi 4 | 100 | 500 | 0 (hardware owned) |
| PMI (50-500 ha) | EMQX su VPS 4 vCPU/8GB | 1.000 | 5.000 | 40-80 EUR |
| Grande Azienda (500+ ha) | EMQX Cluster 3 nodi | 10.000 | 50.000 | 200-400 EUR |
| Cooperativa/Distretto | HiveMQ Enterprise / AWS IoT Core | 100.000+ | Illimitato | Pay-per-use |
AWS IoT Core e Azure IoT Hub sono le scelte cloud-native per scale nazionale o multi-azienda: gestiscono automaticamente la scalabilità, offrono SLA del 99.99% e si integrano nativamente con i rispettivi ecosistemi (AWS Timestream, Lambda, S3 per AWS; Azure Data Explorer, Stream Analytics, Data Lake per Azure). Il costo e tipicamente 1-5 dollari per milione di messaggi, con free tier generoso per i primi test.
Conclusioni e Prossimi Passi
Abbiamo costruito l'intera pipeline IoT per agricoltura di precisione: dai sensori nel campo al data lake strutturato, passando per MQTT con QoS appropriato, validazione Pydantic, storage InfluxDB per time-series e architettura Medallion per il data lake. Il codice Python mostrato e production-ready e copre i pattern fondamentali che si trovano nei sistemi reali.
I punti chiave da portare con te:
- MQTT e il protocollo giusto per IoT agricolo: leggero, asincrono, con supporto per connettivita instabile
- LoRaWAN per il campo aperto, WiFi/RS-485 per le serre: non esiste un unico protocollo wireless universale
- La validazione schema (Pydantic) non e opzionale: i sensori inviano dati sbagliati più spesso di quanto pensi
- La Medallion Architecture (Bronze/Silver/Gold) garantisce riprocessabilita e qualità progressiva dei dati
- InfluxDB + Grafana e lo stack minimo per monitoring in tempo reale senza costi di licenza
- Gli incentivi PNRR e Transizione 5.0 coprono fino al 45% dell'investimento per le imprese agricole italiane
Nel prossimo articolo della serie FoodTech, esploreremo come applicare modelli di machine learning edge per il monitoraggio delle colture direttamente sul gateway, riducendo la latenza da minuti a millisecondi e abilitando reazioni in tempo reale a eventi critici come gelate improvvise o attacchi fungini in fase iniziale.
Risorse e Approfondimenti
- Documentazione ufficiale paho-mqtt: eclipse.dev/paho
- EMQX Broker (open source, cluster-ready): emqx.io
- InfluxDB 2.x documentazione: docs.influxdata.com
- Piano Transizione 5.0 MIMIT: mimit.gov.it
- ISTAT Agricoltura di Precisione 2024: istat.it
- Databricks Medallion Architecture: databricks.com
Articoli Correlati in Altre Serie
- MLOps: Come deployare in produzione i modelli ML di previsione resa
- Data & AI Business - AI nella Manifattura: Pattern IoT applicati all'industria con Kafka e OPC-UA
- AI Engineering: RAG su documentazione agronomica per assistenti virtuali per agronomi

Commenti
Caricamento commenti...
Accedi per commentare