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
| # | Articolo | Livello | Stato |
|---|---|---|---|
| 1 | Pipeline IoT per Agricoltura di Precisione con Python e MQTT | Advanced | Disponibile |
| 2 | ML Edge per Monitoraggio Colture: Computer Vision sui Campi | Advanced | Disponibile |
| 3 | Satellite API e Indici Vegetativi: NDVI con Python e Sentinel-2 | Intermediate | Disponibile |
| 4 | Tracciabilita Blockchain nel Food: dal Campo al Supermercato | Intermediate | Disponibile |
| 5 | Computer Vision per Quality Control nell'Industria Alimentare | Advanced | Disponibile |
| 6 | FSMA e Compliance Digitale: Automazione dei Processi Normativi | Intermediate | Disponibile |
| 7 | Vertical Farming: Controllo Ambientale con IoT e ML | Advanced | Disponibile |
| 8 | Demand Forecasting per il Food Retail con Prophet e LightGBM | Intermediate | Disponibile |
| 9 | Dashboard Real-Time per Farm IoT con Angular e Grafana (sei qui) | Advanced | Corrente |
| 10 | Supply Chain Food Optimization: ML per Riduzione degli Sprechi | Intermediate | Disponibile |
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
| Scenario | Senza Real-Time | Con Real-Time | Delta |
|---|---|---|---|
| Consumo idrico annuo | Baseline 100% | -25% a -40% | Risparmio significativo |
| Perdite stress idrico | 15-30% resa | 3-7% resa | +8-23% resa recuperata |
| Costi fertilizzanti | Baseline 100% | -20% a -35% | Ottimizzazione variabile |
| Perdite per gelo tardivo | Elevate, parzialmente evitabili | Ridotte con allerta anticipata | -60% danni prevenibili |
| Ore lavoro monitoraggio | 8-12 ore/settimana | 1-2 ore/settimana | -80% tempo operativo |
| ROI sistema IoT + dashboard | N/A | 18-36 mesi payback | Positivo 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
| Metrica | Panel Type | perchè | Soglie Colore |
|---|---|---|---|
| Temperatura suolo/aria | Time Series + Stat | Trend temporale + valore attuale | Verde <25, Arancione 25-30, Rosso >30 |
| Umidita suolo | Gauge + Time Series | Gauge mostra % rispetto a FC e PWP | Rosso <25, Arancione 25-35, Verde 35-70 |
| pH suolo | Gauge | Valore puntuale, cambia lentamente | Rosso <5.5 o >7.5, Verde 6.0-7.0 |
| PAR/DLI | Time Series | Variabilità diurna, pattern solare | Nessuna soglia, informativo |
| Precipitazioni | Bar Chart | Dati discreti giornalieri | Nessuna soglia, informativo |
| Livello acqua | Gauge + Stat | Percentuale cisterna + litri disponibili | Rosso <20%, Arancione 20-40% |
| Batteria sensori | Table | Vista tutti i sensori, filtra critici | Rosso <20%, Arancione 20-30% |
| Mappa appezzamenti | Geomap | Visualizzazione geografica stato farm | Colore 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
| Criterio | Grafana Puro | Angular Custom |
|---|---|---|
| Utente target | Tecnici, data analysts | Agronomi, manager, operatori campo |
| UX personalizzata | Limitata ai panel types | Illimitata, mobile-first |
| Logica business | Non adatto | Alert calcolati, raccomandazioni AI |
| Integrazione app esistenti | Difficile (iframe) | Nativa Angular |
| PWA offline-first | Non supportato | Service Worker nativo |
| Refresh rate | Min 1s (polling) | WebSocket sub-secondo |
| Costo sviluppo | Basso (configurazione) | Alto (sviluppo custom) |
| Manutenzione | Bassa | Alta |
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
| Tecnica | Implementazione | Impatto |
|---|---|---|
| Downsampling multi-tier | Task Flux ogni 1h, 1d | Query storiche 10-50x più veloci |
| Aggregation push-down | aggregateWindow in Flux, non in-memory | Riduzione dati trasferiti 90%+ |
| Dashboard query caching | Grafana cache layer + Redis | -70% query ridondanti |
| Materialized views | Bucket "farm_kpi" con pre-aggregazioni | Dashboard load <100ms |
| WebSocket invece di polling | Angular WebSocket vs HTTP polling | -80% carico server frontend |
| Progressive loading | Carica KPI prima, grafici dopo | Perceived performance +60% |
| Tag cardinality control | Max 1000 serie per measurement | Previene 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
| Componente | Dettaglio |
|---|---|
| Sensori totali | 50 (8-9 per appezzamento): suolo, meteo, pH, PAR |
| Gateway edge | 6 Raspberry Pi 4 (uno per appezzamento), connessione 4G |
| Protocollo wireless sensori | LoRaWAN (suolo/pH), Zigbee (meteo locali), RS-485 (cisterne) |
| Broker MQTT | Eclipse Mosquitto, VPS OVH Europa, TLS 1.3 |
| Database | InfluxDB v2.7, VPS 4 vCPU / 8GB RAM, SSD 200GB |
| Refresh rate dashboard | 30 secondi dati suolo, 5 secondi allerte critiche |
| Volume dati | ~3.5 milioni punti/giorno, ~1.2 GB/mese raw |
| Dashboard Grafana | 4 dashboard: overview farm, per-appezzamento, sensori, alert history |
| App Angular mobile | PWA, usata dagli operatori in campo su Android |
| Alerting | Telegram (critico immediato) + email giornaliera digest |
| Hardware totale | 6 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)
| Metrica | Pre-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 tardivo | Variabile, 0-15% in anni critici | 0% (7 interventi antigelo tempestivi) | Eliminato |
| Costi fertilizzanti/ha | Baseline 100% | 73% del baseline | -27% |
| Ore/settimana monitoraggio | 12-15 ore agronomo | 2-3 ore (solo supervisione) | -82% |
| Resa media vigneto | 7.2 ton/ha | 8.6 ton/ha | +19.4% |
| Valore produzione extra | - | +€ 42.000/stagione | ROI 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
| Area | Misura | Priorità |
|---|---|---|
| MQTT Broker | TLS 1.3 + certificati mTLS per ogni device | Critica |
| MQTT Autenticazione | Username/password unica per device + ACL per topic | Critica |
| InfluxDB | Token read-only separati per Grafana e app Angular | Alta |
| InfluxDB | Non esporre porta 8086 su internet, solo rete interna | Critica |
| Grafana | Autenticazione OAuth2/SAML, disabilitare login anonimo | Alta |
| Grafana | Reverse proxy Nginx con rate limiting | Alta |
| Angular App | HTTPS always, HSTS headers, CSP | Alta |
| VPS/Server | Firewall solo porte necessarie, fail2ban, aggiornamenti auto | Critica |
| Secrets | Variabili ambiente, mai hardcoded, rotazione trimestrale | Critica |
| Gateway edge | Aggiornamenti firmware automatici, VPN verso cloud | Alta |
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