Angular と Grafana を使用したファーム IoT のリアルタイム ダッシュボード
7月のある夜の午前3時47分。キャンティのブドウ畑のセンサーがその温度を記録する 根の高さの土壌が4回目の臨界値である摂氏28度を超えた。 連続1時間。リアルタイム監視システムがなければ、農学者は朝に問題を発見する 手動検査中に次のことを行います。被害はすでに発生しています: 急性水ストレス、成熟 不均一に加速し、バッチ収率で 12% の損失が推定されました。
アラートにリンクされたリアルタイム ダッシュボードを使用すると、同じイベントによって通知が生成されます。 03:48の電報。自動灌水システムは 03:49 に作動します。 06:00に、 農学者が目を覚ますと、行われた介入、つまり曲線を記録した報告書を見つけます。 正しい温度と現在のステータス: すべて正常です。この違いは、反応性と 積極性は平均して価値があります 水ストレスによる損失の 15 ~ 30% 作物で 素晴らしい地中海のもの。
ファーム インテリジェンス ダッシュボードはレポート ツールではありません。オンタイムの意思決定システムです。 本当の。組み合わせる グラファナ 動作表示用、 流入DB 時系列ストレージの場合、e 角度のある カスタムモバイルファーストインターフェイスの場合、e 生のセンサーデータを変換するファームインテリジェンスプラットフォームを構築することが可能です。 具体的な農業活動。この記事では、MQTT ブローカーからパネルまでのアーキテクチャ全体について説明します。 現場の農学者の携帯電話で制御できます。
この記事で学べること
- IoT ファーム用のダッシュボード システムの完全なアーキテクチャ: センサー、InfluxDB、Grafana、Angular
- Flux と InfluxQL を使用して農業データ用に最適化された InfluxDB スキーマ設計
- Grafana 構成: データソース、パネル、テンプレート変数、YAML プロビジョニング
- 連絡先 (電子メール、テレグラム、Slack)、アラート ルール、通知ポリシーを使用した Grafana へのアラート
- Angular カスタムと純粋な Grafana を使用する場合: ユースケースとハイブリッド アーキテクチャ
- WebSocket、Signals、および ngx-charts を使用したリアルタイム チャートを使用した Angular IoT サービス
- Docker Compose フルスタック: Mosquitto + InfluxDB + Grafana + Angular
- ケーススタディ: 50 個のセンサーと 6 つの区画を持つワイナリー
フードテックシリーズ - すべての記事
| # | アイテム | レベル | Stato |
|---|---|---|---|
| 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 | 中級 | 利用可能 |
リアルタイム ダッシュボードが農業を変える理由
伝統的な農業は長い観察サイクルで行われます: 農学者が現場を訪問します 週に 1 ~ 2 回、視覚パラメータを検出し、経験に基づいて行動します。 このモデルは、環境変数がゆっくりと変化する場合にうまく機能します。しかし、危機 気候の不安定性が加速:熱波、突然の干ばつ、遅霜 また、鉄砲水には対応時間が必要であり、毎週の訪問では保証できません。
経済データは、この意思決定の待ち時間のコストを定量化します。研究によると 2024年にヴァーヘニンゲン大学を卒業、損失は以下に起因する 灌漑なし 最適な それらは作物の潜在的な収量の 15% から 30% に相当します。 集中的な果物と野菜。高品質のブドウ栽培のためには、この時期の水ストレスが必要です。 ヴェレゾンはブドウに永久的なダメージを与え、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 Consumer の 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: ファーム IoT のセットアップと構成
Grafana は、産業用 IoT 環境向けの最も人気のある視覚化およびアラート プラットフォームです。 バージョン 11.x (2025) では、統合アラートが大幅に改善されました。 地理パネル (Geomap) およびコードとしてのプロビジョニング。 IoT ファームのインストールの場合 セルフホスト型の場合、OSS バージョンで十分です。エンタープライズ バージョンには 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
}
]
}
}
農業用ダッシュボード パネル: 完全ガイド
各メトリクスに適切なタイプのパネルを選択することは、メトリクスを読みやすくするために非常に重要です。 ダッシュボード。バッテリー残量と即時性を示す「時系列」タイプのパネル 「ゲージ」や「ステータス」のこと。これは、農業指標に最適なパネルのマップです。
農業指標のパネルの種類
| メトリック | パネルの種類 | なぜ | 色のしきい値 |
|---|---|---|---|
| 土壌/気温の温度 | 時系列 + 統計 | 時間トレンド+現在値 | 緑 <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" }
}
}
]
}
}
ファーム 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 ダッシュボードのカスタム: いつ、そしてなぜ
Grafana は、運用監視のユースケースの 90% をカバーします。しかし、次のようなシナリオもあります。 カスタム Angular ダッシュボードと正しい選択:
Grafana と Angular ダッシュボード: いつ何を選択するか
| 基準 | ピュアグラファナ | 角度カスタム |
|---|---|---|
| 対象ユーザー | 技術者、データアナリスト | 農学者、管理者、現場オペレーター |
| カスタムUX | パネル種類限定 | 無制限、モバイルファースト |
| ビジネスロジック | 不適切 | 計算されたアラート、AI による推奨事項 |
| 既存のアプリの統合 | ハード (iframe) | ネイティブ角度 |
| オフラインファースト PWA | サポートされていません | ネイティブサービスワーカー |
| リフレッシュレート | 最小 1 秒 (ポーリング) | WebSocket 1 秒未満 |
| 開発費 | 低 (構成) | 高 (カスタム開発) |
| メンテナンス | 低い | 高い |
エンタープライズおよび IoT ファームに推奨される戦略 ハイブリッド: のためのグラファナ 内部技術監視 (IT、運用、高度な農学) とモバイル アプリ用の Angular 現場のオペレーターが使用します。 2 つのプラットフォームは同じデータ ソースを共有します (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-charts を使用した 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; }
}
オフラインファースト向けの Service Worker 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 のデータ量は増加しています 迅速: 1 日あたり約 500 ~ 2000 万ポイント。最適化戦略がなければ、 ダッシュボードのクエリが遅くなり、ユーザー エクスペリエンスが低下します。ここにテクニックがあります これは、小規模なハードウェアでも応答時間を 500 ミリ秒未満に維持するための基本です。
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 ワイナリー - 50 個のセンサー、6 つのプロット
ロッシ農業会社は、6 つの地域にまたがる 35 ヘクタールでキャンティ クラシコ DOCG を生産しています。 異なる土壌特性を持つ区画。システムを導入する前に IoT のモニタリングは、毎週の農学者の訪問と手動測定に基づいていました。 最適ではない灌漑と遅霜による季節ごとの平均損失を推定しました 潜在的な収量の約18%。
Rossi Company の導入仕様
| 成分 | 詳細 |
|---|---|
| センサーの総数 | 50 (プロットあたり 8 ~ 9): 土壌、天候、pH、PAR |
| エッジゲートウェイ | Raspberry Pi 4 6 台 (プロットごとに 1 台)、4G 接続 |
| ワイヤレスセンサープロトコル | LoRaWAN (土壌/pH)、Zigbee (地域気象)、RS-485 (タンク) |
| MQTT ブローカー | Eclipse モスキート、VPS OVH ヨーロッパ、TLS 1.3 |
| データベース | InfluxDB v2.7、VPS 4 vCPU / 8GB RAM、200GB SSD |
| リフレッシュ レート ダッシュボード | 30 秒の地上データ、5 秒の重大なアラート |
| データ量 | ~350 万ポイント/日、~1.2 GB/月 (生) |
| グラファナダッシュボード | 4 つのダッシュボード: 農場の概要、プロットごと、センサー、アラート履歴 |
| Angularモバイルアプリ | PWA、Android 上の現場オペレータによって使用される |
| 警告 | Telegram (即時重要) + 毎日の電子メール ダイジェスト |
| ハードウェア全体 | 6 ゲートウェイ + 50 センサー + クラウド VPS |
| インフラストラクチャコスト/年 | ~4,200 ユーロ (VPS + 帯域幅 + メンテナンス) |
2シーズン後に測定された結果
節約と ROI の指標 - Rossi Company (2024 年と 2025 年のシーズン)
| メトリック | IoT 以前 (平均 2022 ~ 2023 年) | ポストIoT (2024-2025) | デルタ |
|---|---|---|---|
| 灌漑用水の消費量 | ~2,800 m3/ヘクタール/シーズン | ~1,750 m3/ヘクタール/季節 | -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 度を下回る現象が 3 回発生し、自動的に活動が開始されます。 02:15、03:40、01:20 に電報通知。 3 つのケースすべてにおいて、従業員は 農場に住んでいる人々は、農場内の霜防止システム(葉の上のスプリンクラー)を作動させています。 20分。介入がなかった場合の推定凍害は約 15,000 ユーロであった 3月のイベント限定。
実装から学んだ教訓
- 土壌センサーの校正: 容量性水分センサーは土壌の組成に敏感です。現場でのキャリブレーションは、プラグアンドプレイの設置だけでなく、あらゆる種類の地形に対して必要です。
- ボトルネックとしての 4G 接続: 丘陵地帯では、4G の通信範囲が断続的になる場合があります。データ損失を回避するには、ゲートウェイ上のローカル バッファー (SQLite、7 日間) が不可欠であることが判明しました。
- アラート疲労: 最初の構成にはアクティブなしきい値が多すぎました。 2 週間以内に、オペレーターは通知を無視し始めました。いくつかの重要なアラートから始めて、徐々に追加することが重要です。
- オペレーターのトレーニング: Angular モバイル アプリを導入するには、2 時間のトレーニング セッションが 3 回必要でした。トレーニングへの投資は、技術への投資と同じくらい重要です。
- センサーのメンテナンス: ブドウ栽培環境の土壌センサーは四半期に一度の洗浄(処理残留物)が必要です。予防保守ラウンドをスケジュールします。
IoT ファーム システムのセキュリティ: ベスト プラクティス
インターネットに接続された農業 IoT システムは、保護されていないと攻撃ベクトルになります 十分に。攻撃対象領域には、公開された MQTT ブローカー、InfluxDB API、 Grafana インターフェイス、Angular アプリ。以下の対策は最低限必要です 本番展開の場合。
IoT ファームのセキュリティ チェックリスト
| エリア | 測定 | 優先度 |
|---|---|---|
| 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 ユーロ程度です。 年間当たりの農業利益と比較して、ロッシ社の場合はそれを上回っていました。 最初のフルシーズンからすでに年間40,000ユーロ。 ROI がこれほど明確になったことはありません。
基本的なモニタリングを導入した企業の次のステップは、 の予測モデルとの統合: センサーデータをマージします 衛星データ (Sentinel-2 の NDVI、このシリーズの記事 3 で参照)、 水ストレスと収量予測のための詳細な天気予報と ML モデル。 それは、IoT データが予測的な農業インテリジェンスとなるフロンティアです。
技術概要
- 流入DB: タグ/フィールド分離、多層バケット、自動ダウンサンプリング用の Flux を備えたスキーマ設計
- 電信: MQTT-InfluxDB ブリッジ、JSON 解析、トピック構造からのタグ抽出
- グラファナ: データソース プロビジョニング YAML、農業指標のパネル タイプ、複数のコンタクト ポイントによる統合アラート
- 角度信号: リアルタイムの不変状態管理、計算された導関数、自動再試行を備えた WebSocket
- ngx チャート: シグナル駆動の更新によるリアルタイム時系列の折れ線グラフ、面グラフ
- PWA: Service Worker Angular によるオフラインファースト、最新 API のキャッシュ戦略の鮮度
- Docker Compose: フルスタック 5 サービス、環境変数、ヘルスチェック、隔離されたネットワーク
シリーズの次の記事
FoodTechシリーズの最後から2番目の記事に到達しました。で 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 を使用した食品小売の需要予測







