Pulpit nawigacyjny w czasie rzeczywistym dla Farm IoT z Angular i Grafana
Jest 3:47 rano, lipcowy wieczór. Czujnik w winnicy Chianti rejestruje tę temperaturę gleby na poziomie korzeni po raz czwarty przekroczyła krytyczny próg 28 stopni Celsjusza kolejna godzina. Bez systemu monitorowania w czasie rzeczywistym agronom odkrywa problem już rano przestrzegać podczas kontroli ręcznej. Uszkodzenie już nastąpiło: ostry stres wodny, dojrzewanie przyspieszał nierównomiernie, szacowana strata 12% wydajności partii.
Dzięki pulpitowi nawigacyjnemu działającemu w czasie rzeczywistym połączonemu z alertami to samo zdarzenie generuje powiadomienie Telegram o 03:48. Automatyczny system nawadniania zaczyna działać o godzinie 03:49. O 06:00, kiedy agronom się budzi, znajduje raport dokumentujący interwencję, która miała miejsce, czyli krzywą prawidłowa temperatura i aktualny stan: wszystko w normie. Ta różnica między reaktywnością a proaktywność, jest warta średnio 15-30% strat spowodowanych stresem wodnym w uprawach dobre śródziemnomorskie.
Pulpity informacyjne gospodarstw rolnych nie są narzędziem do raportowania – są to systemy podejmowania decyzji na czas prawdziwy. Łączenie Grafana do wyświetlania operacyjnego, NapływDB do przechowywania szeregów czasowych, np Kątowy dla niestandardowych interfejsów mobilnych, np Możliwe jest zbudowanie platformy inteligencji rolniczej, która przekształca surowe dane z czujników w konkretne działania agronomiczne. W tym artykule omówiono całą architekturę, od brokera MQTT po panel sterowanie na telefonie agronoma na polu.
Czego dowiesz się w tym artykule
- Kompletna architektura systemu dashboardów dla farm IoT: czujniki, InfluxDB, Grafana, Angular
- Projekt schematu InfluxDB zoptymalizowany pod kątem danych rolniczych za pomocą Flux i InfluxQL
- Konfiguracja Grafany: źródło danych, panele, zmienne szablonowe, dostarczanie YAML
- Alertowanie Grafana z punktami kontaktowymi (e-mail, Telegram, Slack), zasadami alertów i polityką powiadomień
- Kiedy używać Angulara niestandardowego czy czystej Grafany: przypadki użycia i architektura hybrydowa
- Usługa Angular IoT z WebSocketami, sygnałami i wykresami w czasie rzeczywistym z ngx-charts
- Pełny stos Docker Compose: Mosquitto + InfluxDB + Grafana + Angular
- Studium przypadku: winiarnia z 50 czujnikami i 6 działkami
Seria FoodTech - Wszystkie artykuły
| # | Przedmiot | Poziom | Państwo |
|---|---|---|---|
| 1 | Rurociąg IoT dla rolnictwa precyzyjnego z Pythonem i MQTT | Zaawansowany | Dostępny |
| 2 | ML Edge do monitorowania upraw: wizja komputerowa na polach | Zaawansowany | Dostępny |
| 3 | Satelitarne API i wskaźniki roślinności: NDVI z Pythonem i Sentinel-2 | Mediator | Dostępny |
| 4 | Identyfikowalność Blockchain w żywności: od pola do supermarketu | Mediator | Dostępny |
| 5 | Wizja komputerowa w kontroli jakości w przemyśle spożywczym | Zaawansowany | Dostępny |
| 6 | FSMA i zgodność cyfrowa: automatyzacja procesów regulacyjnych | Mediator | Dostępny |
| 7 | Rolnictwo pionowe: kontrola środowiska za pomocą IoT i ML | Zaawansowany | Dostępny |
| 8 | Prognozowanie popytu na sprzedaż detaliczną żywności za pomocą Prophet i LightGBM | Mediator | Dostępny |
| 9 | Pulpit nawigacyjny w czasie rzeczywistym dla Farm IoT z Angular i Grafana (tutaj jesteś) | Zaawansowany | Aktualny |
| 10 | Optymalizacja żywności w łańcuchu dostaw: ML na rzecz redukcji odpadów | Mediator | Dostępny |
dlaczego pulpity nawigacyjne działające w czasie rzeczywistym zmieniają rolnictwo
Tradycyjne rolnictwo opiera się na długich cyklach obserwacji: agronom odwiedza pole raz lub dwa razy w tygodniu, wykrywa parametry wizualne, działa w oparciu o doświadczenie. Model ten sprawdza się dobrze, gdy zmienne środowiskowe zmieniają się powoli. Ale kryzys klimat przyspieszył zmienność: fale upałów, nagłe susze, późne przymrozki a gwałtowne powodzie wymagają czasu reakcji, którego nie są w stanie zagwarantować cotygodniowe wizyty.
Dane ekonomiczne określają ilościowo koszt opóźnienia decyzji. Według badania Uniwersytetu w Wageningen w 2024 r., straty z winy bez nawadniania optymalny stanowią one od 15% do 30% potencjalnego plonu roślin uprawnych intensywne owoce i warzywa. W przypadku wysokiej jakości uprawy winorośli stres wodny w okresie Veraison może spowodować trwałe uszkodzenie winogron, powodując spadek o 20-35%. wartość handlową tej partii. We Włoszech, gdzie ważny jest sektor wina ponad 15 miliardów euro rocznie, różnica między monitorowaniem pasywnym i reaktywne reprezentują w każdym sezonie setki milionów euro wartości zagrożonej.
Ekonomiczny wpływ monitorowania w czasie rzeczywistym: porównanie
| Scenariusz | Bez czasu rzeczywistego | Z czasem rzeczywistym | Delta |
|---|---|---|---|
| Roczne zużycie wody | Wartość bazowa 100% | -25% do -40% | Znaczące oszczędności |
| Straty stresu wodnego | Wydajność 15-30%. | Wydajność 3-7%. | +8-23% odzyskanej wydajności |
| Koszty nawozów | Wartość bazowa 100% | -20% do -35% | Zmienna optymalizacja |
| Późne straty mrozowe | Wysoka, częściowo możliwa do uniknięcia | Obniżka po wczesnym ostrzeżeniu | -60% możliwych do uniknięcia obrażeń |
| Monitorowanie godzin pracy | 8-12 godzin/tydzień | 1-2 godziny/tydzień | -80% czasu pracy |
| ROI systemu IoT + pulpit nawigacyjny | Nie dotyczy | Zwrot w ciągu 18–36 miesięcy | Pozytywny za 2-3 sezony |
Z platformy Grafana korzysta m.in AgroTech, firma specjalizująca się w monitoring konopi przemysłowych, umożliwiający zarządzanie danymi z ponad 70 punktów pomiarowych środowisko na gospodarstwo, z odświeżaniem co 60 sekund. Udokumentowanym wynikiem był a 4-krotny wzrost produkcji w porównaniu z wartością bazową monitorowania wstępnego, przy: redukcja kosztów nakładów o 28%. Takie przypadki pokazują tę technologię dashboardów działających w czasie rzeczywistym nie jest kosztem IT: to agronomiczna inwestycja z wymiernym zwrotem.
Architektura systemu IoT Dashboard Farm
Projekt architektoniczny jest pierwszym krytycznym krokiem. Do tego zły wybór poziom rozprzestrzenia się w całym systemie, a jego późniejsza korekta staje się kosztowna. Architektura, którą tutaj prezentujemy, została sprawdzona w rzeczywistych środowiskach produkcyjnych i skali od 10 do 10 000 czujników bez zmian architektonicznych.
Architektura kompleksowa: czujniki na desce rozdzielczej
┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────────┘
Kluczowym punktem tej architektury jest rozdzielenie obowiązków: Grafana zajmuje się wizualizacją operacyjną i alarmowaniem dla operatorów technicznych, podczas gdy Angular udostępnia interfejs mobilny dla agronomów i kierowników pól, którzy go potrzebują bardziej ukierunkowanego i spersonalizowanego doświadczenia. Dane zawsze płyną z InfluxDB as jednego źródła prawdy, unikając niespójności.
InfluxDB dla danych rolniczych: projektowanie schematów i zapytania
InfluxDB to referencyjna baza danych szeregów czasowych dla zastosowań IoT w rolnictwie jego zdolność do obsługi pozyskiwania o dużej przepustowości, wydajnej kompresji i zapytań rodzime burze. Wybór pomiędzy InfluxDB v2 (open source), InfluxDB v3 (nowa architektura OSS oparty na Apache Arrow/Parquet), a InfluxDB Cloud zależy od wielkości wdrożenia. W przypadku farmy składającej się z maksymalnie 200 czujników wystarczający jest własny host InfluxDB v2 OSS.
Projekt schematu danych rolniczych
Podstawowa zasada InfluxDB: tagi są indeksowane (użyj ich do filtr), tj pole nie są (użyj ich w przypadku wartości numerycznych). Zły facet projektowanie schematów jest główną przyczyną pogorszenia wydajności w środowisku produkcyjnym.
# 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
Ostrzeżenie: wysoka liczność znaczników
Nigdy nie umieszczaj w tagach wysokich wartości kardynalności: znaczników czasu, losowych identyfikatorów UUID, współrzędnych GPS dokładne lub ciągłe wartości liczbowe. Tworzą one „wybuchową liczność”, która ulega degradacji Wydajność InfluxDB wykładniczo. Zamiast tego użyj pól dla wysokich wartości zmienności, takich jak współrzędne GPS (które należy zaokrąglić lub zapisać jako znaczniki). z dyskretnymi wartościami dla stref).
Konfiguracja Telegrafu dla konsumenta MQTT
Telegraf i moduł zbierający dane InfluxData: łączy się z brokerem MQTT, parsa m.in JSON i zapisuje je w InfluxDB z poprawną konfiguracją pomiaru, tagu i pola.
# 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"
Zapytania dotyczące strumienia dla pulpitów nawigacyjnych
Flux i język zapytań InfluxDB v2/v3. Jest bardziej wyrazisty niż InfluxQL i obsługuje łączy pomiar, zaawansowane funkcje statystyczne i deklaratywne próbkowanie.
// 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")
Zasady przechowywania i próbkowanie w dół
# 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: Instalacja i konfiguracja Farm IoT
Grafana to najpopularniejsza platforma wizualizacji i alertów dla przemysłowych środowisk IoT. Wersja 11.x (2025) wprowadziła istotne usprawnienia w zakresie ujednoliconego alertowania, w panelach geograficznych (Geomap) oraz w dostarczaniu jako kod. Do instalacji farmy IoT hostowane samodzielnie, wystarczy wersja OSS: wersja Enterprise dodaje funkcję SSO i audyt zaawansowane i autorskie wtyczki przydatne tylko w środowiskach korporacyjnych obejmujących wiele gospodarstw.
Udostępnianie źródła danych za pośrednictwem 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
Zmienne szablonu panelu kontrolnego
Zmienne szablonów w Grafanie umożliwiają tworzenie interaktywnych dashboardów, w których użytkownik wybierz farmę, działkę i zakres czasowy z menu rozwijanego u góry strony.
# 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
}
]
}
}
Panele rozdzielcze dla rolnictwa: kompletny przewodnik
Wybór odpowiedniego typu panelu dla każdej metryki ma kluczowe znaczenie dla czytelności metryki deska rozdzielcza. Panel typu „Seria czasowa” pokazujący poziom naładowania baterii i mniej natychmiastowy „Wskaźnika” lub „Statu”. Oto mapa optymalnych paneli wskaźników rolniczych:
Typy paneli dla wskaźników rolniczych
| Metryczny | Typ panelu | Dlaczego | Progi kolorów |
|---|---|---|---|
| Temperatura gleby/powietrza | Szeregi czasowe + statystyki | Trend czasowy + aktualna wartość | Zielony <25, Pomarańczowy 25-30, Czerwony >30 |
| Wilgotność gleby | Wskaźnik + szereg czasowy | Wskaźnik pokazuje % względem HR i PWP | Czerwony <25, Pomarańczowy 25-35, Zielony 35-70 |
| pH gleby | Miernik | Wartość punktualna, zmienia się powoli | Czerwony <5,5 lub >7,5, Zielony 6,0-7,0 |
| PAR/DLI | Seria czasowa | Zmienność dobowa, układ słoneczny | Bez progu, informacyjny |
| Osad | Wykres słupkowy | Dzienne dane dyskretne | Bez progu, informacyjny |
| Poziom wody | Wskaźnik + statystyka | Procent zbiornika + dostępne litry | Czerwony <20%, Pomarańczowy 20-40% |
| Bateria czujnika | Tabela | Wyświetl wszystkie czujniki, filtr krytyczny | Czerwony <20%, Pomarańczowy 20-30% |
| Mapa działki | Geomapa | Geograficzne wyświetlanie statusu gospodarstwa | Kolor według stanu skupienia |
Konfiguracja panelu wilgotności gleby z progami
# 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 Geomapy do wizualizacji działki
# 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" }
}
}
]
}
}
Alarmowanie Grafana dla Farm IoT: pełna konfiguracja
System ostrzegania jest najważniejszym elementem propozycji wartości dashboardu. Alarm spóźniony o 2 godziny z powodu stresu wodnego może być wart tysiące euro odszkodowania uprawy. Grafana Unified Alerting (wprowadzona w Grafana 9, skonsolidowana w wersji 10/11) zapewnia kompletny system z konfigurowalnymi regułami alertów, punktami kontaktowymi i polityką powiadomień w całości za pośrednictwem YAML (Infrastruktura jako kod).
Punkty kontaktowe: 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 }}
Zasady ostrzegania o krytycznych warunkach rolniczych
# 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."
Zasady powiadamiania i wyciszanie
# 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: kiedy i dlaczego
Grafana obejmuje 90% przypadków użycia monitorowania operacyjnego. Ale są scenariusze, w których niestandardowy dashboard Angular i właściwy wybór:
Grafana vs Angular Dashboard: kiedy co wybrać
| Kryterium | Czysta Grafana | Kątowy niestandardowy |
|---|---|---|
| Użytkownik docelowy | Technicy, analitycy danych | Agronomowie, menedżerowie, operatorzy pól |
| Niestandardowy UX | Ograniczone do typów paneli | Nieograniczone, przede wszystkim mobilne |
| Logika biznesowa | Nie nadaje się | Obliczone alerty, rekomendacje AI |
| Istniejąca integracja aplikacji | Twardy (iframe) | Natywny Angular |
| PWA w trybie offline | Nieobsługiwane | Rodzimy pracownik serwisu |
| Częstotliwość odświeżania | Min. 1 s (odpytywanie) | Sekunda WebSocket |
| Koszt rozwoju | Niski (konfiguracja) | Wysoka (rozwój niestandardowy) |
| Konserwacja | Niski | Wysoki |
Zalecana strategia dla przedsiębiorstw i farm IoT hybrydowy: Grafana dla wewnętrzny monitoring techniczny (IT, operacje, zaawansowana agronomia) oraz Angular dla aplikacji mobilnej których używają operatorzy w terenie. Obie platformy korzystają z tego samego źródła danych (InfluxDB) i Angular mogą również osadzać określone panele Grafana za pośrednictwem podpisanych tokenem ramek iframe w przypadku skomplikowanych analiz, których nie warto powielać.
Implementacja Angular: usługa IoT i pulpit nawigacyjny w czasie rzeczywistym
Szablony TypeScript dla danych czujnika
// 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[];
}
Usługa IoT z WebSocket i sygnałami Angular
// 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 } }
);
}
}
Główny komponent pulpitu nawigacyjnego
// 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) }
]);
}
}
Szablon panelu HTML z wykresami 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>
Projektowanie mobilne i PWA dla operatorów terenowych
Pracownicy terenowi korzystają ze smartfonów, często z niestabilnymi połączeniami i warunkami intensywnego światła słonecznego, które utrudnia odczytanie ekranów z białym tłem. Projekt pulpitów nawigacyjnych gospodarstw rolnych zorientowany przede wszystkim na urządzenia mobilne wymaga szczególnych rozważań do tradycyjnych dashboardów korporacyjnych.
/* 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 dla trybu 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: wdrożenie wersji produkcyjnej
Można wdrożyć cały stos (Mosquitto, InfluxDB, Telegraf, Grafana, aplikacja Angular)
z singlem docker compose up -d. To jest punkt wyjścia dla każdego
wdrożenia, zarówno lokalnie, jak i na chmurze 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
Wydajność i skalowalność: optymalizacja zapytań i buforowanie
Przy 50-200 czujnikach wysyłających dane co 30 sekund, ilość danych w InfluxDB rośnie szybko: około 5-20 milionów punktów dziennie. Bez strategii optymalizacyjnych zapytania do panelu kontrolnego spowalniają, a komfort użytkownika ulega pogorszeniu. Oto techniki ma fundamentalne znaczenie dla utrzymania czasu reakcji poniżej 500 ms nawet na skromnym sprzęcie.
Strategie optymalizacji farmy pulpitów nawigacyjnych IoT
| Technika | Realizacja | Uderzenie |
|---|---|---|
| Próbkowanie wielopoziomowe | Strumień zadań co 1 godzinę, 1 dzień | 10-50x szybsze zapytania historyczne |
| Agregacja w dół | agregowaneWindow w Flux, a nie w pamięci | Redukcja transferu danych 90%+ |
| Buforowanie zapytań w panelu kontrolnym | Warstwa pamięci podręcznej Grafana + Redis | -70% zbędnych zapytań |
| Zmaterializowane poglądy | Wiadro „farm_kpi” ze wstępnymi agregacjami | Obciążenie panelu <100ms |
| WebSocket zamiast odpytywania | Odpytywanie Angular WebSocket a HTTP | -80% obciążenia serwera frontendowego |
| Progresywne ładowanie | Najpierw załaduj wskaźniki KPI, później wykresy | Postrzegana wydajność +60% |
| Kontrola liczności znaczników | Maks. 1000 serii na pomiar | Zapobiega degradacji bazy danych |
// 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"])
Studium przypadku: Winiarnia Rossi – 50 czujników, 6 działek
Przedsiębiorstwo Rolne Rossi produkuje Chianti Classico DOCG na 35 hektarach rozmieszczonych na 6 obszarach działki o różnej charakterystyce gleby. Przed wdrożeniem systemu Monitorowanie Internetu Rzeczy opierało się na cotygodniowych wizytach agronomów i pomiarach ręcznych. Oszacowano średnie straty w sezonie spowodowane nieoptymalnym nawadnianiem i późnymi przymrozkami około 18% potencjalnego plonu.
Specyfikacje wdrożeniowe firmy Rossi
| Część | Szczegół |
|---|---|
| Razem czujniki | 50 (8-9 na działkę): gleba, pogoda, pH, PAR |
| Bramy brzegowe | 6 Raspberry Pi 4 (po jednym na działkę), połączenie 4G |
| Protokół czujnika bezprzewodowego | LoRaWAN (gleba/pH), Zigbee (lokalna pogoda), RS-485 (zbiorniki) |
| Broker MQTT | Eclipse Mosquitto, VPS OVH Europa, TLS 1.3 |
| Bazy danych | InfluxDB v2.7, VPS 4 vCPU / 8 GB RAM, 200 GB SSD |
| Panel częstotliwości odświeżania | Dane naziemne w ciągu 30 sekund, alerty krytyczne w ciągu 5 sekund |
| Ilość danych | ~3,5 miliona punktów/dzień, ~1,2 GB/miesiąc w stanie surowym |
| Panel Grafany | 4 dashboardy: przegląd gospodarstwa, poszczególne poletka, czujniki, historia alertów |
| Angularowa aplikacja mobilna | PWA, używane przez operatorów terenowych na Androidzie |
| Alarmowanie | Telegram (natychmiast krytyczny) + codzienne podsumowanie wiadomości e-mail |
| Całkowity sprzęt | 6 bramek + 50 czujników + VPS w chmurze |
| Koszt infrastruktury/rok | ~4200 EUR (VPS + przepustowość + utrzymanie) |
Wyniki mierzone po 2 sezonach
Wskaźniki oszczędności i ROI - Rossi Company (sezony 2024 i 2025)
| Metryczny | Przed IoT (średnia z lat 2022–2023) | Post-IoT (2024–2025) | Delta |
|---|---|---|---|
| Zużycie wody do nawadniania | ~2800 m3/ha/sezon | ~1750 m3/ha/sezon | -37,5% |
| Straty stresu wodnego | ~18% potencjalnej wydajności | ~4% potencjalnej wydajności | -78% strat |
| Późne straty mrozowe | Zmienna, 0-15% w latach krytycznych | 0% (7 terminowych interwencji zapobiegających zamarzaniu) | Wyłączony |
| Koszty nawozów/ha | Wartość bazowa 100% | 73% wartości bazowej | -27% |
| Godziny monitorowania/tydzień | 12-15 godzin agronom | 2-3 godziny (tylko pod nadzorem) | -82% |
| Średni plon winnicy | 7,2 tony/ha | 8,6 tony/ha | +19,4% |
| Dodatkowa wartość produkcyjna | - | +42 000 euro/sezon | Zwrot inwestycji < 18 miesięcy |
Najważniejszymi danymi nie jest oszczędność wody (choć znacząca), aleeliminacja uzupełnione stratami spowodowanymi późnymi przymrozkami. W sezonie 2024 system przejął kontrolę trzy zdarzenia spadku temperatury poniżej 2 stopni w okresie od marca do kwietnia, aktywujące się automatycznie Powiadomienia telegramowe o 02:15, 03:40 i 01:20. We wszystkich trzech przypadkach pracownicy którzy mieszkają w gospodarstwie, włączyli w nim systemy przeciwzamrożeniowe (zraszacze nadmiaru liści). 20 minut. W przypadku braku interwencji szacunkowe szkody spowodowane przez mróz wyniosłyby około 15 000 EUR tylko na marcowe wydarzenie.
Wnioski wyciągnięte z wdrożenia
- Kalibracja czujnika gleby: Pojemnościowe czujniki wilgoci są wrażliwe na skład gleby. Kalibracja na miejscu jest wymagana dla każdego rodzaju terenu, a nie tylko instalacji typu plug-and-play.
- Łączność 4G jako wąskie gardło: Na terenach pagórkowatych zasięg sieci 4G może być przerywany. Lokalny bufor na bramkach (SQLite, 7 dni) okazał się niezbędny, aby uniknąć utraty danych.
- Alarmujące zmęczenie: Pierwsza konfiguracja miała zbyt wiele aktywnych progów. W ciągu dwóch tygodni operatorzy zaczęli ignorować powiadomienia. Ważne jest, aby zacząć od kilku krytycznych alertów i stopniowo dodawać kolejne.
- Szkolenie operatora: Wdrożenie aplikacji mobilnej Angular wymagało 3 2-godzinnych sesji szkoleniowych. Inwestycje w szkolenia są równie ważne jak inwestycje technologiczne.
- Konserwacja czujnika: Czujniki gleby w środowisku uprawy winorośli wymagają czyszczenia co kwartał (pozostałości po zabiegu). Zaplanuj rundy konserwacji zapobiegawczej.
Bezpieczeństwo systemu farmy IoT: najlepsze praktyki
Rolniczy system IoT podłączony do Internetu stanowi wektor ataku, jeśli nie jest chroniony odpowiednio. Powierzchnia ataku obejmuje: odsłonięty broker MQTT, API InfluxDB, Interfejs Grafana, aplikacja Angular. Poniższe środki stanowią niezbędne minimum na wdrożenie produkcyjne.
Lista kontrolna bezpieczeństwa farmy IoT
| Obszar | Mierzyć | Priorytet |
|---|---|---|
| Broker MQTT | Certyfikaty TLS 1.3 + mTLS dla każdego urządzenia | Krytyka |
| Uwierzytelnianie MQTT | Unikalna nazwa użytkownika/hasło na urządzenie + lista ACL na temat | Krytyka |
| NapływDB | Oddzielne tokeny tylko do odczytu dla aplikacji Grafana i Angular | Wysoki |
| NapływDB | Nie udostępniaj portu 8086 do Internetu, tylko do sieci wewnętrznej | Krytyka |
| Grafana | Uwierzytelnianie OAuth2/SAML, wyłącz logowanie anonimowe | Wysoki |
| Grafana | Odwrotne proxy Nginx z ograniczeniem szybkości | Wysoki |
| Aplikacja Angularna | Zawsze HTTPS, nagłówki HSTS, CSP | Wysoki |
| VPS/serwer | Potrzebne są tylko porty zapory ogniowej, Fail2Ban, automatyczne aktualizacje | Krytyka |
| Tajniki | Zmienne środowiskowe, nigdy niekodowane na stałe, rotacja kwartalna | Krytyka |
| Bramy brzegowe | Automatyczne aktualizacje oprogramowania sprzętowego, VPN do chmury | Wysoki |
Wnioski: Panel kontrolny jako centrum decyzyjne w gospodarstwie
Dobrze zaprojektowana farma pulpitów nawigacyjnych IoT nie jest panelem danych: to cyfrowy mózg nowoczesnej firmy rolniczej. Gdy Grafana wykryje wilgotność gleby w winnicy A3 spada poniżej progu krytycznego, a Angular powiadamia operatora terenowego za pomocą czujnika dokładne i zalecenie działania, pętla percepcja-analiza-decyzja-działanie tak kompresuje od godzin (lub dni) do minut.
Stos opisany w tym artykule Mosquitto + Telegraf + InfluxDB + Grafana + Angular, oraz dojrzała, gotowa do produkcji kombinacja open source. Koszty infrastruktury dla średniej wielkości firmy (50-100 czujników) są rzędu 3 000-6 000 EUR rocznie, w porównaniu do korzyści agronomicznych, które w przypadku firmy Rossi przekroczyły 40 000 EUR rocznie już od pierwszego pełnego sezonu. ROI nigdy nie było tak jasne.
Kolejny krok dla firm, które wdrożyły podstawowy monitoring np theintegracja z modelami predykcyjnymi: Połącz dane czujnika z dane satelitarne (NDVI z Sentinel-2, o których mowa w artykule 3 z tej serii), Granulowane prognozy pogody i modele ML do prognozowania deficytu wody i plonów. To granica, w której dane IoT stają się predykcyjną inteligencją agronomiczną.
Podsumowanie techniczne
- Baza napływu: Projekt schematu z separacją znaczników/pól, wielopoziomowym wiadra, Flux do automatycznego próbkowania w dół
- Telegraf: Most MQTT-InfluxDB, parsowanie JSON, ekstrakcja tagów ze struktury tematu
- Grafana: Dostarczanie źródeł danych YAML, typy paneli dla wskaźników rolniczych, ujednolicone alerty z wieloma punktami kontaktowymi
- Sygnały kątowe: Zarządzanie stanem niezmiennym w czasie rzeczywistym, obliczane pochodne, WebSocket z automatyczną ponowną próbą
- wykresy ngx: Wykres liniowy, wykres warstwowy dla szeregów czasowych w czasie rzeczywistym z aktualizacją sterowaną sygnałem
- PWA: Service Worker Angular zapewniający świeżość strategii pamięci podręcznej w trybie offline dla najnowszych interfejsów API
- Tworzenie Dockera: Pełny stos 5 usług, zmienne środowiskowe, kontrola stanu, izolowana sieć
Następny artykuł z serii
Dotarliśmy do przedostatniego artykułu z serii FoodTech. w numer 10, ostatnim z serii, zajmiemy się Optymalizacja żywności w łańcuchu dostaw: jak modele ML optymalizują łańcuch dostaw żywności w celu ograniczenia marnotrawstwa, przewidywać popyt i koordynować logistykę i dystrybucję w sposób oparty na danych. Od optymalizacji tras dostaw po przewidywanie okresu przydatności do spożycia, artykuł łączący doskonałość agronomiczną z wydajnością przemysłową.
Poznaj resztę serii FoodTech
- Artykuł 1: Rurociąg IoT dla rolnictwa precyzyjnego – podstawy MQTT i Pythona
- Artykuł 2: ML Edge do monitorowania upraw – wizja komputerowa na polach
- Artykuł 3: Satelitarne API i NDVI z Pythonem i Sentinel-2 - otwarte dane satelitarne
- Artykuł 4: Możliwość śledzenia łańcucha bloków w żywności – od pochodzenia do konsumenta
- Artykuł 5: Wizja komputerowa w kontroli jakości w przemyśle spożywczym
- Artykuł 6: FSMA i zgodność cyfrowa – automatyzacja przepisów FDA/UE
- Artykuł 7: Rolnictwo wertykalne – Kontrola środowiska za pomocą Internetu Rzeczy i uczenia maszynowego
- Artykuł 8: Prognozowanie popytu na sprzedaż detaliczną żywności za pomocą Prophet i LightGBM







