Angular 및 Grafana를 사용한 농장 IoT용 실시간 대시보드
7월 밤 3시 47분입니다. 키안티 포도원의 센서가 온도를 기록합니다. 뿌리 수준의 토양이 4번째로 임계 임계값인 섭씨 28도를 초과했습니다. 연속 시간. 실시간 모니터링 시스템이 없어 농경제학자가 아침에 문제점을 발견 수동 검사 중 다음. 피해는 이미 발생했습니다: 급성 물 스트레스, 성숙 고르지 않게 가속되어 배치 수율이 12% 손실되는 것으로 추정됩니다.
경고에 연결된 실시간 대시보드를 사용하면 동일한 이벤트가 다음에 대한 알림을 생성합니다. 03:48에 전보. 자동 관개 시스템은 03:49에 작동됩니다. 06:00에, 농업경제학자가 깨어났을 때 그는 발생한 개입을 기록한 보고서를 발견합니다. 정확한 온도 및 현재 상태: 모든 것이 정상입니다. 반응성과 적극적 활동은 평균적으로 가치가 있습니다. 물 스트레스로 인한 손실의 15~30% 작물에 좋은 지중해 것들.
농장 인텔리전스 대시보드는 보고 도구가 아니라 적시 의사결정 시스템입니다. 진짜. 결합 그라파나 작동 디스플레이용, 인플럭스DB 시계열 저장의 경우 e 모난 맞춤형 모바일 우선 인터페이스의 경우, e 원시 센서 데이터를 데이터로 변환하는 농장 인텔리전스 플랫폼 구축이 가능합니다. 구체적인 농업적 조치. 이 기사에서는 MQTT 브로커부터 패널까지 전체 아키텍처를 다룹니다. 현장에서 농업경제학자의 전화를 제어합니다.
이 기사에서 배울 내용
- IoT 팜용 대시보드 시스템의 전체 아키텍처: 센서, InfluxDB, Grafana, Angular
- Flux 및 InfluxQL을 사용하여 농업 데이터에 최적화된 InfluxDB 스키마 설계
- Grafana 구성: 데이터 소스, 패널, 템플릿 변수, YAML 프로비저닝
- 연락처(이메일, Telegram, Slack), 경고 규칙 및 알림 정책으로 Grafana에 경고
- Angular 사용자 정의와 순수 Grafana를 사용하는 경우: 사용 사례 및 하이브리드 아키텍처
- WebSocket, 신호 및 ngx-charts가 포함된 실시간 차트를 갖춘 Angular IoT 서비스
- Docker Compose 전체 스택: Mosquitto + InfluxDB + Grafana + Angular
- 사례 연구: 50개의 센서와 6개의 플롯이 있는 와이너리
FoodTech 시리즈 - 모든 기사
| # | Articolo | 수준 | 상태 |
|---|---|---|---|
| 1 | Python 및 MQTT를 사용한 정밀 농업용 IoT 파이프라인 | 고급의 | 사용 가능 |
| 2 | 작물 모니터링을 위한 ML Edge: 현장의 컴퓨터 비전 | 고급의 | 사용 가능 |
| 3 | 위성 API 및 식생 지수: Python 및 Sentinel-2를 사용한 NDVI | 중급 | 사용 가능 |
| 4 | 식품의 블록체인 추적성: 현장에서 슈퍼마켓까지 | 중급 | 사용 가능 |
| 5 | 식품 산업의 품질 관리를 위한 컴퓨터 비전 | 고급의 | 사용 가능 |
| 6 | FSMA 및 디지털 규정 준수: 규제 프로세스 자동화 | 중급 | 사용 가능 |
| 7 | 수직 농업: IoT 및 ML을 통한 환경 제어 | 고급의 | 사용 가능 |
| 8 | Prophet 및 LightGBM을 사용한 식품 소매 수요 예측 | 중급 | 사용 가능 |
| 9 | Angular 및 Grafana를 사용한 농장 IoT용 실시간 대시보드(현재 위치) | 고급의 | 현재의 |
| 10 | 공급망 식품 최적화: 폐기물 감소를 위한 ML | 중급 | 사용 가능 |
실시간 대시보드가 농업을 변화시키는 이유
전통적인 농업은 긴 관찰 주기로 운영됩니다. 농업 경제학자가 현장을 방문합니다. 일주일에 한두 번 시각적 매개 변수를 감지하고 경험을 바탕으로 행동합니다. 이 모델은 환경 변수가 천천히 변할 때 잘 작동합니다. 그러나 위기 기후는 폭염, 갑작스러운 가뭄, 늦은 서리 등 변동성을 가속화했습니다. 돌발 홍수에는 주간 방문이 보장할 수 없는 응답 시간이 필요합니다.
경제 데이터는 이러한 결정 지연으로 인한 비용을 정량화합니다. 한 연구에 따르면 2024년 Wageningen University의 손실로 인한 손실 관개 없음 최적의 이는 작물의 잠재적 수확량의 15%에서 30% 사이에 위치합니다. 집중적인 과일과 채소. 고품질의 포도재배를 위해, Veraison은 포도에 영구적인 손상을 주어 20-35%의 감소를 가져올 수 있습니다. 그 부지의 상업적 가치. 와인 분야가 중요한 이탈리아에서는 연간 150억 유로 이상, 패시브 모니터링의 차이점 반응형은 매 시즌 위험에 처한 수억 유로의 가치를 나타냅니다.
실시간 모니터링의 경제적 영향: 비교
| 대본 | 실시간 없이 | 실시간으로 | 델타 |
|---|---|---|---|
| 연간 물 소비량 | 기준 100% | -25% ~ -40% | 상당한 비용 절감 |
| 수분 스트레스 손실 | 15-30% 수율 | 3-7% 수율 | +8-23% 회수된 수율 |
| 비료 비용 | 기준 100% | -20% ~ -35% | 변수 최적화 |
| 늦은 서리 손실 | 높음, 부분적으로 피할 수 있음 | 조기경보로 감소 | -60% 예방 가능한 피해 |
| 근무 시간 모니터링 | 주 8~12시간 | 주 1~2시간 | -80% 작동 시간 |
| IoT 시스템 ROI + 대시보드 | 해당 없음 | 18~36개월 회수 | 2~3시즌 긍정적 |
Grafana 플랫폼은 다음에서 사용됩니다. 애그리테크, 전문 기업입니다. 산업용 대마 모니터링, 70개가 넘는 측정 지점의 데이터 관리 팜당 환경, 60초마다 새로 고침. 문서화된 결과는 사전 모니터링 기준에 비해 생산량이 4배 증가했습니다. 투입 비용이 28% 감소합니다. 이와 같은 사례는 그 기술을 입증합니다. 실시간 대시보드의 비용은 IT 비용이 아니라 측정 가능한 수익을 제공하는 농업적 투자입니다.
대시보드팜 IoT 시스템 아키텍처
건축 설계는 첫 번째 중요한 단계입니다. 그 점에서는 나쁜 선택이다 레벨은 시스템 전체에 전파되므로 나중에 수정하는 데 비용이 많이 듭니다. 여기에 제시된 아키텍처는 실제 생산 환경과 규모에서 검증되었습니다. 아키텍처 변경 없이 10~10,000개의 센서를 사용할 수 있습니다.
엔드투엔드 아키텍처: 대시보드의 센서
┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────────┘
이 아키텍처의 핵심은 책임의 분리: Grafana는 기술 운영자를 위한 운영 시각화 및 경고를 담당합니다. Angular는 이를 필요로 하는 농업 경제학자와 현장 관리자를 위한 모바일 인터페이스를 제공합니다. 보다 안내되고 개인화된 경험을 제공합니다. 데이터는 항상 InfluxDB에서 다음과 같이 흐릅니다. 불일치를 방지하는 단일 진실 소스.
농업 데이터용 InfluxDB: 스키마 설계 및 쿼리
InfluxDB는 농업 IoT 애플리케이션을 위한 참조 시계열 데이터베이스입니다. 높은 처리량의 수집, 효율적인 압축 및 쿼리를 처리하는 기능 기본 뇌우. InfluxDB v2(오픈 소스), InfluxDB v3(OSS 새 아키텍처) 중에서 선택 Apache Arrow/Parquet 기반), InfluxDB Cloud는 배포 규모에 따라 다릅니다. 최대 200개의 센서가 있는 팜의 경우 자체 호스팅 InfluxDB v2 OSS이면 충분합니다.
농업 데이터를 위한 스키마 설계
InfluxDB의 기본 규칙: i 태그 색인이 생성되어 있습니다(다음 용도로 사용). 필터), 나 필드 그렇지 않습니다(숫자 값에 사용). 나쁜 놈 스키마 디자인은 프로덕션 성능 저하의 가장 큰 원인입니다.
# 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
경고: 높은 태그 카디널리티
태그에 타임스탬프, 임의 UUID, GPS 좌표 등 높은 카디널리티 값을 입력하지 마세요. 정확하거나 연속적인 숫자 값. 이는 품질을 저하시키는 "폭발적인 카디널리티"를 생성합니다. InfluxDB 성능이 기하급수적으로 증가합니다. 대신 높은 값을 입력하려면 필드를 사용하세요. GPS 좌표와 같은 가변성(반올림되거나 태그로 저장되어야 함) 영역에 대한 개별 값 포함).
MQTT 소비자를 위한 Telegraf 구성
Telegraf 및 InfluxData 데이터 수집기: MQTT 브로커 parsa i에 연결 JSON 페이로드를 작성하고 올바른 측정, 태그 및 필드 구성을 사용하여 InfluxDB에 기록합니다.
# telegraf.conf - MQTT Consumer per Farm IoT
[agent]
interval = "10s"
round_interval = true
metric_batch_size = 1000
metric_buffer_limit = 10000
flush_interval = "10s"
# Input: MQTT Broker
[[inputs.mqtt_consumer]]
servers = ["tcp://mosquitto:1883"]
topics = [
"farm/+/+/soil",
"farm/+/+/weather",
"farm/+/+/ph",
"farm/+/+/par"
]
username = "telegraf"
password = "{{ TELEGRAF_MQTT_PASSWORD }}"
qos = 1
client_id = "telegraf_farm_consumer"
data_format = "json"
json_name_key = ""
tag_keys = ["farm_id", "field_id", "sensor_id", "sensor_type"]
# Processore: aggiungi zone da sensor_id
[[processors.regex]]
[[processors.regex.tags]]
key = "sensor_id"
pattern = "soil_0[1-3]"
replacement = "zona_nord"
result_key = "zone"
# Output: InfluxDB v2
[[outputs.influxdb_v2]]
urls = ["http://influxdb:8086"]
token = "{{ INFLUXDB_TOKEN }}"
organization = "azienda_rossi"
bucket = "farm_raw"
timeout = "5s"
대시보드에 대한 Flux 쿼리
Flux 및 InfluxDB v2/v3 쿼리 언어. InfluxQL보다 더 표현력이 뛰어나고 다음을 지원합니다. 측정, 고급 통계 기능 및 선언적 다운샘플링을 결합합니다.
// 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")
보존 정책 및 다운샘플링
# 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: Farm IoT 설정 및 구성
Grafana는 산업용 IoT 환경을 위한 가장 인기 있는 시각화 및 경고 플랫폼입니다. 버전 11.x(2025)에서는 통합 알림이 크게 개선되었습니다. 지리적 패널(Geomap) 및 코드형 프로비저닝에서. IoT 팜 설치의 경우 자체 호스팅, OSS 버전이면 충분합니다. Enterprise 버전에는 SSO, 감사 기능이 추가됩니다. 다중 농장 기업 환경에서만 유용한 고급 독점 플러그인입니다.
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
대시보드 템플릿 변수
Grafana의 템플릿 변수를 사용하면 사용자가 대화형 대시보드를 만들 수 있습니다. 페이지 상단의 드롭다운에서 농장, 플롯, 시간 범위를 선택하세요.
# 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
}
]
}
}
농업용 대시보드 패널: 전체 가이드
각 측정항목에 적합한 패널 유형을 선택하는 것은 측정항목의 가독성을 위해 매우 중요합니다. 대시보드. 배터리 수준 및 덜 즉각적인 정보를 제공하는 "시계열" 유형 패널 "게이지" 또는 "통계"입니다. 농업 지표에 대한 최적의 패널 맵은 다음과 같습니다.
농업 지표에 대한 패널 유형
| 미터법 | 패널 유형 | perchè | 색상 임계값 |
|---|---|---|---|
| 토양/공기 온도 | 시계열 + 통계 | 시간 추세 + 현재 값 | 녹색 <25, 주황색 25-30, 빨간색 >30 |
| 토양 수분 | 게이지 + 시계열 | 게이지에는 HR 및 PWP 대비 %가 표시됩니다. | 빨간색 <25, 주황색 25-35, 녹색 35-70 |
| 토양 pH | 계량기 | 지키는 가치, 천천히 변화 | 빨간색 <5.5 또는 >7.5, 녹색 6.0-7.0 |
| PAR/DLI | 시계열 | 일별 변동성, 태양 패턴 | 임계값 없음, 유익함 |
| 강수량 | 막대 차트 | 일일 이산 데이터 | 임계값 없음, 유익함 |
| 수위 | 게이지 + 스텟 | 탱크 비율 + 사용 가능한 리터 | 빨간색 <20%, 주황색 20-40% |
| 센서 배터리 | 테이블 | 모든 센서 보기, 필터 중요 | 빨간색 <20%, 주황색 20-30% |
| 플롯 지도 | 지리지도 | 농장 상태의 지리적 표시 | 집계 상태별 색상 |
임계값이 있는 토양 수분 패널 구성
# Estratto JSON panel Gauge umidita suolo
{
"id": 3,
"title": "Umidita Suolo - ${sensor_id}",
"type": "gauge",
"datasource": "InfluxDB-FarmRaw",
"targets": [
{
"refId": "A",
"query": "from(bucket: \"farm_raw\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"farm_sensors\")\n |> filter(fn: (r) => r.farm_id == \"${farm_id}\")\n |> filter(fn: (r) => r._field == \"soil_moisture_pct\")\n |> last()\n |> group(columns: [\"sensor_id\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "red", "value": 0 },
{ "color": "orange", "value": 25 },
{ "color": "green", "value": 35 },
{ "color": "orange", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"mappings": [],
"custom": {
"neutralColors": false
}
}
},
"options": {
"reduceOptions": {
"values": false,
"calcs": ["lastNotNull"],
"fields": ""
},
"showThresholdLabels": true,
"showThresholdMarkers": true
}
}
플롯 시각화를 위한 지리지도 패널
# Panel Geomap - 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" }
}
}
]
}
}
Farm IoT에 대한 Grafana 알림: 전체 구성
경고 시스템은 대시보드의 가치 제안에 가장 중요한 구성 요소입니다. 물 스트레스로 인해 2시간 늦게 경보가 울리면 수천 유로의 피해를 입을 수 있습니다. 작물. Grafana Unified Alerting(Grafana 9에 도입되고 v10/v11에 통합됨)은 다음을 제공합니다. 구성 가능한 경고 규칙, 연락처 및 알림 정책을 갖춘 완벽한 시스템 전적으로 YAML(코드형 인프라)을 통해 이루어집니다.
연락처: 이메일, 텔레그램, 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 }}
심각한 농업 조건에 대한 경고 규칙
# 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."
알림 정책 및 침묵
# 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: 시기와 이유
Grafana는 운영 모니터링 사용 사례의 90%를 다루고 있습니다. 그러나 다음과 같은 시나리오가 있습니다. 맞춤형 Angular 대시보드와 올바른 선택:
Grafana 대 Angular 대시보드: 언제 무엇을 선택해야 할까요?
| 표준 | 퓨어 그라파나 | 각도맞춤 |
|---|---|---|
| 대상 사용자 | 기술자, 데이터 분석가 | 농업경제학자, 관리자, 현장 운영자 |
| 맞춤형 UX | 패널 유형으로 제한됨 | 무제한, 모바일 우선 |
| 비즈니스 로직 | 적합하지 않음 | 계산된 알림, AI 추천 |
| 기존 앱 통합 | 하드(iframe) | 네이티브 각도 |
| 오프라인 우선 PWA | 지원되지 않음 | 네이티브 서비스 워커 |
| 재생률 | 최소 1초(폴링) | WebSocket 1초 미만 |
| 개발 비용 | 낮음(구성) | 높음(맞춤 개발) |
| 유지 | 낮은 | 높은 |
기업 및 IoT 팜에 권장되는 전략 잡종: 그라파나(Grafana) 내부 기술 모니터링(IT, 운영, 고급 농업경제학) 및 모바일 앱용 Angular 현장의 운영자가 사용하는 것입니다. 두 플랫폼은 동일한 데이터 소스를 공유합니다. (InfluxDB) 및 Angular는 토큰 서명 iframe을 통해 특정 Grafana 패널을 포함할 수도 있습니다. 복제할 가치가 없는 복잡한 분석의 경우.
Angular 구현: IoT 서비스 및 실시간 대시보드
센서 데이터용 TypeScript 템플릿
// 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[];
}
WebSocket 및 Angular 신호를 사용한 IoT 서비스
// 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 } }
);
}
}
기본 대시보드 구성요소
// 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) }
]);
}
}
ngx 차트가 포함된 HTML 대시보드 템플릿
<!-- 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>
현장 운영자를 위한 모바일 우선 설계 및 PWA
현장 작업자는 연결 및 상태가 불안정한 경우가 많은 스마트폰을 사용합니다. 흰색 배경의 화면을 읽기 어렵게 만드는 강렬한 햇빛. 팜 대시보드의 모바일 우선 디자인에는 구체적인 고려 사항이 필요합니다. 기존 기업 대시보드에
/* 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; }
}
오프라인 우선을 위한 서비스 워커 PWA
// 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 풀 스택: 프로덕션 준비 배포
전체 스택(Mosquitto, InfluxDB, Telegraf, Grafana, Angular 앱)을 배포할 수 있습니다.
싱글로 docker compose up -d. 이것이 모든 일의 출발점이다
온프레미스 및 클라우드 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
성능 및 확장성: 쿼리 최적화 및 캐싱
50~200개의 센서가 30초마다 데이터를 전송하면서 InfluxDB의 데이터 양이 증가하고 있습니다. 빠르게: 하루에 약 500만~2000만 포인트. 최적화 전략이 없으면 대시보드 쿼리 속도가 느려지고 사용자 경험이 저하됩니다. 여기에 기술이 있습니다 적당한 하드웨어에서도 응답 시간을 500ms 미만으로 유지하는 것이 기본입니다.
IoT 대시보드 팜의 최적화 전략
| 기술 | 구현 | 영향 |
|---|---|---|
| 다중 계층 다운샘플링 | 1시간, 1일마다 작업 플럭스 | 10~50배 빠른 기록 쿼리 |
| 집계 푸시다운 | 메모리 내가 아닌 Flux의 집계 창 | 데이터 전송 감소 90%+ |
| 대시보드 쿼리 캐싱 | Grafana 캐시 레이어 + Redis | -70% 중복 쿼리 |
| 구체화된 뷰 | 사전 집계가 포함된 버킷 "farm_kpi" | 대시보드 로드 <100ms |
| 폴링 대신 WebSocket | Angular WebSocket과 HTTP 폴링 | -80% 프런트엔드 서버 로드 |
| 프로그레시브 로딩 | KPI를 먼저 로드하고 그래프는 나중에 로드하세요. | 체감 성능 +60% |
| 태그 카디널리티 제어 | 측정당 최대 1000개 시리즈 | 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"])
사례 연구: Rossi Winery - 센서 50개, 플롯 6개
Rossi Agricultural Company는 6개 지역에 분산된 35헥타르 규모의 Chianti Classico DOCG를 생산합니다. 다양한 토양 특성을 지닌 플롯. 시스템을 구현하기 전에 IoT 모니터링은 주간 농업경제학자 방문과 수동 측정을 기반으로 이루어졌습니다. 최적이 아닌 관개 및 늦은 서리로 인한 계절별 평균 손실이 추정되었습니다. 잠재 수익률의 약 18%입니다.
Rossi 회사 배포 사양
| 요소 | 세부 사항 |
|---|---|
| 총 센서 | 50(플롯당 8-9): 토양, 날씨, pH, PAR |
| 엣지 게이트웨이 | 6 Raspberry Pi 4(플롯당 1개), 4G 연결 |
| 무선 센서 프로토콜 | LoRaWAN(토양/pH), Zigbee(현지 날씨), RS-485(탱크) |
| MQTT 브로커 | Eclipse Mosquitto, VPS OVH 유로파, TLS 1.3 |
| 데이터베이스 | InfluxDB v2.7, VPS 4 vCPU / 8GB RAM, 200GB SSD |
| 새로고침 빈도 대시보드 | 30초 지상 데이터, 5초 중요 경보 |
| 데이터 볼륨 | ~350만 포인트/일, ~1.2GB/월 원시 |
| 그라파나 대시보드 | 4개의 대시보드: 농장 개요, 플롯별, 센서, 경고 기록 |
| 각도 모바일 앱 | Android의 현장 운영자가 사용하는 PWA |
| 경고 | 텔레그램(즉시 중요) + 일일 이메일 요약 |
| 전체 하드웨어 | 게이트웨이 6개 + 센서 50개 + 클라우드 VPS |
| 인프라 비용/년 | ~4,200 EUR(VPS + 대역폭 + 유지 관리) |
2시즌 후 측정 결과
절감액 및 ROI 지표 - Rossi Company(2024년 및 2025년 시즌)
| 미터법 | IoT 이전(평균 2022~2023) | 포스트 IoT(2024~2025) | 델타 |
|---|---|---|---|
| 관개수 소비량 | ~2,800m3/헥타르/계절 | ~1,750m3/헥타르/계절 | -37.5% |
| 수분 스트레스 손실 | ~18% 잠재 수율 | ~4% 잠재 수익률 | -78% 손실 |
| 늦은 서리 손실 | 가변적, 중요한 연도에는 0-15% | 0%(적시에 부동액 개입 7회) | 제거됨 |
| 비료 비용/ha | 기준 100% | 기준치의 73% | -27% |
| 모니터링 시간/주 | 12-15시간 농업경제학자 | 2~3시간(감독 전용) | -82% |
| 평균 포도밭 수확량 | 7.2톤/ha | 8.6톤/ha | +19.4% |
| 추가적인 생산 가치 | - | +€42,000/시즌 | ROI 회수 <18개월 |
가장 중요한 데이터는 (중요하긴 하지만) 물 절약이 아니라,제거 늦은 서리로 인한 손실로 가득 차 있음. 2024시즌에는 시스템이 대신했다. 3~4월 사이 기온이 2도 이하로 떨어지는 세 가지 이벤트가 자동으로 활성화됩니다. 텔레그램 알림은 02:15, 03:40, 01:20에 있습니다. 세 가지 경우 모두 직원은 농장에 거주하는 사람들은 농장 내의 서리 방지 시스템(잎이 많은 스프링클러)을 활성화했습니다. 20분. 개입이 없을 경우 예상되는 서리 피해는 약 15,000유로였습니다. 3월 이벤트에만 적용됩니다.
구현에서 얻은 교훈
- 토양 센서 교정: 용량성 수분 센서는 토양 구성에 민감합니다. 플러그 앤 플레이 설치뿐만 아니라 모든 유형의 지형에 현장 교정이 필요합니다.
- 병목 현상으로 인한 4G 연결: 언덕이 많은 지역에서는 4G 신호가 간헐적으로 연결될 수 있습니다. 게이트웨이의 로컬 버퍼(SQLite, 7일)는 데이터 손실을 방지하는 데 필수적인 것으로 입증되었습니다.
- 경고 피로: 첫 번째 구성에는 활성 임계값이 너무 많습니다. 2주 내에 운영자들은 알림을 무시하기 시작했습니다. 몇 가지 중요한 경고로 시작하여 점차적으로 더 많은 경고를 추가하는 것이 중요합니다.
- 운영자 교육: Angular 모바일 앱을 채택하려면 2시간짜리 교육 세션이 3번 필요했습니다. 훈련에 대한 투자는 기술 투자만큼 중요합니다.
- 센서 유지 관리: 포도재배 환경의 토양 센서는 분기별로 청소(처리 잔여물)가 필요합니다. 예방적 유지보수 일정을 계획하세요.
IoT 팜 시스템의 보안: 모범 사례
인터넷에 연결된 농업 IoT 시스템은 보호되지 않으면 공격 벡터가 됩니다. 적절하게. 공격 표면에는 노출된 MQTT 브로커, InfluxDB API, Grafana 인터페이스, Angular 앱. 다음 조치는 최소한의 필요 사항입니다. 프로덕션 배포의 경우.
IoT Farm 보안 체크리스트
| 영역 | 측정하다 | 우선 사항 |
|---|---|---|
| MQTT 브로커 | 각 장치에 대한 TLS 1.3 + mTLS 인증서 | 비판 |
| MQTT 인증 | 장치별 고유 사용자 이름/비밀번호 + 주제별 ACL | 비판 |
| 인플럭스DB | Grafana 및 Angular 앱에 대한 별도의 읽기 전용 토큰 | 높은 |
| 인플럭스DB | 포트 8086을 인터넷에 노출하지 마십시오. 내부 네트워크에만 노출하십시오. | 비판 |
| 그라파나 | OAuth2/SAML 인증, 익명 로그인 비활성화 | 높은 |
| 그라파나 | 속도 제한이 있는 Nginx 역방향 프록시 | 높은 |
| 각도 앱 | HTTPS 항상, HSTS 헤더, CSP | 높은 |
| VPS/서버 | 방화벽 전용 포트 필요, Fail2ban, 자동 업데이트 | 비판 |
| 기미 | 하드코딩되지 않은 환경 변수, 분기별 순환 | 비판 |
| 엣지 게이트웨이 | 자동 펌웨어 업데이트, 클라우드에 대한 VPN | 높은 |
결론: 농장의 결정 센터로서의 대시보드
잘 설계된 IoT 대시보드 팜은 데이터 대시보드가 아니라 디지털 두뇌입니다. 현대 농업 회사의. Grafana가 포도원의 토양 습도를 감지하는 경우 A3가 임계 임계값 아래로 떨어지면 Angular는 센서를 통해 현장 운영자에게 알립니다. 정확하고 조치 권장 사항, 인식-분석-결정-조치 루프 예 몇 시간(또는 며칠)에서 몇 분으로 압축됩니다.
이 기사에서 설명하는 스택은 Mosquitto + Telegraf + InfluxDB + Grafana + Angular, 성숙하고 생산 준비가 완료된 오픈 소스 조합입니다. 인프라 비용 중견 기업(센서 50~100개)의 가격은 3,000~6,000 EUR 정도입니다. Rossi 회사의 경우 연간 농업적 이익이 초과된 것과 비교하여 첫 번째 풀 시즌부터 연간 40,000 EUR입니다. ROI가 그 어느 때보다 명확해졌습니다.
기본 모니터링을 구현한 기업의 다음 단계 는예측 모델과의 통합: 센서 데이터를 다음과 병합합니다. 위성 데이터(Sentinel-2의 NDVI, 이 시리즈의 기사 3에 표시됨), 물 스트레스 및 수확량 예측을 위한 세부적인 일기 예보 및 ML 모델입니다. 이는 IoT 데이터가 예측 가능한 농업 지능이 되는 최전선입니다.
기술 요약
- 인플럭스DB: 태그/필드 분리, 다중 계층 버킷, 자동 다운샘플링을 위한 Flux를 갖춘 스키마 설계
- 전신: MQTT-InfluxDB 브리지, JSON 구문 분석, 주제 구조에서 태그 추출
- 그라파나: 데이터 소스 프로비저닝 YAML, 농업 지표용 패널 유형, 여러 접점을 통한 통합 경고
- 각도 신호: 실시간 불변 상태 관리, 계산된 파생 상품, 자동 재시도 기능이 있는 WebSocket
- ngx 차트: 신호 기반 업데이트를 통한 실시간 시계열용 꺾은선형 차트, 영역 차트
- PWA: 최신 API에 대한 오프라인 우선 캐시 전략 최신성을 위한 Service Worker Angular
- 도커 작성: 풀스택 5개 서비스, 환경변수, 헬스체크, 격리된 네트워크
시리즈의 다음 기사
FoodTech 시리즈의 두 번째 기사에 도달했습니다. 에서 번호 10, 시리즈의 마지막 문제인 공급망 식품 최적화: ML 모델이 식품 공급망을 최적화하여 폐기물을 줄이는 방법 데이터 기반 방식으로 수요를 예측하고 물류 및 유통을 조정합니다. 배송경로 최적화부터 유통기한 예측까지, 농업적 우수성과 산업적 효율성을 연결하는 기사입니다.
나머지 FoodTech 시리즈 살펴보기
- 기사 1: 정밀 농업을 위한 IoT 파이프라인 - MQTT 및 Python 기반
- 기사 2: 작물 모니터링을 위한 ML Edge - 현장의 컴퓨터 비전
- 기사 3: Python 및 Sentinel-2를 사용한 위성 API 및 NDVI - 개방형 위성 데이터
- 조항 4: 식품의 블록체인 추적성 - 원산지에서 소비자까지
- 기사 5: 식품 산업의 품질 관리를 위한 컴퓨터 비전
- 조항 6: FSMA 및 디지털 규정 준수 - FDA/EU 규제 자동화
- 7조: 수직 농업 - IoT 및 ML을 통한 환경 제어
- 기사 8: Prophet 및 LightGBM을 이용한 식품 소매 수요 예측







