Tabloul de bord în timp real pentru Farm IoT cu Angular și Grafana
Este ora 3:47 într-o noapte de iulie. Un senzor dintr-o podgorie din Chianti înregistrează temperatura respectivă a solului la nivelul rădăcinii a depășit pragul critic de 28 de grade Celsius pentru al patrulea oră consecutivă. Fără un sistem de monitorizare în timp real, agronom descoperă problema dimineața urmând în timpul inspecției manuale. Prejudiciul s-a produs deja: stres hidric acut, maturare accelerat inegal, pierdere estimată de 12% la randamentul lotului.
Cu un tablou de bord în timp real legat de alertă, același eveniment generează o notificare activată Telegramă la 03:48. Sistemul automat de irigare intră în acțiune la ora 03:49. La ora 06:00, când se trezește agronom, găsește un proces verbal care documentează intervenția care a avut loc, curba temperatura corectă și starea curentă: totul normal. Această diferență, între reactivitate și proactivitate, valorează în medie 15-30% din pierderi din cauza stresului hidric în culturi fine mediteraneene.
Tablourile de bord ale informațiilor agricole nu sunt un instrument de raportare – sunt sisteme de luare a deciziilor la timp reale. Combinând Grafana pentru afișare operațională, InfluxDB pentru stocarea serii temporale, de ex unghiular pentru interfețe personalizate pentru mobil primul, de ex Este posibil să construiți o platformă de inteligență agricolă care transformă datele brute ale senzorilor în acţiuni agronomice concrete. Acest articol acoperă întreaga arhitectură, de la brokerul MQTT până la panou control pe telefonul medicului agronom din teren.
Ce veți învăța în acest articol
- Arhitectura completă a unui sistem de bord pentru fermele IoT: senzori, InfluxDB, Grafana, Angular
- Proiectarea schemei InfluxDB optimizată pentru date agricole cu Flux și InfluxQL
- Configurare Grafana: sursă de date, panouri, variabile șablon, aprovizionare YAML
- Alertarea Grafana cu puncte de contact (e-mail, Telegram, Slack), reguli de alertă și politici de notificare
- Când să utilizați Angular custom vs pur Grafana: cazuri de utilizare și arhitectură hibridă
- Serviciu IoT angular cu WebSockets, semnale și diagrame în timp real cu diagrame ngx
- Docker Compose stiva completă: Mosquitto + InfluxDB + Grafana + Angular
- Studiu de caz: cramă cu 50 de senzori și 6 parcele
Seria FoodTech - Toate articolele
| # | Articol | Nivel | Stat |
|---|---|---|---|
| 1 | Conductă IoT pentru agricultura de precizie cu Python și MQTT | Avansat | Disponibil |
| 2 | ML Edge pentru monitorizarea culturilor: computer Vision in the Fields | Avansat | Disponibil |
| 3 | Satelit API și indici de vegetație: NDVI cu Python și Sentinel-2 | Intermediar | Disponibil |
| 4 | Trasabilitatea blockchain în alimente: de la câmp la supermarket | Intermediar | Disponibil |
| 5 | Viziunea computerizată pentru controlul calității în industria alimentară | Avansat | Disponibil |
| 6 | FSMA și Digital Compliance: Automatizarea proceselor de reglementare | Intermediar | Disponibil |
| 7 | Agricultura verticală: Controlul mediului cu IoT și ML | Avansat | Disponibil |
| 8 | Prognoza cererii pentru comerțul cu amănuntul alimentar cu Prophet și LightGBM | Intermediar | Disponibil |
| 9 | Tabloul de bord în timp real pentru Farm IoT cu Angular și Grafana (ești aici) | Avansat | Actual |
| 10 | Optimizarea lanțului de aprovizionare alimentară: ML pentru reducerea deșeurilor | Intermediar | Disponibil |
de ce tablourile de bord în timp real schimbă agricultura
Agricultura tradițională funcționează pe cicluri lungi de observare: agronom vizitează câmpul o dată sau de două ori pe săptămână, detectează parametrii vizuali, acționează pe baza experienței. Acest model funcționează bine atunci când variabilele de mediu se modifică lent. Dar criza clima a accelerat volatilitatea: valuri de căldură, secete bruște, înghețuri târzii iar inundațiile fulgerătoare necesită timpi de răspuns pe care vizitele săptămânale nu îi pot garanta.
Datele economice cuantifică costul acestei latențe de decizie. Potrivit unui studiu de la Universitatea Wageningen în 2024, pierderi atribuibile fara irigare optim ele reprezintă între 15% și 30% din randamentul potențial al culturilor fructe și legume intensive. Pentru viticultură de calitate, stres hidric în perioada de Veraison poate provoca daune permanente strugurilor, rezultând scăderi de 20-35%. valoarea comercială pentru acel lot. În Italia, unde sectorul vitivinicol este important peste 15 miliarde de euro pe an, diferența dintre monitorizarea pasivă și reactiv reprezintă sute de milioane de euro în valoare de risc în fiecare sezon.
Impactul economic al monitorizării în timp real: comparație
| Scenariu | Fără Real-Time | Cu în timp real | Delta |
|---|---|---|---|
| Consumul anual de apă | Linia de referință 100% | -25% până la -40% | Economii semnificative |
| Pierderile de stres hidric | 15-30% randament | 3-7% randament | +8-23% randament recuperat |
| Costurile îngrășămintelor | Linia de referință 100% | -20% până la -35% | Optimizare variabilă |
| Pierderi de îngheț târziu | Ridicat, parțial evitabil | Redus cu avertizare timpurie | -60% daune prevenibile |
| Monitorizarea programului de lucru | 8-12 ore/saptamana | 1-2 ore/saptamana | -80% timp de funcționare |
| ROI al sistemului IoT + tablou de bord | N / A | Rambursare 18-36 luni | Pozitiv în 2-3 sezoane |
Platforma Grafana este folosită de AgriTech, o companie specializata in monitorizarea cânepei industriale, pentru a gestiona datele din peste 70 de puncte de măsurare mediu per fermă, cu reîmprospătare la fiecare 60 de secunde. Rezultatul documentat a fost a Creștere de 4 ori a producției față de linia de bază de premonitorizare, cu a reducerea costurilor intrărilor cu 28%. Cazuri ca acesta demonstrează această tehnologie a tablourilor de bord în timp real nu este un cost IT: este o investiție agronomică cu randament măsurabil.
Arhitectura sistemului Dashboard Farm IoT
Designul arhitectural este primul pas critic. O alegere proastă nivelul se propagă în întregul sistem și devine costisitor de corectat ulterior. Arhitectura pe care o prezentăm aici a fost validată în medii de producție din lumea reală și la scară de la 10 la 10.000 de senzori fără modificări arhitecturale.
Arhitectură end-to-end: senzori de la tabloul de bord
┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────────┘
Punctul cheie al acestei arhitecturi este separarea responsabilitatilor: Grafana se ocupă de vizualizarea și alertarea operațională pentru operatorii tehnici, în timp ce Angular servește interfața mobilă pentru agronomii și managerii de teren care au nevoie de ea a unei experiențe mai ghidate și personalizate. Datele curg întotdeauna din InfluxDB ca sursă unică de adevăr, evitând inconsecvențele.
InfluxDB pentru date agricole: proiectare și interogare a schemelor
InfluxDB este baza de date de referință în serie de timp pentru aplicațiile agricole IoT datorită capacitatea sa de a gestiona asimilarea cu debit mare, compresia eficientă și interogările furtuni native. Alegerea între InfluxDB v2 (sursă deschisă), InfluxDB v3 (noua arhitectură OSS bazat pe Apache Arrow/Parquet), iar InfluxDB Cloud depinde de dimensiunea implementării. Pentru o fermă cu până la 200 de senzori, OSS auto-găzduit InfluxDB v2 este suficient.
Proiectare schema pentru date agricole
Regula fundamentală a InfluxDB: i etichete sunt indexate (folosește-le pentru filtru), i domeniu nu sunt (folositi-le pentru valori numerice). Un tip rău proiectarea schemei este cauza numărul unu a degradării performanței în producție.
# 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
Avertisment: Cardinalitate mare a etichetei
Nu introduceți niciodată valori mari de cardinalitate în etichete: marcaje temporale, UUID-uri aleatorii, coordonate GPS valori numerice precise sau continue. Acestea creează o „cardinalitate explozivă” care se degradează Performanța InfluxDB în mod exponențial. Folosiți în schimb câmpuri pentru valori mari variabilitate, cum ar fi coordonatele GPS (care trebuie rotunjite sau salvate ca etichete cu valori discrete pentru zone).
Configurare Telegraf pentru MQTT Consumer
Telegraf și colectorul de date InfluxData: se conectează la brokerul MQTT, parsa i Sarcina utilă JSON și le scrie în InfluxDB cu măsurarea, eticheta și configurația de câmp corectă.
# 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"
Interogări de flux pentru tablouri de bord
Flux și limbajul de interogare InfluxDB v2/v3. Este mai expresiv decât InfluxQL și suportă îmbinări între măsurare, funcții statistice avansate și subeșantionare declarativă.
// 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")
Politici de retenție și eșantionare
# 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: Configurare și configurare pentru Farm IoT
Grafana este cea mai populară platformă de vizualizare și alertă pentru mediile industriale IoT. Versiunea 11.x (2025) a introdus îmbunătățiri semnificative în alertele unificate, în panouri geografice (Geomap) și în provisioning-as-code. Pentru o instalare fermă IoT auto-găzduită, versiunea OSS este suficientă: versiunea Enterprise adaugă SSO, auditare pluginuri avansate și proprietare utile numai în mediile de întreprindere cu mai multe ferme.
Aprovizionarea sursei de date prin 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
Variabilele șablonului tabloului de bord
Variabilele șablon din Grafana vă permit să creați tablouri de bord interactive în care utilizatorul selectați ferma, parcela și intervalul de timp din meniurile derulante din partea de sus a paginii.
# 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
}
]
}
}
Panouri de bord pentru agricultură: Ghid complet
Alegerea tipului potrivit de panou pentru fiecare măsură este crucială pentru lizibilitatea tabloul de bord. Un panou de tip „Seria temporală” pentru nivelul bateriei și mai puțin imediat a unui „Gauge” sau „Stat”. Iată harta panourilor optime pentru valorile agricole:
Tipuri de panouri pentru metrici agricole
| Metric | Tip panou | De ce | Praguri de culoare |
|---|---|---|---|
| Temperatura sol/aer | Seria temporală + statistici | Tendință temporală + valoarea curentă | Verde <25, Portocaliu 25-30, Roșu >30 |
| Umiditatea solului | Ecartament + Seria temporală | Indicatorul arată % față de HR și PWP | Roșu <25, Portocaliu 25-35, Verde 35-70 |
| pH-ul solului | Ecartament | Valoare punctuală, se modifică încet | Roșu <5,5 sau >7,5, Verde 6,0-7,0 |
| PAR/DLI | Serii de timp | Variabilitatea diurnă, modelul solar | Fără prag, informativ |
| Precipitare | Diagramă cu bare | Date discrete zilnice | Fără prag, informativ |
| Nivelul apei | Indicator + Stat | Procentul rezervor + litri disponibili | Roșu <20%, portocaliu 20-40% |
| Baterie senzor | Masă | Vedeți toți senzorii, filtrul critic | Roșu <20%, portocaliu 20-30% |
| Harta grafică | Geohartă | Afișarea geografică a stării fermei | Culoare după starea agregată |
Configurația panoului de umiditate a solului cu praguri
# 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
}
}
Panoul Geomap pentru vizualizarea diagramei
# 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" }
}
}
]
}
}
Alertarea Grafana pentru Farm IoT: Configurare completă
Sistemul de alertă este componenta cea mai critică pentru propunerea de valoare a tabloului de bord. O alertă cu 2 ore de întârziere din cauza stresului hidric poate valora daune de mii de euro culturile. Grafana Unified Alerting (introdus în Grafana 9, consolidat în v10/v11) oferă un sistem complet cu reguli de alertă configurabile, puncte de contact și politici de notificare în întregime prin YAML (Infrastructure as Code).
Puncte de contact: e-mail, telegramă și 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 }}
Reguli de alertă pentru condiții agricole critice
# 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."
Politici de notificare și tăcere
# 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: Când și de ce
Grafana acoperă 90% din cazurile de utilizare a monitorizării operaționale. Dar există scenarii în care un tablou de bord Angular personalizat și alegerea potrivită:
Grafana vs tabloul de bord angular: când să alegi ce
| Criteriu | Grafana pură | AngularCustom |
|---|---|---|
| Utilizatorul țintă | Tehnicieni, analisti de date | Agronomi, manageri, operatori de teren |
| UX personalizat | Limitat la tipurile de panouri | Nelimitat, mai întâi pe mobil |
| Logica de afaceri | Nu este potrivit | Alerte calculate, recomandări AI |
| Integrarea aplicației existente | Hard (iframe) | Angular nativ |
| Offline-prima PWA | Nu este acceptat | Muncitor de serviciu nativ |
| Rata de reîmprospătare | Min 1s (sondaj) | WebSocket sub o secundă |
| Costul de dezvoltare | Scăzut (configurație) | Ridicat (dezvoltare personalizată) |
| Întreţinere | Scăzut | Ridicat |
Strategia recomandată pentru fermele întreprinderi și IoT hibrid: Grafana pentru monitorizare tehnică internă (IT, operațiuni, agronomie avansată) și Angular pentru aplicația mobilă pe care operatorii din domeniu îl folosesc. Cele două platforme au aceeași sursă de date (InfluxDB) și Angular pot, de asemenea, încorpora panouri Grafana specifice prin iframe semnate cu simbol pentru analize complexe care nu merită replicate.
Implementare unghiulară: serviciu IoT și tablou de bord în timp real
Șabloane TypeScript pentru datele senzorului
// 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[];
}
Serviciu IoT cu WebSocket și semnale unghiulare
// 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 } }
);
}
}
Componenta principală a tabloului de bord
// 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) }
]);
}
}
Șablon de tablou de bord HTML cu diagrame ngx
<!-- 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 și PWA pentru operatorii de teren
Lucrătorii de pe teren folosesc smartphone-uri, adesea cu conexiuni și condiții instabile de lumina intensă a soarelui care face ca ecranele cu fundal alb să fie greu de citit. Designul mai întâi mobil pentru tablourile de bord ale fermei necesită considerații specifice la tablourile de bord tradiționale ale întreprinderii.
/* 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 pentru 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 Full Stack: Deploy Production Ready
Întreaga stivă (Mosquitto, InfluxDB, Telegraf, Grafana, aplicația Angular) poate fi implementată
cu un singur docker compose up -d. Acesta este punctul de plecare pentru orice
implementare, atât on-premise, cât și pe cloud VPS.
# 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
Performanță și scalabilitate: optimizarea interogărilor și stocarea în cache
Cu 50-200 de senzori care trimit date la fiecare 30 de secunde, cantitatea de date din InfluxDB este în creștere rapid: aproximativ 5-20 de milioane de puncte pe zi. Fără strategii de optimizare, interogările din tabloul de bord încetinesc și experiența utilizatorului se degradează. Iată tehnicile fundamental pentru menținerea timpilor de răspuns sub 500 ms chiar și pe hardware modest.
Strategii de optimizare pentru IoT Dashboard Farm
| Tehnică | Implementarea | Impact |
|---|---|---|
| Eșantionare pe mai multe niveluri | Flux de sarcini la fiecare 1 oră, 1 zi | Interogări istorice de 10-50 de ori mai rapide |
| Push-down de agregare | aggregateWindow în Flux, nu în memorie | Reducerea transferului de date cu 90%+ |
| Memorarea în cache a interogărilor din tabloul de bord | Strat cache Grafana + Redis | -70% interogări redundante |
| Vederi materializate | Buchetă „farm_kpi” cu pre-agregări | Sarcina tabloului de bord <100 ms |
| WebSocket în loc de sondaj | Angular WebSocket vs sondaj HTTP | -80% încărcare server frontend |
| Încărcare progresivă | Încărcați mai întâi KPI-urile, mai târziu graficele | Performanță percepută +60% |
| Controlul cardinalității etichetei | Maxim 1000 de serii per măsurare | Previne degradarea 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"])
Studiu de caz: Crama Rossi - 50 de senzori, 6 loturi
Compania Agricolă Rossi produce Chianti Classico DOCG pe 35 de hectare distribuite pe 6 parcele cu diferite caracteristici ale solului. Înainte de implementarea sistemului Monitorizarea IoT s-a bazat pe vizite săptămânale ale agronomului și măsurători manuale. S-au estimat pierderile medii pe sezon din irigarea suboptimă și înghețul târziu aproximativ 18% din randamentul potențial.
Specificații de implementare a companiei Rossi
| Componentă | Detaliu |
|---|---|
| Senzori totale | 50 (8-9 per parcelă): sol, vreme, pH, PAR |
| Gateway-uri Edge | 6 Raspberry Pi 4 (unul pe parcelă), conexiune 4G |
| Protocol senzor wireless | LoRaWAN (sol/pH), Zigbee (veteme locală), RS-485 (rezervoare) |
| Broker MQTT | Eclipse Mosquitto, VPS OVH Europa, TLS 1.3 |
| Baze de date | InfluxDB v2.7, VPS 4 vCPU / 8 GB RAM, 200 GB SSD |
| Tabloul de bord pentru rata de reîmprospătare | 30 de secunde date la sol, 5 secunde alerte critice |
| Volumul datelor | ~3,5 milioane de puncte/zi, ~1,2 GB/lună brut |
| Tabloul de bord Grafana | 4 tablouri de bord: prezentare generală a fermei, per parcelă, senzori, istoric de alerte |
| Aplicație mobilă Angular | PWA, folosit de operatorii de teren pe Android |
| Alertarea | Telegramă (critică imediată) + rezumat zilnic de e-mail |
| Hardware total | 6 gateway-uri + 50 de senzori + cloud VPS |
| Costul infrastructurii/an | ~4.200 EUR (VPS + lățime de bandă + întreținere) |
Rezultate măsurate după 2 sezoane
Valori de economii și rentabilitate a investiției - Compania Rossi (sezoanele 2024 și 2025)
| Metric | Pre-IoT (medie 2022-2023) | Post-IoT (2024-2025) | Delta |
|---|---|---|---|
| Consumul de apă pentru irigare | ~2.800 mc/hectar/sezon | ~1.750 mc/hectar/sezon | -37,5% |
| Pierderile de stres hidric | ~18% randament potential | ~4% randament potential | -78% pierderi |
| Pierderi de îngheț târziu | Variabil, 0-15% în anii critici | 0% (7 intervenții antigel în timp util) | Eliminat |
| Costuri îngrășăminte/ha | Linia de referință 100% | 73% din valoarea inițială | -27% |
| Monitorizare ore/saptamana | 12-15 ore agronom | 2-3 ore (doar supraveghere) | -82% |
| Randament mediu al viei | 7,2 tone/ha | 8,6 tone/ha | +19,4% |
| Valoare suplimentară de producție | - | +42.000€/sezon | Rambursarea rentabilității investiției <18 luni |
Cele mai semnificative date nu sunt economia de apă (deși semnificativă), cieliminarea completă cu pierderi de la înghețul târziu. În sezonul 2024, sistemul a preluat controlul trei evenimente de temperatură scad sub 2 grade între martie și aprilie, activând automat Notificări Telegram la 02:15, 03:40 și 01:20. În toate cele trei cazuri, angajații care locuiesc la fermă au activat sistemele antiîngheț (aspersoare pentru suprafrunziș) în interior 20 de minute. Pagubele estimate de îngheț în lipsa intervenției ar fi fost de aproximativ 15.000 EUR doar pentru evenimentul din martie.
Lecții învățate din implementare
- Calibrarea senzorului de sol: Senzorii capacitivi de umiditate sunt sensibili la compoziția solului. Calibrarea in situ este necesară pentru fiecare tip de teren, nu doar pentru instalarea plug-and-play.
- Conectivitatea 4G ca blocaj: În zonele deluroase, acoperirea 4G poate fi intermitentă. Buffer-ul local de pe gateway-uri (SQLite, 7 zile) s-a dovedit a fi indispensabil pentru a evita pierderea datelor.
- Alertă oboseală: Prima configurație avea prea multe praguri active. În două săptămâni, operatorii au început să ignore notificările. Este esențial să începeți cu câteva alerte critice și să adăugați treptat mai multe.
- Instruirea operatorilor: Adoptarea aplicației mobile Angular a necesitat 3 sesiuni de antrenament de 2 ore. Investiția în formare este la fel de importantă ca și investiția tehnologică.
- Întreținere senzor: Senzorii de sol într-un mediu viticol necesită curățare trimestrială (reziduuri de tratare). Programați runde de întreținere preventivă.
Securitatea sistemului IoT Farm: cele mai bune practici
Un sistem agricol IoT conectat la internet este un vector de atac dacă nu este protejat în mod adecvat. Suprafața de atac include: broker MQTT expus, API InfluxDB, Interfață Grafana, aplicație Angular. Următoarele măsuri sunt minime necesare pentru o implementare de producție.
Lista de verificare a securității fermei IoT
| Zonă | Măsură | Prioritate |
|---|---|---|
| Broker MQTT | Certificate TLS 1.3 + mTLS pentru fiecare dispozitiv | Critică |
| Autentificare MQTT | Nume de utilizator/parolă unic pentru fiecare dispozitiv + ACL pe subiect | Critică |
| InfluxDB | Jetoane separate numai pentru citire pentru aplicațiile Grafana și Angular | Ridicat |
| InfluxDB | Nu expuneți portul 8086 la internet, numai la rețeaua internă | Critică |
| Grafana | Autentificare OAuth2/SAML, dezactivați autentificarea anonimă | Ridicat |
| Grafana | Proxy invers Nginx cu limitare a ratei | Ridicat |
| Aplicația Angular | HTTPS întotdeauna, antete HSTS, CSP | Ridicat |
| VPS/Server | Firewall sunt necesare doar porturi, fail2ban, actualizări automate | Critică |
| Secrete | Variabile de mediu, niciodată codificate, rotație trimestrială | Critică |
| Gateway-uri Edge | Actualizări automate de firmware, VPN către cloud | Ridicat |
Concluzii: Tabloul de bord ca centru de decizie al fermei
O fermă de tablouri de bord IoT bine concepută nu este un tablou de bord de date: este creierul digital a companiei agricole moderne. Când Grafana detectează umiditatea solului în podgorie A3 scade sub pragul critic, iar Angular anunță operatorul de teren cu senzorul exact și recomandarea de acțiune, bucla percepție-analiza-decizie-acțiune da comprese de la ore (sau zile) la minute.
Stiva descrisă în acest articol, Mosquitto + Telegraf + InfluxDB + Grafana + Angular, și o combinație open-source matură, pregătită pentru producție. Costurile de infrastructură pt o companie mijlocie (50-100 de senzori) sunt de ordinul 3.000-6.000 EUR pe an, comparativ cu beneficiile agronomice care în cazul firmei Rossi au depășit 40.000 EUR pe an deja din primul sezon complet. ROI nu a fost niciodată atât de clar.
Următorul pas pentru companiile care au implementat monitorizarea de bază e celintegrarea cu modele predictive: Îmbina datele senzorului cu date satelitare (NDVI de la Sentinel-2, văzut în articolul 3 din această serie), Prognoze meteorologice granulare și modele ML pentru stresul hidric și predicția randamentului. Este frontiera în care datele IoT devin inteligență agronomică predictivă.
Rezumat tehnic
- InfluxDB: Design schema cu separare etichetă/câmp, găleată cu mai multe niveluri, Flux pentru eșantionare automată
- Telegraf: Puntea MQTT-InfluxDB, analiza JSON, extragerea etichetelor din structura subiectului
- Grafana: Aprovizionarea surselor de date YAML, tipuri de panouri pentru valorile agricole, alerte unificate cu mai multe puncte de contact
- Semnale unghiulare: Gestionarea stării imuabile în timp real, derivate calculate, WebSocket cu reîncercare automată
- ngx-charts: Diagramă cu linii, diagramă cu zone pentru serii temporale în timp real cu actualizare bazată pe semnal
- PWA: Service Worker Angular pentru prospețimea strategiei de cache offline pentru API-urile recente
- Docker Compose: Full stack 5 servicii, variabile de mediu, control de sănătate, rețea izolată
Următorul articol din serie
Am ajuns la penultimul articol din seria FoodTech. În numarul 10, ultimul din serie, vom aborda Optimizarea lanțului de aprovizionare alimentară: modul în care modelele ML optimizează lanțul de aprovizionare cu alimente pentru a reduce risipa, prezice cererea și coordonează logistica și distribuția într-un mod bazat pe date. De la optimizarea rutelor de livrare la estimarea termenului de valabilitate, un articol care leagă excelența agronomică cu eficiența industrială.
Explorați restul seriei FoodTech
- Articolul 1: Conducta IoT pentru Agricultura de Precizie - fundații MQTT și Python
- Articolul 2: ML Edge pentru monitorizarea culturilor - Computer Vision in the Fields
- Articolul 3: Satellite API și NDVI cu Python și Sentinel-2 - date satelit deschise
- Articolul 4: Trasabilitatea blockchain în alimente - de la origine până la consumator
- Articolul 5: Viziunea computerizată pentru controlul calității în industria alimentară
- Articolul 6: FSMA și Digital Compliance - automatizarea reglementărilor FDA/UE
- Articolul 7: Agricultura verticală - Controlul mediului cu IoT și ML
- Articolul 8: Prognoza cererii pentru vânzarea cu amănuntul de produse alimentare cu Prophet și LightGBM







