Salta al contenuto principale

Dashboard Real-Time per Farm IoT con Angular e Grafana

Sono le 03:47 di una notte di luglio. Un sensore in un vigneto del Chianti registra che la temperatura del suolo al livello delle radici ha superato…

Cosa ne pensi di questo articolo?

Dashboard Real-Time per Farm IoT con Angular e Grafana
FoodTechFoto:Rene TerpPexels

In Breve

Sono le 03:47 di una notte di luglio. Un sensore in un vigneto del Chianti registra che la temperatura del suolo al livello delle radici ha superato…

Dashboard Real-Time per Farm IoT con Angular e Grafana

Sono le 03:47 di una notte di luglio. Un sensore in un vigneto del Chianti registra che la temperatura del suolo al livello delle radici ha superato la soglia critica di 28 gradi centigradi per la quarta ora consecutiva. Senza un sistema di monitoring real-time, l'agronomo scopre il problema il mattino seguente durante l'ispezione manuale. Il danno e già avvenuto: stress idrico acuto, maturazione accelerata in modo non uniforme, perdita stimata del 12% sulla resa del lotto.

Con una dashboard real-time collegata all'alerting, quello stesso evento genera una notifica su Telegram alle 03:48. Il sistema di irrigazione automatico entra in azione alle 03:49. Alle 06:00, quando l'agronomo si sveglia, trova un report che documenta l'intervento avvenuto, la curva di temperatura corretta e lo stato attuale: tutto nella norma. Questa differenza, tra reattività e proattivita, vale in media il 15-30% delle perdite da stress idrico nelle colture mediterrane pregiate.

Le dashboard farm intelligence non sono uno strumento di reporting: sono sistemi decisionali in tempo reale. Combinando Grafana per la visualizzazione operativa, InfluxDB per lo storage time-series, e Angular per le interfacce custom mobile-first, e possibile costruire una piattaforma di farm intelligence che trasforma dati grezzi dei sensori in azioni agronomiche concrete. Questo articolo copre l'intera architettura, dal broker MQTT al pannello di controllo sul telefono dell'agronomo in campo.

Cosa Imparerai in Questo Articolo

  • Architettura completa di un sistema dashboard per farm IoT: sensori, InfluxDB, Grafana, Angular
  • Schema design InfluxDB ottimizzato per dati agricoli con Flux e InfluxQL
  • Configurazione Grafana: datasource, panels, variabili template, provisioning YAML
  • Alerting Grafana con contact points (email, Telegram, Slack), alert rules e notification policies
  • Quando usare Angular custom vs Grafana puro: casi d'uso e architettura ibrida
  • Servizio Angular IoT con WebSocket, Signals, e grafici real-time con ngx-charts
  • Docker Compose stack completo: Mosquitto + InfluxDB + Grafana + Angular
  • Case study: azienda vitivinicola con 50 sensori e 6 appezzamenti

Serie FoodTech - Tutti gli Articoli

#ArticoloLivelloStato
1Pipeline IoT per Agricoltura di Precisione con Python e MQTTAdvancedDisponibile
2ML Edge per Monitoraggio Colture: Computer Vision sui CampiAdvancedDisponibile
3Satellite API e Indici Vegetativi: NDVI con Python e Sentinel-2IntermediateDisponibile
4Tracciabilita Blockchain nel Food: dal Campo al SupermercatoIntermediateDisponibile
5Computer Vision per Quality Control nell'Industria AlimentareAdvancedDisponibile
6FSMA e Compliance Digitale: Automazione dei Processi NormativiIntermediateDisponibile
7Vertical Farming: Controllo Ambientale con IoT e MLAdvancedDisponibile
8Demand Forecasting per il Food Retail con Prophet e LightGBMIntermediateDisponibile
9Dashboard Real-Time per Farm IoT con Angular e Grafana (sei qui)AdvancedCorrente
10Supply Chain Food Optimization: ML per Riduzione degli SprechiIntermediateDisponibile

perchè le Dashboard Real-Time Cambiano l'Agricoltura

L'agricoltura tradizionale opera su cicli di osservazione lunghi: l'agronomo visita il campo una o due volte a settimana, rileva parametri visivi, agisce sulla base dell'esperienza. Questo modello funziona bene quando le variabili ambientali cambiano lentamente. Ma la crisi climatica ha accelerato la volatilita: ondate di calore, siccita improvvise, gelate tardive e alluvioni lampo richiedono tempi di risposta che la visita settimanale non può garantire.

I dati economici quantificano il costo di questa latenza decisionale. Secondo uno studio dell'Universita di Wageningen del 2024, le perdite attribuibili a irrigazione non ottimale si attestano tra il 15% e il 30% della resa potenziale nelle colture ortofrutticole intensive. Per una viticoltura di qualità, lo stress idrico nel periodo di invaiatura può causare danni permanenti all'uva che si traducono in cali del 20-35% del valore commerciale per quel lotto. In Italia, dove il comparto vitivinicolo vale oltre 15 miliardi di euro annui, la differenza tra monitoraggio passivo e reattivo rappresenta centinaia di milioni di euro di valore a rischio ogni stagione.

Impatto Economico del Monitoraggio Real-Time: Confronto

ScenarioSenza Real-TimeCon Real-TimeDelta
Consumo idrico annuoBaseline 100%-25% a -40%Risparmio significativo
Perdite stress idrico15-30% resa3-7% resa+8-23% resa recuperata
Costi fertilizzantiBaseline 100%-20% a -35%Ottimizzazione variabile
Perdite per gelo tardivoElevate, parzialmente evitabiliRidotte con allerta anticipata-60% danni prevenibili
Ore lavoro monitoraggio8-12 ore/settimana1-2 ore/settimana-80% tempo operativo
ROI sistema IoT + dashboardN/A18-36 mesi paybackPositivo in 2-3 stagioni

La piattaforma Grafana e utilizzata da AgriTech, azienda specializzata nel monitoraggio della canapa industriale, per gestire dati da oltre 70 punti di misurazione ambientale per farm, con refresh ogni 60 secondi. Il risultato documentato e stato un aumento della produzione di 4 volte rispetto alla baseline pre-monitoring, con una riduzione dei costi degli input del 28%. Casi come questo dimostrano che la tecnologia di dashboard real-time non e un costo IT: e un investimento agronomico con ritorno misurabile.

Architettura del Sistema Dashboard Farm IoT

La progettazione dell'architettura e il primo passo critico. Una scelta sbagliata a questo livello si propaga attraverso tutto il sistema e diventa costosa da correggere in seguito. L'architettura che presentiamo qui e stata validata in ambienti di produzione reali e scala da 10 a 10.000 sensori senza modifiche architetturali.

Architettura End-to-End: Sensori al Dashboard

┌──────────────────────────────────────────────────────────────────────────┐
│                         LAYER 1: FIELD (Campo)                           │
│                                                                          │
│  [Sensore Suolo]   [Stazione Meteo]   [Sensore pH]   [Sensore PAR/DLI]  │
│  T/U/CE/N suolo    T aria, RH, mm     pH 0-14        Lux, umol/m2/s     │
│        │                │                 │                 │            │
│        └────────────────┴─────────────────┴─────────────────┘           │
│                           LoRaWAN / Zigbee / RS-485 / 4G                │
└─────────────────────────────────┬────────────────────────────────────────┘
                                  │
┌─────────────────────────────────▼────────────────────────────────────────┐
│                     LAYER 2: EDGE GATEWAY                                │
│                                                                          │
│          [Gateway Raspberry Pi 4 / Industrial IoT Gateway]              │
│          - Aggregazione dati multi-sensore (polling 30s)                 │
│          - Filtro outlier e validazione locale                           │
│          - Buffer SQLite offline (7 giorni autonomia)                    │
│          - Timestamp normalizzazione UTC                                 │
│          - MQTT publish: farm/campo1/sensore01/soil                      │
└─────────────────────────────────┬────────────────────────────────────────┘
                                  │ MQTT over TLS 8883
┌─────────────────────────────────▼────────────────────────────────────────┐
│                     LAYER 3: BROKER MQTT                                 │
│                                                                          │
│          [Eclipse Mosquitto / EMQX Enterprise]                          │
│          - Topic ACL per appezzamento e sensore                          │
│          - mTLS autenticazione dispositivi                               │
│          - QoS 1 per dati critici, QoS 0 per telemetria                 │
│          - Last Will Testament per offline detection                     │
└─────────────────────────────────┬────────────────────────────────────────┘
                                  │
┌─────────────────────────────────▼────────────────────────────────────────┐
│                     LAYER 4: INGESTION & STORAGE                        │
│                                                                          │
│   [Telegraf MQTT Consumer]  ───►  [InfluxDB v2.7 / v3]                 │
│   - Parsing JSON payload         - Measurement: farm_sensors            │
│   - Tag extraction               - Retention: 90 giorni raw             │
│   - Field mapping                - Downsampling: 1y compressed           │
│   - Batch write 1000 punti       - Continuous queries 5m/1h/1d          │
└───────────┬─────────────────────────┬────────────────────────────────────┘
            │                         │
┌───────────▼───────────┐   ┌────────▼────────────────────────────────────┐
│   LAYER 5: GRAFANA    │   │   LAYER 5: ANGULAR FRONTEND                 │
│                       │   │                                              │
│   - Datasource InfluxDB│   │   - Dashboard custom mobile-first           │
│   - 12 panel types    │   │   - WebSocket real-time (1s refresh)        │
│   - Alerting engine   │   │   - Angular Signals state management        │
│   - Provisioning YAML │   │   - ngx-charts: line, gauge, geo            │
│   - Embed in Angular  │   │   - PWA offline-first                       │
└───────────────────────┘   └─────────────────────────────────────────────┘
            │                         │
            └────────────┬────────────┘
                         │
┌────────────────────────▼────────────────────────────────────────────────┐
│                     LAYER 6: ALERTING & NOTIFICATION                    │
│   Email, Telegram Bot, Slack, Webhook, SMS Gateway                      │
│   Alert rules: stress idrico, gelo, pH anomalo, batteria bassa          │
└─────────────────────────────────────────────────────────────────────────┘

Il punto chiave di questa architettura e la separazione delle responsabilità: Grafana si occupa della visualizzazione operativa e dell'alerting per gli operatori tecnici, mentre Angular serve l'interfaccia mobile per agronomi e manager in campo che necessitano di un'esperienza più guidata e personalizzata. I dati fluiscono sempre da InfluxDB come fonte di verita unica, evitando inconsistenze.

InfluxDB per Dati Agricoli: Schema Design e Query

InfluxDB e il database time-series di riferimento per applicazioni IoT agricole grazie alla sua capacità di gestire ingestion ad alto throughput, compressione efficiente e query temporali native. La scelta tra InfluxDB v2 (open source), InfluxDB v3 (OSS nuova architettura basata su Apache Arrow/Parquet), e InfluxDB Cloud dipende dalle dimensioni del deployment. Per un'azienda agricola con fino a 200 sensori, InfluxDB v2 OSS in self-hosted e sufficiente.

Schema Design per Dati Agricoli

La regola fondamentale di InfluxDB: i tag sono indicizzati (usali per filtrare), i field non lo sono (usali per i valori numerici). Un cattivo schema design e la causa numero uno di degradazione delle performance in produzione.

# Schema InfluxDB per Farm IoT
# Measurement: farm_sensors
# Tags (indicizzati - alta cardinalita moderata):
#   farm_id:     "az_rossi_chianti"
#   field_id:    "appezzamento_a1"
#   sensor_id:   "soil_01"
#   sensor_type: "soil" | "weather" | "ph" | "par"
#   zone:        "zona_nord" | "zona_sud"
#
# Fields (valori numerici):
#   soil_temp_c:     float (temperatura suolo gradi Celsius)
#   soil_moisture_pct: float (umidita suolo %)
#   soil_ec_ds:      float (conducibilita elettrica dS/m)
#   soil_ph:         float (pH 0-14)
#   air_temp_c:      float (temperatura aria gradi Celsius)
#   air_humidity_pct: float (umidita relativa aria %)
#   rainfall_mm:     float (precipitazioni mm)
#   wind_speed_ms:   float (velocità vento m/s)
#   par_umol:        float (radiazione PAR umol/m2/s)
#   dli_mol:         float (DLI giornaliero mol/m2/d)
#   water_level_cm:  float (livello acqua cisterna cm)
#   battery_pct:     float (livello batteria sensore %)

# Esempio line protocol per Telegraf/scrittura diretta:
farm_sensors,farm_id=az_rossi_chianti,field_id=appezzamento_a1,sensor_id=soil_01,sensor_type=soil \
  soil_temp_c=24.7,soil_moisture_pct=38.2,soil_ec_ds=0.45,battery_pct=87.0 1709289600000000000

Attenzione: Alta Cardinalita dei Tag

Non inserire mai valori ad alta cardinalita nei tag: timestamp, UUID casuali, coordinate GPS precise o valori numerici continui. Questi creano una "cardinalita esplosiva" che degrada le performance di InfluxDB in modo esponenziale. Usa invece i field per valori ad alta variabilità come le coordinate GPS (che devono essere arrotondate o salvate come tag con valori discreti per zone).

Configurazione Telegraf per MQTT Consumer

Telegraf e l'agente di raccolta dati di InfluxData: si connette al broker MQTT, parsa i payload JSON e li scrive in InfluxDB con la configurazione corretta di measurement, tag e field.

# telegraf.conf - MQTT Consumer per Farm IoT
[agent]
  interval = "10s"
  round_interval = true
  metric_batch_size = 1000
  metric_buffer_limit = 10000
  flush_interval = "10s"

# Input: MQTT Broker
[[inputs.mqtt_consumer]]
  servers = ["tcp://mosquitto:1883"]
  topics = [
    "farm/+/+/soil",
    "farm/+/+/weather",
    "farm/+/+/ph",
    "farm/+/+/par"
  ]
  username = "telegraf"
  password = "{{ TELEGRAF_MQTT_PASSWORD }}"
  qos = 1
  client_id = "telegraf_farm_consumer"
  data_format = "json"
  json_name_key = ""
  tag_keys = ["farm_id", "field_id", "sensor_id", "sensor_type"]

# Processore: aggiungi zone da sensor_id
[[processors.regex]]
  [[processors.regex.tags]]
    key = "sensor_id"
    pattern = "soil_0[1-3]"
    replacement = "zona_nord"
    result_key = "zone"

# Output: InfluxDB v2
[[outputs.influxdb_v2]]
  urls = ["http://influxdb:8086"]
  token = "{{ INFLUXDB_TOKEN }}"
  organization = "azienda_rossi"
  bucket = "farm_raw"
  timeout = "5s"

Flux Queries per Dashboard

Flux e il linguaggio di query di InfluxDB v2/v3. E più espressivo di InfluxQL e supporta join tra measurement, funzioni statistiche avanzate e downsampling dichiarativo.

// Query 1: Temperatura suolo ultima ora per tutti i sensori di un appezzamento
from(bucket: "farm_raw")
  |> range(start: -1h)
  |> filter(fn: (r) => r._measurement == "farm_sensors")
  |> filter(fn: (r) => r.farm_id == "az_rossi_chianti")
  |> filter(fn: (r) => r.field_id == "appezzamento_a1")
  |> filter(fn: (r) => r._field == "soil_temp_c")
  |> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
  |> yield(name: "soil_temp_5m_avg")

// Query 2: Stress idrico - soglia umidita sotto 30%
from(bucket: "farm_raw")
  |> range(start: -24h)
  |> filter(fn: (r) => r._measurement == "farm_sensors")
  |> filter(fn: (r) => r._field == "soil_moisture_pct")
  |> filter(fn: (r) => r._value < 30.0)
  |> group(columns: ["sensor_id", "field_id"])
  |> count()
  |> yield(name: "stress_idrico_events")

// Query 3: DLI giornaliero accumulato per ottimizzare esposizione
from(bucket: "farm_raw")
  |> range(start: today())
  |> filter(fn: (r) => r._measurement == "farm_sensors")
  |> filter(fn: (r) => r._field == "par_umol")
  |> filter(fn: (r) => r.farm_id == "az_rossi_chianti")
  |> aggregateWindow(every: 1d, fn: integral, unit: 1s, createEmpty: false)
  |> map(fn: (r) => ({ r with _value: r._value * 0.0000864 }))
  // Converti da umol/m2/s * secondi a mol/m2/d (DLI)
  |> yield(name: "dli_daily")

// Query 4: Downsampling per retention policy - dati orari
option task = {name: "downsample_to_hourly", every: 1h}

from(bucket: "farm_raw")
  |> range(start: -task.every)
  |> filter(fn: (r) => r._measurement == "farm_sensors")
  |> aggregateWindow(
    every: 1h,
    fn: (tables=<>, column) => tables
      |> mean(column: column),
    createEmpty: false
  )
  |> to(bucket: "farm_1h", org: "azienda_rossi")

// Query 5: Alert check - batteria sensori sotto 20%
from(bucket: "farm_raw")
  |> range(start: -5m)
  |> filter(fn: (r) => r._measurement == "farm_sensors")
  |> filter(fn: (r) => r._field == "battery_pct")
  |> last()
  |> filter(fn: (r) => r._value < 20.0)
  |> yield(name: "batteria_critica")

Retention Policies e Downsampling

# Struttura bucket InfluxDB per retention multi-tier:
#
# Bucket "farm_raw"     → retention 90 giorni (dati raw ogni 30s)
# Bucket "farm_1h"      → retention 2 anni (media oraria)
# Bucket "farm_1d"      → retention 10 anni (media giornaliera)
# Bucket "farm_alerts"  → retention 5 anni (eventi alert)
#
# Task Flux per downsampling automatico (eseguito da InfluxDB Tasks):

# influxdb-tasks/downsample-daily.flux
option task = {
  name: "downsample_farm_to_daily",
  every: 1d,
  offset: 30m
}

data = from(bucket: "farm_1h")
  |> range(start: -task.every)
  |> filter(fn: (r) => r._measurement == "farm_sensors")

data
  |> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
  |> set(key: "_measurement", value: "farm_sensors_daily")
  |> to(bucket: "farm_1d", org: "azienda_rossi")

Grafana: Setup e Configurazione per Farm IoT

Grafana e la piattaforma di visualizzazione e alerting più diffusa per ambienti IoT industriali. La versione 11.x (2025) ha introdotto miglioramenti significativi nell'alerting unificato, nei panels geographic (Geomap), e nel provisioning-as-code. Per un'installazione farm IoT self-hosted, la versione OSS e sufficiente: la versione Enterprise aggiunge SSO, auditing avanzato e plugin proprietari utili solo in ambienti enterprise multi-farm.

Provisioning Datasource via YAML

# grafana/provisioning/datasources/influxdb.yaml
apiVersion: 1

datasources:
  - name: InfluxDB-FarmRaw
    type: influxdb
    access: proxy
    url: http://influxdb:8086
    jsonData:
      version: Flux
      organization: azienda_rossi
      defaultBucket: farm_raw
      tlsSkipVerify: false
    secureJsonData:
      token: ${INFLUXDB_GRAFANA_TOKEN}
    isDefault: true
    editable: false

  - name: InfluxDB-Farm1h
    type: influxdb
    access: proxy
    url: http://influxdb:8086
    jsonData:
      version: Flux
      organization: azienda_rossi
      defaultBucket: farm_1h
    secureJsonData:
      token: ${INFLUXDB_GRAFANA_TOKEN}
    editable: false

Variabili Template Dashboard

Le variabili template in Grafana permettono di creare dashboard interattive dove l'utente seleziona farm, appezzamento e intervallo temporale dai dropdown in cima alla pagina.

# grafana/provisioning/dashboards/farm-overview.json (estratto variabili)
{
  "templating": {
    "list": [
      {
        "name": "farm_id",
        "type": "query",
        "label": "Azienda",
        "datasource": "InfluxDB-FarmRaw",
        "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"farm_raw\", tag: \"farm_id\")",
        "refresh": 2,
        "includeAll": false,
        "multi": false,
        "current": {}
      },
      {
        "name": "field_id",
        "type": "query",
        "label": "Appezzamento",
        "datasource": "InfluxDB-FarmRaw",
        "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"farm_raw\", tag: \"field_id\", predicate: (r) => r.farm_id == \"${farm_id}\")",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      },
      {
        "name": "sensor_id",
        "type": "query",
        "label": "Sensore",
        "datasource": "InfluxDB-FarmRaw",
        "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"farm_raw\", tag: \"sensor_id\", predicate: (r) => r.field_id == \"${field_id}\")",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      }
    ]
  }
}

Dashboard Panels per Agricoltura: Guida Completa

La scelta del tipo di panel giusto per ogni metrica e cruciale per la leggibilità della dashboard. Un panel di tipo "Time Series" per il livello della batteria e meno immediato di un "Gauge" o "Stat". Ecco la mappa dei panel ottimali per le metriche agricole:

Panel Types per Metriche Agricole

MetricaPanel TypeperchèSoglie Colore
Temperatura suolo/ariaTime Series + StatTrend temporale + valore attualeVerde <25, Arancione 25-30, Rosso >30
Umidita suoloGauge + Time SeriesGauge mostra % rispetto a FC e PWPRosso <25, Arancione 25-35, Verde 35-70
pH suoloGaugeValore puntuale, cambia lentamenteRosso <5.5 o >7.5, Verde 6.0-7.0
PAR/DLITime SeriesVariabilità diurna, pattern solareNessuna soglia, informativo
PrecipitazioniBar ChartDati discreti giornalieriNessuna soglia, informativo
Livello acquaGauge + StatPercentuale cisterna + litri disponibiliRosso <20%, Arancione 20-40%
Batteria sensoriTableVista tutti i sensori, filtra criticiRosso <20%, Arancione 20-30%
Mappa appezzamentiGeomapVisualizzazione geografica stato farmColore per stato aggregato

Configurazione Panel Umidita Suolo con Soglie

# Estratto JSON panel Gauge umidita suolo
{
  "id": 3,
  "title": "Umidita Suolo - ${sensor_id}",
  "type": "gauge",
  "datasource": "InfluxDB-FarmRaw",
  "targets": [
    {
      "refId": "A",
      "query": "from(bucket: \"farm_raw\")\n  |> range(start: -5m)\n  |> filter(fn: (r) => r._measurement == \"farm_sensors\")\n  |> filter(fn: (r) => r.farm_id == \"${farm_id}\")\n  |> filter(fn: (r) => r._field == \"soil_moisture_pct\")\n  |> last()\n  |> group(columns: [\"sensor_id\"])"
    }
  ],
  "fieldConfig": {
    "defaults": {
      "unit": "percent",
      "min": 0,
      "max": 100,
      "thresholds": {
        "mode": "absolute",
        "steps": [
          { "color": "red", "value": null },
          { "color": "red", "value": 0 },
          { "color": "orange", "value": 25 },
          { "color": "green", "value": 35 },
          { "color": "orange", "value": 70 },
          { "color": "red", "value": 85 }
        ]
      },
      "mappings": [],
      "custom": {
        "neutralColors": false
      }
    }
  },
  "options": {
    "reduceOptions": {
      "values": false,
      "calcs": ["lastNotNull"],
      "fields": ""
    },
    "showThresholdLabels": true,
    "showThresholdMarkers": true
  }
}

Panel Geomap per Visualizzazione Appezzamenti

# Panel Geomap - stato aggregato per appezzamento
{
  "id": 10,
  "title": "Mappa Farm - Stato Sensori",
  "type": "geomap",
  "datasource": "InfluxDB-FarmRaw",
  "targets": [
    {
      "refId": "A",
      "query": "// Query che restituisce lat/lon e stato aggregato\nfrom(bucket: \"farm_raw\")\n  |> range(start: -10m)\n  |> filter(fn: (r) => r._measurement == \"farm_sensors\")\n  |> filter(fn: (r) => r.farm_id == \"${farm_id}\")\n  |> filter(fn: (r) => r._field == \"soil_moisture_pct\")\n  |> last()\n  |> group(columns: [\"field_id\"])\n  |> mean()"
    }
  ],
  "options": {
    "view": {
      "id": "coords",
      "lat": 43.4,
      "lon": 11.1,
      "zoom": 13
    },
    "layers": [
      {
        "type": "markers",
        "config": {
          "size": { "fixed": 20 },
          "color": {
            "field": "soil_moisture_pct",
            "fixed": "green"
          },
          "fillOpacity": 0.8,
          "symbol": { "fixed": "circle" }
        }
      }
    ]
  }
}

Alerting Grafana per Farm IoT: Configurazione Completa

Il sistema di alerting e il componente più critico per la value proposition della dashboard. Un alert in ritardo di 2 ore per stress idrico può valere migliaia di euro di danni alle colture. Grafana Unified Alerting (introdotto in Grafana 9, consolidato in v10/v11) fornisce un sistema completo con alert rules, contact points e notification policies configurabili interamente via YAML (Infrastructure as Code).

Contact Points: Email, Telegram e Slack

# grafana/provisioning/alerting/contact-points.yaml
apiVersion: 1

contactPoints:
  # Contact point email per il responsabile tecnico
  - orgId: 1
    name: email-agronomo
    receivers:
      - uid: email_agronomo_01
        type: email
        settings:
          addresses: "agronomo@aziendarossi.it;tecnico@aziendarossi.it"
          singleEmail: false
          message: |
            ALERT FARM: {{ .GroupLabels.alertname }}
            Appezzamento: {{ .CommonLabels.field_id }}
            Valore: {{ .CommonAnnotations.value }}
            Ora: {{ .CommonAnnotations.timestamp }}
        disableResolveMessage: false

  # Telegram Bot per notifiche immediate in campo
  - orgId: 1
    name: telegram-farm
    receivers:
      - uid: telegram_farm_01
        type: telegram
        settings:
          bottoken: ${TELEGRAM_BOT_TOKEN}
          chatid: ${TELEGRAM_CHAT_ID}
          message: |
            ALLERTA FARM {{ .CommonLabels.farm_id }}
            {{ .CommonAnnotations.description }}
            Appezzamento: {{ .CommonLabels.field_id }}
            Sensore: {{ .CommonLabels.sensor_id }}
            Valore attuale: {{ .CommonAnnotations.current_value }}
            Soglia: {{ .CommonAnnotations.threshold }}

  # Slack per team operations
  - orgId: 1
    name: slack-farmops
    receivers:
      - uid: slack_farmops_01
        type: slack
        settings:
          url: ${SLACK_WEBHOOK_URL}
          channel: "#farm-alerts"
          title: "FARM ALERT - {{ .GroupLabels.alertname }}"
          text: |
            *Farm:* {{ .CommonLabels.farm_id }}
            *Appezzamento:* {{ .CommonLabels.field_id }}
            *Problema:* {{ .CommonAnnotations.description }}
            *Valore:* {{ .CommonAnnotations.current_value }}

Alert Rules per Condizioni Agricole Critiche

# grafana/provisioning/alerting/alert-rules.yaml
apiVersion: 1

groups:
  - orgId: 1
    name: farm-critical-alerts
    folder: Farm IoT
    interval: 1m
    rules:
      # Regola 1: Stress idrico - umidita suolo sotto soglia critica
      - uid: alert_stress_idrico
        title: "Stress Idrico Rilevato"
        condition: C
        data:
          - refId: A
            relativeTimeRange:
              from: 300  # ultimi 5 minuti
              to: 0
            datasourceUid: influxdb-farmraw
            model:
              query: |
                from(bucket: "farm_raw")
                  |> range(start: -5m)
                  |> filter(fn: (r) => r._measurement == "farm_sensors")
                  |> filter(fn: (r) => r._field == "soil_moisture_pct")
                  |> last()
          - refId: B
            datasourceUid: __expr__
            model:
              type: reduce
              expression: A
              reducer: mean
          - refId: C
            datasourceUid: __expr__
            model:
              type: threshold
              expression: B
              conditions:
                - evaluator:
                    type: lt
                    params: [28.0]
        for: 10m  # deve persistere 10 minuti prima di scattare
        labels:
          severity: critical
          category: irrigation
          farm_alert: "true"
        annotations:
          summary: "Stress idrico in ${{ $labels.field_id }}"
          description: "Umidita suolo sotto soglia critica (28%). Attivare irrigazione."
          current_value: "${{ $values.B }}%"
          threshold: "28%"

      # Regola 2: Rischio gelo - temperatura aria sotto 2 gradi Celsius
      - uid: alert_rischio_gelo
        title: "Rischio Gelo"
        condition: C
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: influxdb-farmraw
            model:
              query: |
                from(bucket: "farm_raw")
                  |> range(start: -10m)
                  |> filter(fn: (r) => r._measurement == "farm_sensors")
                  |> filter(fn: (r) => r._field == "air_temp_c")
                  |> last()
          - refId: B
            datasourceUid: __expr__
            model:
              type: reduce
              expression: A
              reducer: last
          - refId: C
            datasourceUid: __expr__
            model:
              type: threshold
              expression: B
              conditions:
                - evaluator:
                    type: lt
                    params: [2.0]
        for: 5m
        labels:
          severity: critical
          category: frost
        annotations:
          summary: "Rischio gelo in ${{ $labels.field_id }}"
          description: "Temperatura aria sotto 2 gradi. Attivare antigelo se disponibile."

      # Regola 3: Batteria sensore critica
      - uid: alert_batteria_bassa
        title: "Batteria Sensore Critica"
        condition: C
        data:
          - refId: A
            relativeTimeRange:
              from: 300
              to: 0
            datasourceUid: influxdb-farmraw
            model:
              query: |
                from(bucket: "farm_raw")
                  |> range(start: -5m)
                  |> filter(fn: (r) => r._measurement == "farm_sensors")
                  |> filter(fn: (r) => r._field == "battery_pct")
                  |> last()
          - refId: B
            datasourceUid: __expr__
            model:
              type: reduce
              expression: A
              reducer: last
          - refId: C
            datasourceUid: __expr__
            model:
              type: threshold
              expression: B
              conditions:
                - evaluator:
                    type: lt
                    params: [15.0]
        for: 0s
        labels:
          severity: warning
          category: maintenance
        annotations:
          summary: "Batteria bassa sensore ${{ $labels.sensor_id }}"
          description: "Batteria sotto 15%. Pianificare sostituzione entro 48 ore."

Notification Policies e Silencing

# grafana/provisioning/alerting/notification-policies.yaml
apiVersion: 1

policies:
  - orgId: 1
    receiver: email-agronomo  # receiver di default
    group_by: ["farm_id", "field_id", "alertname"]
    group_wait: 30s       # attendi 30s prima del primo invio
    group_interval: 5m    # attendi 5m prima di re-inviare per gruppo
    repeat_interval: 4h   # ripeti notifica ogni 4 ore se alert attivo
    routes:
      # Priorità alta: gelo e stress idrico critico → Telegram immediato
      - receiver: telegram-farm
        matchers:
          - name: severity
            value: critical
            isEqual: true
        group_wait: 0s      # invio immediato
        repeat_interval: 1h

      # Priorità media: manutenzione batteria → solo email
      - receiver: email-agronomo
        matchers:
          - name: category
            value: maintenance
            isEqual: true
        repeat_interval: 24h

      # Team operations: tutti gli alert su Slack
      - receiver: slack-farmops
        matchers:
          - name: farm_alert
            value: "true"
            isEqual: true
        group_interval: 10m
        repeat_interval: 12h

Angular Dashboard Custom: Quando e perchè

Grafana copre il 90% dei casi d'uso di monitoring operativo. Ma ci sono scenari in cui una dashboard Angular custom e la scelta giusta:

Grafana vs Angular Dashboard: Quando Scegliere Cosa

CriterioGrafana PuroAngular Custom
Utente targetTecnici, data analystsAgronomi, manager, operatori campo
UX personalizzataLimitata ai panel typesIllimitata, mobile-first
Logica businessNon adattoAlert calcolati, raccomandazioni AI
Integrazione app esistentiDifficile (iframe)Nativa Angular
PWA offline-firstNon supportatoService Worker nativo
Refresh rateMin 1s (polling)WebSocket sub-secondo
Costo sviluppoBasso (configurazione)Alto (sviluppo custom)
ManutenzioneBassaAlta

La strategia raccomandata per farm IoT enterprise e ibrida: Grafana per il monitoring tecnico interno (IT, operazioni, agronomia avanzata) e Angular per l'app mobile che usano gli operatori in campo. Le due piattaforme condividono la stessa fonte dati (InfluxDB) e Angular può anche embeddare panel Grafana specifici via iframe signed con token per analytics complessi che non vale la pena replicare.

Implementazione Angular: Servizio IoT e Dashboard Real-Time

Modelli TypeScript per Dati Sensori

// src/app/models/farm-sensor.model.ts

export interface SensorReading {
  sensorId: string;
  farmId: string;
  fieldId: string;
  sensorType: 'soil' | 'weather' | 'ph' | 'par' | 'water';
  timestamp: Date;
  values: SensorValues;
  batteryPct: number;
  rssi?: number;
}

export interface SensorValues {
  soilTempC?: number;
  soilMoisturePct?: number;
  soilEcDs?: number;
  soilPh?: number;
  airTempC?: number;
  airHumidityPct?: number;
  rainfallMm?: number;
  windSpeedMs?: number;
  parUmol?: number;
  dliMol?: number;
  waterLevelCm?: number;
}

export interface AlertEvent {
  id: string;
  sensorId: string;
  fieldId: string;
  alertType: 'stress_idrico' | 'gelo' | 'batteria' | 'ph_anomalo' | 'pioggia_intensa';
  severity: 'critical' | 'warning' | 'info';
  message: string;
  value: number;
  threshold: number;
  timestamp: Date;
  acknowledged: boolean;
}

export interface FieldStatus {
  fieldId: string;
  name: string;
  lat: number;
  lon: number;
  area_ha: number;
  crop: string;
  sensors: SensorReading[];
  overallStatus: 'ok' | 'warning' | 'critical';
  activeAlerts: AlertEvent[];
}

Servizio IoT con WebSocket e Angular Signals

// src/app/services/farm-iot.service.ts
import { Injectable, signal, computed, effect, DestroyRef, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { Observable, retry, timer, catchError, EMPTY, switchMap } from 'rxjs';
import { SensorReading, AlertEvent, FieldStatus } from '../models/farm-sensor.model';

@Injectable({ providedIn: 'root' })
export class FarmIotService {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  private wsSubject: WebSocketSubject<SensorReading> | null = null;

  // === Angular Signals per stato real-time ===
  readonly latestReadings = signal<Map<string, SensorReading>>(new Map());
  readonly activeAlerts = signal<AlertEvent[]>([]);
  readonly fieldStatuses = signal<FieldStatus[]>([]);
  readonly isConnected = signal<boolean>(false);
  readonly lastUpdateAt = signal<Date | null>(null);

  // === Computed signals derivati ===
  readonly criticalAlerts = computed(() =>
    this.activeAlerts().filter(a => a.severity === 'critical' && !a.acknowledged)
  );

  readonly sensorsWithLowBattery = computed(() =>
    Array.from(this.latestReadings().values())
      .filter(r => r.batteryPct < 20)
      .sort((a, b) => a.batteryPct - b.batteryPct)
  );

  readonly avgSoilMoisture = computed(() => {
    const readings = Array.from(this.latestReadings().values())
      .filter(r => r.sensorType === 'soil' && r.values.soilMoisturePct !== undefined);
    if (readings.length === 0) return null;
    const sum = readings.reduce((acc, r) => acc + (r.values.soilMoisturePct ?? 0), 0);
    return sum / readings.length;
  });

  constructor() {
    // Effect: log quando cambiano gli alert critici
    effect(() => {
      const criticals = this.criticalAlerts();
      if (criticals.length > 0) {
        console.warn(`[FarmIoT] ${criticals.length} alert critici attivi`);
      }
    });
  }

  // Connessione WebSocket al backend farm
  connectWebSocket(farmId: string, wsUrl: string): void {
    if (this.wsSubject) {
      this.wsSubject.complete();
    }

    this.wsSubject = webSocket<SensorReading>({
      url: `${wsUrl}/farm/${farmId}/stream`,
      openObserver: {
        next: () => {
          this.isConnected.set(true);
          console.log('[FarmIoT] WebSocket connesso');
        }
      },
      closeObserver: {
        next: () => {
          this.isConnected.set(false);
          console.log('[FarmIoT] WebSocket disconnesso');
        }
      }
    });

    this.wsSubject.pipe(
      retry({
        count: 5,
        delay: (error, retryCount) => timer(Math.min(retryCount * 2000, 30000))
      }),
      catchError(err => {
        console.error('[FarmIoT] WebSocket errore irreversibile:', err);
        this.isConnected.set(false);
        return EMPTY;
      }),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(reading => {
      this.processReading(reading);
    });
  }

  private processReading(reading: SensorReading): void {
    // Aggiorna la mappa dei latest readings (immutable update)
    this.latestReadings.update(map => {
      const newMap = new Map(map);
      newMap.set(reading.sensorId, { ...reading, timestamp: new Date(reading.timestamp) });
      return newMap;
    });

    this.lastUpdateAt.set(new Date());
    this.checkAlertConditions(reading);
  }

  private checkAlertConditions(reading: SensorReading): void {
    const newAlerts: AlertEvent[] = [];

    // Check stress idrico
    if (reading.sensorType === 'soil' &&
        reading.values.soilMoisturePct !== undefined &&
        reading.values.soilMoisturePct < 28) {
      newAlerts.push({
        id: crypto.randomUUID(),
        sensorId: reading.sensorId,
        fieldId: reading.fieldId,
        alertType: 'stress_idrico',
        severity: reading.values.soilMoisturePct < 20 ? 'critical' : 'warning',
        message: `Umidita suolo al ${reading.values.soilMoisturePct.toFixed(1)}% (soglia: 28%)`,
        value: reading.values.soilMoisturePct,
        threshold: 28,
        timestamp: new Date(),
        acknowledged: false
      });
    }

    // Check rischio gelo
    if (reading.sensorType === 'weather' &&
        reading.values.airTempC !== undefined &&
        reading.values.airTempC < 2) {
      newAlerts.push({
        id: crypto.randomUUID(),
        sensorId: reading.sensorId,
        fieldId: reading.fieldId,
        alertType: 'gelo',
        severity: 'critical',
        message: `Temperatura aria a ${reading.values.airTempC.toFixed(1)} gradi (rischio gelo)`,
        value: reading.values.airTempC,
        threshold: 2,
        timestamp: new Date(),
        acknowledged: false
      });
    }

    if (newAlerts.length > 0) {
      this.activeAlerts.update(alerts => [...alerts, ...newAlerts]);
    }
  }

  acknowledgeAlert(alertId: string): void {
    this.activeAlerts.update(alerts =>
      alerts.map(a => a.id === alertId ? { ...a, acknowledged: true } : a)
    );
  }

  // Carica storico da API REST InfluxDB
  getHistoricalData(
    farmId: string,
    fieldId: string,
    field: string,
    from: string = '-1h'
  ): Observable<Array<{ time: string; value: number }>> {
    return this.http.get<Array<{ time: string; value: number }>>(
      `/api/farm/${farmId}/history`,
      { params: { fieldId, field, from } }
    );
  }
}

Componente Dashboard Principale

// src/app/pages/farm-dashboard/farm-dashboard.component.ts
import {
  Component, OnInit, inject, signal, computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgxChartsModule, Color, ScaleType } from '@swimlane/ngx-charts';
import { FarmIotService } from '../../services/farm-iot.service';
import { AlertPanelComponent } from '../../components/alert-panel/alert-panel.component';
import { SensorGaugeComponent } from '../../components/sensor-gauge/sensor-gauge.component';

interface ChartDataPoint {
  name: Date;
  value: number;
}

interface ChartSeries {
  name: string;
  series: ChartDataPoint[];
}

@Component({
  selector: 'app-farm-dashboard',
  standalone: true,
  imports: [CommonModule, NgxChartsModule, AlertPanelComponent, SensorGaugeComponent],
  templateUrl: './farm-dashboard.component.html',
  styleUrl: './farm-dashboard.component.css'
})
export class FarmDashboardComponent implements OnInit {
  readonly farmIot = inject(FarmIotService);

  // Config dashboard
  readonly farmId = signal('az_rossi_chianti');
  readonly selectedField = signal('appezzamento_a1');

  // Dati grafici time-series per ngx-charts
  readonly temperatureData = signal<ChartSeries[]>([]);
  readonly moistureData = signal<ChartSeries[]>([]);

  // Computed per statistiche aggregate
  readonly fieldSummary = computed(() => {
    const readings = Array.from(this.farmIot.latestReadings().values())
      .filter(r => r.fieldId === this.selectedField());

    return {
      totalSensors: readings.length,
      avgMoisture: readings
        .filter(r => r.values.soilMoisturePct !== undefined)
        .reduce((sum, r, _, arr) => sum + (r.values.soilMoisturePct ?? 0) / arr.length, 0),
      avgTempSuolo: readings
        .filter(r => r.values.soilTempC !== undefined)
        .reduce((sum, r, _, arr) => sum + (r.values.soilTempC ?? 0) / arr.length, 0),
      criticalCount: readings.filter(r => r.batteryPct < 20).length
    };
  });

  // Colori ngx-charts
  readonly colorScheme: Color = {
    name: 'farm',
    selectable: true,
    group: ScaleType.Ordinal,
    domain: ['#4CAF50', '#FF9800', '#F44336', '#2196F3', '#9C27B0']
  };

  ngOnInit(): void {
    // Connetti WebSocket
    const wsUrl = 'wss://farm-api.aziendarossi.it';
    this.farmIot.connectWebSocket(this.farmId(), wsUrl);

    // Carica dati storici per i grafici
    this.loadHistoricalCharts();
  }

  private loadHistoricalCharts(): void {
    // In un'implementazione reale qui caricheremo da API
    // Per ora simuliamo dati di esempio strutturati per ngx-charts
    const now = new Date();
    const generateSeries = (baseValue: number, variance: number): ChartDataPoint[] =>
      Array.from({ length: 60 }, (_, i) => ({
        name: new Date(now.getTime() - (59 - i) * 60000),
        value: baseValue + (Math.random() - 0.5) * variance
      }));

    this.temperatureData.set([
      { name: 'Suolo A1', series: generateSeries(22, 4) },
      { name: 'Suolo A2', series: generateSeries(24, 3) },
      { name: 'Aria', series: generateSeries(18, 6) }
    ]);

    this.moistureData.set([
      { name: 'Umidita A1-Ovest', series: generateSeries(42, 8) },
      { name: 'Umidita A1-Est', series: generateSeries(38, 10) }
    ]);
  }
}

Template HTML Dashboard con ngx-charts

<!-- farm-dashboard.component.html -->
<div class="farm-dashboard">

  <!-- Header con stato connessione -->
  <header class="dashboard-header">
    <div class="header-left">
      <h1>Farm Intelligence</h1>
      <span class="farm-name">Azienda Rossi - Chianti</span>
    </div>
    <div class="header-right">
      <div class="connection-status" [class.connected]="farmIot.isConnected()">
        <span class="status-dot"></span>
        {{ farmIot.isConnected() ? 'Live' : 'Offline' }}
      </div>
      <span class="last-update">
        Aggiornato: {{ farmIot.lastUpdateAt() | date:'HH:mm:ss' }}
      </span>
    </div>
  </header>

  <!-- Alert Panel: alert critici in evidenza -->
  @if (farmIot.criticalAlerts().length > 0) {
    <section class="alerts-critical">
      <div class="alert-banner">
        <span class="alert-icon">ALLERTA</span>
        <strong>{{ farmIot.criticalAlerts().length }} alert critici attivi</strong>
      </div>
      @for (alert of farmIot.criticalAlerts(); track alert.id) {
        <div class="alert-card critical">
          <div class="alert-info">
            <span class="alert-type">{{ alert.alertType }}</span>
            <span class="alert-sensor">Sensore: {{ alert.sensorId }}</span>
            <p class="alert-msg">{{ alert.message }}</p>
          </div>
          <button
            class="btn-acknowledge"
            (click)="farmIot.acknowledgeAlert(alert.id)">
            Confermato
          </button>
        </div>
      }
    </section>
  }

  <!-- KPI Summary Cards -->
  <section class="kpi-grid">
    <div class="kpi-card">
      <span class="kpi-label">Sensori Attivi</span>
      <span class="kpi-value">{{ fieldSummary().totalSensors }}</span>
    </div>
    <div class="kpi-card" [class.warning]="fieldSummary().avgMoisture < 30">
      <span class="kpi-label">Umidita Media Suolo</span>
      <span class="kpi-value">{{ fieldSummary().avgMoisture | number:'1.1-1' }}%</span>
    </div>
    <div class="kpi-card">
      <span class="kpi-label">Temp. Media Suolo</span>
      <span class="kpi-value">{{ fieldSummary().avgTempSuolo | number:'1.1-1' }} C</span>
    </div>
    <div class="kpi-card" [class.critical]="fieldSummary().criticalCount > 0">
      <span class="kpi-label">Sensori Batteria Critica</span>
      <span class="kpi-value">{{ fieldSummary().criticalCount }}</span>
    </div>
  </section>

  <!-- Grafici Time-Series -->
  <section class="charts-grid">
    <div class="chart-container">
      <h3>Temperatura Suolo e Aria (ultima ora)</h3>
      <ngx-charts-line-chart
        [results]="temperatureData()"
        [scheme]="colorScheme"
        [xAxis]="true"
        [yAxis]="true"
        [legend]="true"
        [showXAxisLabel]="true"
        xAxisLabel="Ora"
        [showYAxisLabel]="true"
        yAxisLabel="Temperatura (C)"
        [timeline]="false"
        [autoScale]="true"
        [roundDomains]="true">
      </ngx-charts-line-chart>
    </div>

    <div class="chart-container">
      <h3>Umidita Suolo (ultima ora)</h3>
      <ngx-charts-area-chart
        [results]="moistureData()"
        [scheme]="colorScheme"
        [xAxis]="true"
        [yAxis]="true"
        [legend]="true"
        [showYAxisLabel]="true"
        yAxisLabel="Umidita (%)"
        [autoScale]="false"
        [yScaleMin]="0"
        [yScaleMax]="100">
      </ngx-charts-area-chart>
    </div>
  </section>

  <!-- Tabella Sensori con Batteria -->
  <section class="sensors-table">
    <h3>Stato Sensori - Appezzamento {{ selectedField() }}</h3>
    <table>
      <thead>
        <tr>
          <th>Sensore</th>
          <th>Tipo</th>
          <th>Umidita</th>
          <th>Temp. Suolo</th>
          <th>Batteria</th>
          <th>Ultimo Update</th>
        </tr>
      </thead>
      <tbody>
        @for (reading of farmIot.latestReadings() | keyvalue; track reading.key) {
          <tr [class.battery-low]="reading.value.batteryPct < 20">
            <td>{{ reading.value.sensorId }}</td>
            <td>{{ reading.value.sensorType }}</td>
            <td>{{ reading.value.values.soilMoisturePct | number:'1.1-1' }}%</td>
            <td>{{ reading.value.values.soilTempC | number:'1.1-1' }} C</td>
            <td>
              <div class="battery-bar">
                <div
                  class="battery-fill"
                  [style.width.%]="reading.value.batteryPct"
                  [class.low]="reading.value.batteryPct < 20">
                </div>
              </div>
              {{ reading.value.batteryPct | number:'1.0-0' }}%
            </td>
            <td>{{ reading.value.timestamp | date:'HH:mm:ss' }}</td>
          </tr>
        }
      </tbody>
    </table>
  </section>
</div>

Mobile-First Design e PWA per Operatori in Campo

Gli operatori in campo utilizzano smartphone, spesso con connessione instabile e in condizioni di luce solare intensa che rende difficile la lettura di schermi con sfondo bianco. Il design mobile-first per farm dashboard richiede considerazioni specifiche rispetto alle dashboard enterprise tradizionali.

/* farm-dashboard.component.css - Mobile-First Responsive */

/* Base: Mobile (320px-767px) */
.farm-dashboard {
  padding: 8px;
  background: #0d1117;
  min-height: 100vh;
  color: #e6edf3;
}

.dashboard-header {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 16px;
  padding: 12px;
  background: #161b22;
  border-radius: 8px;
}

.kpi-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin-bottom: 16px;
}

.kpi-card {
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 8px;
  padding: 12px;
  text-align: center;
}

.kpi-card.warning {
  border-color: #FF9800;
  background: rgba(255, 152, 0, 0.1);
}

.kpi-card.critical {
  border-color: #F44336;
  background: rgba(244, 67, 54, 0.1);
}

.kpi-value {
  display: block;
  font-size: 1.8em;
  font-weight: 700;
  font-family: 'JetBrains Mono', monospace;
  color: #58a6ff;
}

.charts-grid {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin-bottom: 16px;
}

.chart-container {
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 8px;
  padding: 16px;
  overflow: hidden;
}

/* Tablet (768px+) */
@media (min-width: 768px) {
  .farm-dashboard { padding: 16px; }

  .dashboard-header {
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
  }

  .kpi-grid {
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
  }

  .charts-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
  }
}

/* Desktop (1024px+) */
@media (min-width: 1024px) {
  .farm-dashboard {
    max-width: 1400px;
    margin: 0 auto;
    padding: 24px;
  }

  .kpi-value { font-size: 2.2em; }
}

/* Alta luminosita outdoor - contrasto aumentato */
@media (prefers-contrast: high) {
  .kpi-card { border-color: #58a6ff; }
  .kpi-value { color: #ffffff; }
}

Service Worker PWA per Offline-First

// ngsw-config.json - Configurazione PWA offline-first
{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
      }
    }
  ],
  "dataGroups": [
    {
      "name": "farm-api-recent",
      "urls": ["/api/farm/*/history*"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 100,
        "maxAge": "15m",
        "timeout": "3s"
      }
    },
    {
      "name": "farm-api-static",
      "urls": ["/api/farm/*/fields", "/api/farm/*/sensors"],
      "cacheConfig": {
        "strategy": "performance",
        "maxSize": 20,
        "maxAge": "1h"
      }
    }
  ]
}

Docker Compose Stack Completo: Deploy Pronto alla Produzione

L'intero stack (Mosquitto, InfluxDB, Telegraf, Grafana, Angular app) può essere deployato con un singolo docker compose up -d. Questo e il punto di partenza per qualsiasi deployment, sia on-premise che su VPS cloud.

# docker-compose.yml - Farm IoT Stack completo
version: "3.9"

services:
  # === MQTT Broker ===
  mosquitto:
    image: eclipse-mosquitto:2.0.18
    container_name: farm-mosquitto
    restart: unless-stopped
    ports:
      - "1883:1883"
      - "8883:8883"  # MQTT over TLS
      - "9001:9001"  # WebSocket
    volumes:
      - ./mosquitto/config:/mosquitto/config:ro
      - ./mosquitto/data:/mosquitto/data
      - ./mosquitto/log:/mosquitto/log
      - ./certs:/mosquitto/certs:ro
    networks:
      - farm-net

  # === InfluxDB Time-Series Database ===
  influxdb:
    image: influxdb:2.7-alpine
    container_name: farm-influxdb
    restart: unless-stopped
    ports:
      - "8086:8086"
    environment:
      DOCKER_INFLUXDB_INIT_MODE: setup
      DOCKER_INFLUXDB_INIT_USERNAME: admin
      DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUXDB_ADMIN_PASSWORD}
      DOCKER_INFLUXDB_INIT_ORG: azienda_rossi
      DOCKER_INFLUXDB_INIT_BUCKET: farm_raw
      DOCKER_INFLUXDB_INIT_RETENTION: 90d
      DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUXDB_ADMIN_TOKEN}
    volumes:
      - influxdb_data:/var/lib/influxdb2
      - ./influxdb/config:/etc/influxdb2:ro
    networks:
      - farm-net
    healthcheck:
      test: ["CMD", "influx", "ping"]
      interval: 30s
      timeout: 10s
      retries: 3

  # === Telegraf: Bridge MQTT -> InfluxDB ===
  telegraf:
    image: telegraf:1.30-alpine
    container_name: farm-telegraf
    restart: unless-stopped
    depends_on:
      influxdb:
        condition: service_healthy
    environment:
      TELEGRAF_MQTT_PASSWORD: ${TELEGRAF_MQTT_PASSWORD}
      INFLUXDB_TOKEN: ${INFLUXDB_TELEGRAF_TOKEN}
    volumes:
      - ./telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro
    networks:
      - farm-net

  # === Grafana: Visualizzazione e Alerting ===
  grafana:
    image: grafana/grafana-oss:11.5.0
    container_name: farm-grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    depends_on:
      - influxdb
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
      GF_SECURITY_SECRET_KEY: ${GRAFANA_SECRET_KEY}
      GF_SERVER_ROOT_URL: https://grafana.aziendarossi.it
      GF_SMTP_ENABLED: "true"
      GF_SMTP_HOST: smtp.gmail.com:587
      GF_SMTP_USER: ${SMTP_USER}
      GF_SMTP_PASSWORD: ${SMTP_PASSWORD}
      INFLUXDB_GRAFANA_TOKEN: ${INFLUXDB_GRAFANA_TOKEN}
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
      TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
      SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    networks:
      - farm-net

  # === Angular App: Frontend Mobile-First ===
  farm-app:
    image: nginx:1.27-alpine
    container_name: farm-angular-app
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./dist/farm-app/browser:/usr/share/nginx/html:ro
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - grafana
    networks:
      - farm-net

  # === Backup automatico InfluxDB ===
  influxdb-backup:
    image: influxdb:2.7-alpine
    container_name: farm-backup
    restart: "no"
    depends_on:
      - influxdb
    environment:
      INFLUXDB_TOKEN: ${INFLUXDB_ADMIN_TOKEN}
    volumes:
      - ./backups:/backups
      - ./scripts/backup.sh:/backup.sh:ro
    entrypoint: ["/bin/sh", "/backup.sh"]
    networks:
      - farm-net

volumes:
  influxdb_data:
    driver: local
  grafana_data:
    driver: local

networks:
  farm-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
# .env.example - Variabili d'ambiente (NON committare mai il .env reale!)
INFLUXDB_ADMIN_PASSWORD=ChangeMe_Strong_Password_2025!
INFLUXDB_ADMIN_TOKEN=my-super-secret-admin-token-change-this
INFLUXDB_TELEGRAF_TOKEN=telegraf-write-only-token-change-this
INFLUXDB_GRAFANA_TOKEN=grafana-read-only-token-change-this
GRAFANA_ADMIN_PASSWORD=ChangeMe_Grafana_2025!
GRAFANA_SECRET_KEY=random-32-char-secret-key-here-xx
TELEGRAF_MQTT_PASSWORD=telegraf-mqtt-password
SMTP_USER=notifiche@aziendarossi.it
SMTP_PASSWORD=app-specific-password
TELEGRAM_BOT_TOKEN=123456:ABC-DEF-your-bot-token
TELEGRAM_CHAT_ID=-1001234567890

Performance e Scalabilità: Query Optimization e Caching

Con 50-200 sensori che inviano dati ogni 30 secondi, la mole di dati in InfluxDB cresce rapidamente: circa 5-20 milioni di punti al giorno. Senza strategie di ottimizzazione, le query della dashboard rallentano e l'esperienza utente degrada. Ecco le tecniche fondamentali per mantenere tempi di risposta sotto i 500ms anche su hardware modesto.

Strategie di Ottimizzazione per Dashboard Farm IoT

TecnicaImplementazioneImpatto
Downsampling multi-tierTask Flux ogni 1h, 1dQuery storiche 10-50x più veloci
Aggregation push-downaggregateWindow in Flux, non in-memoryRiduzione dati trasferiti 90%+
Dashboard query cachingGrafana cache layer + Redis-70% query ridondanti
Materialized viewsBucket "farm_kpi" con pre-aggregazioniDashboard load <100ms
WebSocket invece di pollingAngular WebSocket vs HTTP polling-80% carico server frontend
Progressive loadingCarica KPI prima, grafici dopoPerceived performance +60%
Tag cardinality controlMax 1000 serie per measurementPreviene degradazione DB
// Configurazione Grafana per ridurre query ridondanti
// grafana/provisioning/grafana.ini

[caching]
enabled = true
ttl = 60s
max_value_mb = 512

[database]
cache_mode = shared

// Query ottimizzata con aggregazione lato server:
// MALE - trasferisce tutti i punti raw, aggrega in Grafana
from(bucket: "farm_raw")
  |> range(start: -24h)
  |> filter(fn: (r) => r._field == "soil_moisture_pct")

// BENE - aggrega in InfluxDB, trasferisce solo i punti necessari
from(bucket: "farm_raw")
  |> range(start: -24h)
  |> filter(fn: (r) => r._field == "soil_moisture_pct")
  |> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
  |> keep(columns: ["_time", "_value", "sensor_id", "field_id"])

Case Study: Azienda Vitivinicola Rossi - 50 Sensori, 6 Appezzamenti

L'Azienda Agricola Rossi produce Chianti Classico DOCG su 35 ettari distribuiti in 6 appezzamenti con caratteristiche pedologiche diverse. Prima dell'implementazione del sistema IoT, il monitoraggio era basato su visite settimanali dell'agronomo e misurazioni manuali. Le perdite medie per stagione da irrigazione non ottimale e gelo tardivo erano stimate intorno al 18% della resa potenziale.

Specifiche Deployment Azienda Rossi

ComponenteDettaglio
Sensori totali50 (8-9 per appezzamento): suolo, meteo, pH, PAR
Gateway edge6 Raspberry Pi 4 (uno per appezzamento), connessione 4G
Protocollo wireless sensoriLoRaWAN (suolo/pH), Zigbee (meteo locali), RS-485 (cisterne)
Broker MQTTEclipse Mosquitto, VPS OVH Europa, TLS 1.3
DatabaseInfluxDB v2.7, VPS 4 vCPU / 8GB RAM, SSD 200GB
Refresh rate dashboard30 secondi dati suolo, 5 secondi allerte critiche
Volume dati~3.5 milioni punti/giorno, ~1.2 GB/mese raw
Dashboard Grafana4 dashboard: overview farm, per-appezzamento, sensori, alert history
App Angular mobilePWA, usata dagli operatori in campo su Android
AlertingTelegram (critico immediato) + email giornaliera digest
Hardware totale6 gateway + 50 sensori + VPS cloud
Costo infrastruttura/anno~4.200 EUR (VPS + banda + manutenzione)

Risultati Misurati dopo 2 Stagioni

Metriche di Risparmio e ROI - Azienda Rossi (stagioni 2024 e 2025)

MetricaPre-IoT (media 2022-2023)Post-IoT (2024-2025)Delta
Consumo acqua irrigazione~2.800 m3/ettaro/stagione~1.750 m3/ettaro/stagione-37.5%
Perdite da stress idrico~18% resa potenziale~4% resa potenziale-78% perdite
Perdite da gelo tardivoVariabile, 0-15% in anni critici0% (7 interventi antigelo tempestivi)Eliminato
Costi fertilizzanti/haBaseline 100%73% del baseline-27%
Ore/settimana monitoraggio12-15 ore agronomo2-3 ore (solo supervisione)-82%
Resa media vigneto7.2 ton/ha8.6 ton/ha+19.4%
Valore produzione extra-+€ 42.000/stagioneROI payback <18 mesi

Il dato più significativo non e il risparmio idrico (pur rilevante), ma l'eliminazione completa delle perdite da gelo tardivo. Nella stagione 2024, il sistema ha rilevato tre eventi di discesa termica sotto i 2 gradi tra marzo e aprile, attivando automaticamente notifiche Telegram alle 02:15, alle 03:40 e alle 01:20. In tutti e tre i casi, i dipendenti che vivono nell'azienda hanno attivato i sistemi antifrost (aspersori sovrachioma) entro 20 minuti. Il danno da gelo stimato in assenza di intervento sarebbe stato di circa 15.000 EUR solo per l'evento di marzo.

Lezioni Apprese dall'Implementazione

  • Calibrazione sensori suolo: I sensori capacitivi di umidita sono sensibili alla composizione del suolo. E necessaria una calibrazione in situ per ogni tipo di terreno, non solo l'installazione plug-and-play.
  • Connettivita 4G come bottleneck: In zone collinari, la copertura 4G può essere intermittente. Il buffer locale sui gateway (SQLite, 7 giorni) si e rivelato indispensabile per evitare perdita di dati.
  • Alert fatigue: La prima configurazione aveva troppe soglie attive. In due settimane gli operatori iniziavano a ignorare le notifiche. E fondamentale partire con pochi alert critici e aggiungerne gradualmente.
  • Formazione operatori: L'adozione dell'app Angular mobile ha richiesto 3 sessioni di formazione da 2 ore. L'investimento in formazione e tanto importante quanto quello tecnologico.
  • Manutenzione sensori: I sensori suolo in ambiente viticolo richiedono pulizia trimestrale (residui trattamenti). Pianificare round di manutenzione preventiva.

Sicurezza del Sistema IoT Farm: Best Practice

Un sistema IoT agricolo connesso a internet e un vettore di attacco se non protetto adeguatamente. La superficie di attacco include: broker MQTT esposto, API InfluxDB, interfaccia Grafana, app Angular. Le seguenti misure sono il minimo necessario per un deployment in produzione.

Checklist Sicurezza IoT Farm

AreaMisuraPriorità
MQTT BrokerTLS 1.3 + certificati mTLS per ogni deviceCritica
MQTT AutenticazioneUsername/password unica per device + ACL per topicCritica
InfluxDBToken read-only separati per Grafana e app AngularAlta
InfluxDBNon esporre porta 8086 su internet, solo rete internaCritica
GrafanaAutenticazione OAuth2/SAML, disabilitare login anonimoAlta
GrafanaReverse proxy Nginx con rate limitingAlta
Angular AppHTTPS always, HSTS headers, CSPAlta
VPS/ServerFirewall solo porte necessarie, fail2ban, aggiornamenti autoCritica
SecretsVariabili ambiente, mai hardcoded, rotazione trimestraleCritica
Gateway edgeAggiornamenti firmware automatici, VPN verso cloudAlta

Conclusioni: La Dashboard come Centro Decisionale della Farm

Una dashboard farm IoT ben progettata non e un cruscotto di dati: e il cervello digitale dell'azienda agricola moderna. Quando Grafana rileva che l'umidita del suolo in vigneto A3 scende sotto la soglia critica, e Angular notifica l'operatore sul campo con il sensore esatto e la raccomandazione di azione, il loop percezione-analisi-decisione-azione si comprime da ore (o giorni) a minuti.

Lo stack descritto in questo articolo, Mosquitto + Telegraf + InfluxDB + Grafana + Angular, e una combinazione open-source matura e production-ready. I costi di infrastruttura per un'azienda di medie dimensioni (50-100 sensori) sono nell'ordine dei 3.000-6.000 EUR annui, a fronte di benefici agronomici che nel caso dell'Azienda Rossi hanno superato i 40.000 EUR annui già dalla prima stagione completa. Il ROI non e mai stato cosi chiaro.

Il passaggio successivo per le aziende che hanno implementato il monitoring base e l'integrazione con modelli predittivi: unire i dati dei sensori con dati satellitari (NDVI da Sentinel-2, visto nell'articolo 3 di questa serie), previsioni meteo granulari e modelli ML per lo stress idrico e la previsione di resa. E la frontier dove i dati IoT diventano intelligenza agronomica predittiva.

Riepilogo Tecnico

  • InfluxDB: Schema design con tag/field separation, bucket multi-tier, Flux per downsampling automatico
  • Telegraf: Bridge MQTT-InfluxDB, parsing JSON, tag extraction dalla struttura topic
  • Grafana: Datasource provisioning YAML, panel types per metriche agricole, alerting unificato con contact points multipli
  • Angular Signals: State management real-time immutable, computed derivati, WebSocket con retry automatico
  • ngx-charts: Line chart, area chart per time-series real-time con aggiornamento Signal-driven
  • PWA: Service Worker Angular per offline-first, cache strategy freshness per API recenti
  • Docker Compose: Stack completo 5 servizi, variabili ambiente, healthcheck, rete isolata

Prossimo Articolo della Serie

Siamo arrivati al penultimo articolo della serie FoodTech. Nel numero 10, l'ultimo della serie, affronteremo la Supply Chain Food Optimization: come i modelli ML ottimizzano la catena di fornitura alimentare per ridurre gli sprechi, prevedere la domanda e coordinare logistica e distribuzione in modo data-driven. Dall'ottimizzazione dei percorsi di consegna alla previsione della shelf-life, un articolo che connette l'eccellenza agronomica con l'efficienza industriale.

Esplora il Resto della Serie FoodTech

  • Articolo 1: Pipeline IoT per Agricoltura di Precisione - fondamenta MQTT e Python
  • Articolo 2: ML Edge per Monitoraggio Colture - Computer Vision sui Campi
  • Articolo 3: Satellite API e NDVI con Python e Sentinel-2 - dati satellitari open
  • Articolo 4: Tracciabilita Blockchain nel Food - dall'origine al consumatore
  • Articolo 5: Computer Vision per Quality Control nell'Industria Alimentare
  • Articolo 6: FSMA e Compliance Digitale - automazione normativa FDA/EU
  • Articolo 7: Vertical Farming - Controllo Ambientale con IoT e ML
  • Articolo 8: Demand Forecasting per il Food Retail con Prophet e LightGBM

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