Salta al contenuto principale

Pipeline IoT per Agricoltura di Precisione con Python e MQTT

Guida completa alla costruzione di pipeline IoT per agricoltura di precisione: sensori MQTT, broker, data lake e dashboard.

Cosa ne pensi di questo articolo?

Pipeline IoT per Agricoltura di Precisione con Python e MQTT
FoodTechFoto:Sergei StarostinPexels

In Breve

Guida completa alla costruzione di pipeline IoT per agricoltura di precisione: sensori MQTT, broker, data lake e dashboard.

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

#ArticoloLivelloStato
1Pipeline IoT per Agricoltura di Precisione (sei qui)AdvancedCorrente
2ML Edge per Monitoraggio Colture: Computer Vision sui CampiAdvancedIn arrivo
3Satellite API e Indici Vegetativi: NDVI con Python e Sentinel-2IntermediateIn arrivo
4Tracciabilita Blockchain nel Food: dal Campo al SupermercatoIntermediateIn arrivo
5Computer Vision per Quality Control nell'Industria AlimentareAdvancedIn arrivo
6FSMA e Compliance Digitale: Automazione dei Processi NormativiIntermediateIn arrivo
7Vertical Farming: Controllo Ambientale con IoT e MLAdvancedIn arrivo
8Demand Forecasting per il Food Retail con Prophet e LightGBMIntermediateIn arrivo
9Dashboard Farm Intelligence: Real-Time Analytics con GrafanaIntermediateIn arrivo
10Supply Chain Food Optimization: ML per Riduzione degli SprechiIntermediateIn 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

TecnologiaApplicazione PrincipaleAdozione ItaliaROI Medio
Sensori suolo IoTIrrigazione variabile, fertilizzazioneAlta (Nord Italia)15-25% riduzione costi input
Droni agricoliMappatura, trattamenti, analisi fogliareMedia30-40% risparmio fitofarmaci
Immagini satellitariNDVI, stress idrico, rese predetteMedia-alta5-10% ottimizzazione rese
Stazioni meteo IoTModelli previsione malattie, irrigazioneAlta10-20% riduzione trattamenti
Variable Rate TechnologySemina, fertilizzazione variabileBassa-media8-15% risparmio input
Machine learning su dati fieldPrevisione rese, ottimizzazione agronomicaBassa+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

CategoriaParametroTecnologiaCosto UnitarioProtocollo
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

ProtocolloRangeBandaBatteriaCosto InfraCaso 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

LivelloNomeGaranziaOverheadUso 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

LayerFormatoContenutoOperazioniRetention
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)

MisuraImporto/BeneficioBeneficiariScadenza
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_version nei 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_moisture invece di farm/campo-nord/sensor-001/soil/moisture rendono 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

ConfigurazioneBrokerSensori MaxMsg/secCosto 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

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

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti