はじめに

第1回で基板を作り、第2回でファームウェアを書き、第3回で受け皿のサーバーを立てました。データは 60 秒ごとに InfluxDB へ積み上がっています。

ただし、今のままでは何も見えません。 データベースの中身を確認するにはクエリを叩くしかなく、「今の部屋の CO2 は何 ppm か」を知るために毎回ターミナルを開くようでは、そもそも測る意味がありません。

最終回は、この数字を1画面にします。使うのは Grafana。8つのパネルで、最新値・推移・閾値超え・機体の死活・センサーの健全性まで一望できるダッシュボードを組みました。

🗓️ 連載「自宅センサー基盤」#4(全4回)

自作のセンサー基板から Grafana のダッシュボードまで、自宅の環境を測る仕組みを丸ごと作る連載です。基板 → ファームウェア → サーバー → 可視化の4回で、1枚の基板が部屋のグラフになるところまで通します。

  • 第1回 基板設計・製造 — KiCad で回路図を描いて JLCPCB に発注し、全部手はんだで組む
  • 第2回 ファームウェア — ESP-IDF でセンサ3種のドライバを自前で書き、MQTT で飛ばす
  • 第3回 サーバー — Mosquitto・Telegraf・InfluxDB の受け皿を Docker で立てる
  • 第4回 ダッシュボード(この記事)— Grafana の8パネルで、部屋の空気を1画面にする
flowchart LR A["第1回 基板設計・製造
KiCad / JLCPCB / 手はんだ"] --> B["第2回 ファームウェア
ESP-IDF / I2Cドライバ
Wi-Fi + MQTT送信"] B --> C["第3回 サーバー
Mosquitto / Telegraf
InfluxDB"] C --> D["第4回 ダッシュボード
(この記事)
Grafana / Flux"] style D fill:#e3f2fd

📝 この記事でやること

  • Grafana とは何か・Flux クエリはどう読むのかを、パネルを作る前に整理する
  • 新しい Grafana は立てない。既存の監視スタックに相乗りする構成にした理由
  • 8パネルを1枚ずつ、「何を・なぜその集計で見せるか」まで開く。 平均で見るか最大で見るか、閾値を線で引くか色で塗るか、まで
  • 第2回で決めた「欠測はキーごと消す」が、グラフの穴としてそのまま出るという呼応
  • 基板が増えてもパネルを1つも編集しないための変数と Repeat
  • 60 秒送信に合わせて、画面側を1分解像度に揃える細工
  • 可視化を作ってはじめて、取り込み設計の穴に気づいた
  • どこまでが自動で、どこからが人間の手作業なのか — データソースの登録と Import だけは、画面から手で入れている
✅ どこまでできているか

ダッシュボードの JSON は完成し、そこに書かれた Flux クエリ 17 本はすべて実データに対して実行して確認済みです。 そして、Grafana へのデータソース登録とダッシュボードの取り込み(Import)だけは人間が画面で操作するしかない部分でしたが、そこも済ませてあり、いまは実データで動いています。

なぜそこだけ手作業なのかは、後半の「ここまでが自動、ここからは手作業」で書きます。画面写真は次章に1枚、8パネルのうち上の4枚が写ったものを載せます。


🧭 前提:Grafana と Flux

パネルの話に入る前に、道具の説明を先にします。すでに Grafana を使っている方は次章まで飛ばしてください。

Grafana — データを持たない「見る専用」のツール

Grafana はダッシュボードのツールです。 そして重要なのは、Grafana 自身はデータを1バイトも持たないことです。

グラフを描くたびに、Grafana は裏側のデータベースへクエリを投げます。返ってきた表を線や数字に変換して並べる。それだけの役割です。データベースのほうは何でもよく、InfluxDB でも Prometheus でも MySQL でも、対応する「データソース」を登録すれば同じ画面に混ぜて置けます。

この「持たない」性質は、実際に運用するときに効いてきます。

性質 何が起きるか
データは DB 側にある ダッシュボードを消しても測定データは消えない。作り直せばよい
画面は JSON 1ファイル ダッシュボードの構成はテキストで持ち出せる。バックアップも差分も取れる
描画のたびにクエリ 表示期間を変えれば、その都度 DB に問い合わせが飛ぶ。重いクエリは重い画面になる

3つめは今回の設計にも関係します。「軽いクエリを8本並べる」ことを意識して組んでいます。

第3回で作った InfluxDB との関係

第3回で立てたのは、この3つでした。Mosquitto(受け取る)・Telegraf(仕分ける)・InfluxDB(貯める)。今回の Grafana は、その一番奥の InfluxDB を読むだけです。

flowchart LR S["センサー基板
60秒ごとに送信"] -->|MQTT| M["Mosquitto"] M --> T["Telegraf"] T --> I[("InfluxDB
bucket: home_sensors
365日保持")] I -->|"read専用トークン
Fluxクエリ"| G["Grafana
8パネル"] style G fill:#e3f2fd style I fill:#fff3e0

読み書きの権限もそこで切ってあります。Grafana に渡すトークンは home_sensors バケットの read だけを持つもので、書き込みは 403 で拒否されます。ダッシュボード側から測定データを壊せないようにしてあるわけです(トークンをスコープで縛る話は第3回に書きました)。

データの中身も第3回のままです。

項目
bucket home_sensors(保持 365 日)
measurement env(測定値・数値のみ)/ device_state(状態・文字列)
tag device_id のみ
time サーバーの受信時刻

tag が device_id しかない、というのが今回ずっと効いてきます。「リビング」「寝室」といった人間が読む名前は DB に入っていません。名前は表示層、つまり Grafana 側で当てる約束になっています。

Flux クエリの読み方(最低限)

InfluxDB 2 系のクエリ言語が Flux です。SQL とは見た目がまるで違いますが、パイプで繋いだ変換の列だと思えば読めます。今回のダッシュボードで実際に使っているクエリは、ほぼ全部この5行の形をしています。

from(bucket: "home_sensors")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "env" and r._field == "co2")
  |> filter(fn: (r) => r.device_id =~ /^${device:regex}$/)
  |> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
  |> keep(columns: ["_time", "_value", "device_id"])

上から順に、こう読みます。

やっていること
from(bucket:) どの入れ物から取るか
range(start:, stop:) 時間の範囲v.timeRangeStart には Grafana の時間ピッカーの値が入る
filter(_measurement / _field) どの種類の・どの項目か(ここでは envco2
filter(device_id) どの機体か${device:regex} は画面で選んだ機体に展開される(後述)
aggregateWindow(every:, fn:) 時間の窓で区切って集計する。1分ごとに平均、など
keep(columns:) 使う列だけ残す。転送量と描画の無駄を削る
💡 ワード解説:aggregateWindow と createEmpty

aggregateWindow は「1分ごと」「5分ごと」といったでデータを区切り、窓ごとに1点へまとめる関数です。24 時間ぶんの点を全部そのまま描くと重いので、表示期間に応じて窓を広げ、点の数を一定に保つのが定石になっています。

createEmpty は、データが1点も無かった窓をどう扱うかの指定です。true なら「空の点」を作り、false ならその窓ごと結果に出しません。今回は全パネルで false にしています。理由は後の「欠測はグラフの穴として出す」で書きます。

keep を毎回書いているのは趣味ではありません。Flux の結果には _start / _stop / _measurement など使わない列がずらりと付いてきます。そのまま返すと、系列名がその全部の組み合わせで作られて凡例が崩れるうえ、転送量も無駄になります。必要な列だけ残すことが、そのまま凡例の設計になるわけです。


🏗️ Grafana をどこで動かすか

Grafana を使うと決めたら、次に決めるのはどこで動かすかです。分かれ道は2つあります。センサー用に新しく1つ立てるか、すでに動いている Grafana に相乗りするか。

サーバーに Grafana がまだ1つも無いなら、選択肢は前者しかありません。すでに1つ動いているなら、比較はこうなります。

選択肢A:センサー用の Grafana を新しく立てる 選択肢B:既存の Grafana に相乗り
コンテナ数 +1(メモリも1本ぶん増える) ±0
ポート もう1つ開ける必要がある 不要
ログイン 増える 既存のまま
他のダッシュボードとの並び 別画面に分かれる 同じ画面から辿れる
影響範囲 隔離されている 既存 Grafana の設定に1つ足す

相乗りのデメリットは「既存環境を触る」ことです。ただし足すのはデータソース1つで、既存のデータソースやダッシュボードの定義には手を入れません。影響範囲は、その1項目ぶんに収まります。

今回の環境:監視用の Grafana が、すでに1つ動いていた

このサーバー(Ubuntu 26.04・Docker)には、センサーとは無関係の監視スタックが前から動いていて、そこに Grafana 13.0.2 が居ました。Prometheus をデータソースにした、サーバー自身を見るためのダッシュボードです。

第3回で足した Mosquitto・Telegraf・InfluxDB は、その監視スタックとは別の Compose プロジェクトです。それでも同じ Docker ネットワークに参加させてあるので、既存の Grafana からそのまま InfluxDB を見にいけます。

そこで選択肢Bを採りました。Grafana のコンテナは1つも増えていません。 実際にやったのはデータソースを1つ追加することだけで、既存の Prometheus データソースには触っていません。

接続の向き先は、ホスト側のポートではなくコンテナ間の名前です。

Grafana → http://influxdb:8086 → InfluxDB

第3回で InfluxDB のポートは 127.0.0.1:8086(ループバックのみ)に絞りました。Grafana は同じ Docker ネットワークの内側に居るので、ホストにポートを公開しなくても Compose の内部 DNS で届きます。 外に開ける必要がない通信を外に出さない、というだけの話ですが、こういう小さい判断の積み重ねが攻撃面を決めます。

💡 ワード解説:Compose の内部 DNS

Docker Compose は、同じネットワークに属するサービスに対してサービス名で引ける名前解決を用意します。influxdb というサービス名がそのままホスト名になり、http://influxdb:8086 で届く。IP アドレスを固定する必要も、ホスト側にポートを公開する必要もありません。

逆に言うと、別の Compose プロジェクトからは既定では届きません。 今回は既存スタックと同じネットワークに参加させることで解決しています。

Grafana の管理画面自体もサーバーのループバック(:3001)にしか開いていないので、操作するときは SSH トンネルを張って手元のブラウザから触ります。

ssh -L 3001:127.0.0.1:3001 <サーバー>

第3回の InfluxDB 管理 UI と同じ流儀です。外から触れる管理画面を増やさない。 この一点だけは、最初から崩さないようにしています。


📊 8パネルをどう決めたか

ダッシュボードは Home Sensors という1枚もので、既定の表示範囲は直近 24 時間、自動更新は 1分にしてあります。

Grafana 13.0.2 のダッシュボード Home Sensors。上から最新値の stat パネル、温湿度トレンドの4本の折れ線、気圧・照度と CO2 の2枚が並び、CO2 は 1170 ppm と赤く表示されている

Import が済んで、実データが流れている状態。8パネルのうち、この画面に写っているのは上の4枚(①最新値・②温湿度トレンド・③気圧・照度・④CO2)で、残る4枚(粉塵・デバイス死活・センサー健全性・電波稼働)はこの下に続く。第3回に載せた InfluxDB の Data Explorer が「データが DB に着地している」証拠だったのに対して、こちらは「着いたデータが読める形になっている」ほう。上端に並ぶ3つのドロップダウンが、後で説明する変数 ds / device / discovered。時間範囲は既定の 24 時間から Last 6 hours に絞って撮っている

この1枚だけでも、ここまでの設計の答え合わせがいくつか写っています。

  • CO2 が実測で 1000ppm を超えて赤い。 検証用に流し込んだダミーではなく、実機 sensor_001 を置いた部屋の実測値です。右下のパネルで面が赤く塗られ、左上の現在値も赤い 1170 ppm になっています
  • 温湿度の4本が、1度も途切れずに描かれている。 60 秒ごとの送信が、そのまま切れ目のない線として出ている状態です
  • 温度2本が 1〜2℃ ずれたまま、同じ形で動いている。 第1回で温湿度を2系統ダブらせた判断の答え合わせで、これは②の節で詳しく書きます
  • 21 時前後に4本そろって振れている。 片方だけが跳ねていないので、これはセンサーの異常ではなく、部屋のほうで何かが起きたイベントです

パネルを並べる前に、方針を3つ決めました。

📌 パネル設計の3方針
  1. 異常に「気づける」ことを最優先にする。 きれいなグラフより、閾値を超えたら赤くなること・機体が黙ったら分かることを優先する
  2. 「今の値」と「推移」を両方置く。 数字だけでは変化が分からず、線だけでは現在値が読み取りにくい
  3. データが無いことも情報として見せる。 欠測を勝手に埋めない。穴は穴のまま描く

その結果が、この8パネルです。

# パネル 種別 見せるもの 主な集計
1 最新値 stat 温度・湿度・気圧・照度・CO2 の現在値 最後の値
2 温湿度トレンド timeseries BME280 系と SEN63C 系の重ね描き 平均
3 気圧・照度 timeseries 左軸 hPa / 右軸 lx 平均
4 CO2 timeseries 800 で黄・1000ppm で赤 平均
5 粉塵 timeseries PM1.0 / PM2.5 / PM4.0 / PM10 の4系列 平均
6 デバイス死活 table 最新 status +最終受信からの経過秒 最後の値+現在時刻との差
7 センサー健全性 table ok / error / absent の色分け 最後の値
8 電波・稼働 timeseries RSSI(左軸)/ uptime(右軸) 平均・最大

画面上の配置はこうです。上ほど「一目で見るもの」、下ほど「気になったときに見るもの」を置いています。

左半分 右半分
1段目 ① 最新値(機体ごとに自動で増える)
2段目 ② 温湿度トレンド(横幅いっぱい)
3段目 ③ 気圧・照度 ④ CO2
4段目 ⑤ 粉塵 ⑥ デバイス死活
5段目 ⑦ センサー健全性 ⑧ 電波・稼働

以下、1枚ずつ「何を・なぜその集計で」を開いていきます。

① 最新値 — 「今」だけを大きく出す

温度・湿度・気圧・照度・CO2 の5つの現在値を、大きな数字で並べる stat パネルです。集計は lastNotNull(最後の非 null 値)

「最新値なんだから最後の点でいいのでは」と思うところですが、last ではなく lastNotNull にしています。 直近の窓がたまたま空だったときに、パネルが「No data」になって全部消えるのを避けるためです。60 秒間隔の送信なので、Wi-Fi が数分切れただけでもこれは起こります。少し前の値でいいから出し続けるほうが、この用途では正しい。

5つの値はそれぞれ単位と小数点以下の桁数を個別に指定しています。

表示名 単位 小数
温度 (SEN-C) °C 1
湿度 (SEN-C) % 1
気圧 hPa 1
照度 lx 0
CO2 ppm 0

温湿度は2系統ありますが、現在値として大きく出すのは SEN63C 側の1本ずつにしました。ここに4つ並べても「今この部屋は何℃か」は読みやすくなりませんし、2系統を突き合わせるのは次のパネル2の仕事だからです。

照度と CO2 だけ小数を切っているのは、この2つが「1桁の差に意味がない」量だからです。照度は数百 lx の単位でふらつきますし、CO2 も数十 ppm の差は気にする値ではありません。小数を出すと、そこに精度があるように見えてしまいます。 温度と湿度は 0.1 刻みに意味があるので残しました。

そして CO2 の数字にだけ、800 で黄・1000 で赤という色を付けてあります。ここは次の話に繋がります。

② 温湿度トレンド — 2系統をあえて重ねる

この基板には温湿度センサーが2つ載っています。BME280 と、粉塵センサー SEN63C の内蔵温湿度計です。第1回で「ダブっているのは判断を受信側に倒すため」と書いた、あの2系統です。

パネル2は、その4本(温度2本・湿度2本)を1枚に重ね描きします。

|> filter(fn: (r) => r._field == "temperature_bme" or r._field == "temperature_sen")

温度は左軸(°C)、湿度は右軸(%)で破線にしてあります。単位が違うものを1枚に置くときの定番の分け方ですが、破線にしているのは色以外の手がかりを足すためです。線が4本あると、凡例の色だけでは追いにくくなります。

なぜ重ねるのか。2つのセンサーの差そのものを見たいからです。同じ部屋の同じ基板に載った2つの温度計が、常に何度ずれているのか。片方だけが跳ねたら、それは環境ではなくセンサー側の異常です。別のパネルに分けてしまうと、この比較ができません。

実データを流してみると、この重ね描きはさっそく答えを返してきました。上の画面写真の2段目がこのパネルで、温度2本は 1〜2℃、湿度2本は 14 ポイントほど離れたまま、同じ山と同じ谷に、同じタイミングで反応しています。同じ基板の上とは思えないほどの差ですが、その差がほぼ一定に保たれているなら、それは読み値の癖の違いであって故障ではない、と切り分けられます。片方だけがずるずる離れ始めたときに、初めて疑えばいい。「絶対値が合っているか」ではなく「2本の関係が崩れていないか」を見る——2系統を重ねる実際の使い方は、これです。

集計は平均(mean)。温湿度は連続的に変化する量なので、窓の中の代表値としては平均が素直です。

③ 気圧・照度 — 単位が違うものを1枚に置く

気圧(hPa)と照度(lx)という、まったく無関係な2つを1つのパネルにまとめています。左軸が気圧、右軸が照度です。

無関係なものを同居させたのは、どちらも単独ではパネル1枚ぶんの情報量がないからです。気圧は1日でせいぜい数 hPa しか動かず、照度は昼夜でしか大きく変わりません。それぞれに大きなパネルを与えると、画面のほとんどが平坦な線で埋まります。

とはいえ、まったく意味のない組み合わせでもありません。気圧が下がった日の照度(=天気)は、並べておくと目で相関が取れます。

④ CO2 — 閾値を「線」ではなく「色」で見せる

CO2 は今回いちばん実用的なパネルです。閾値を 800ppm で黄・1000ppm で赤に設定し、グラフの塗りつぶし自体を閾値の色に連動させてあります(gradientMode: scheme)。

閾値の線を引くだけでも「超えた」ことは分かりますが、線は見落とします。 面が赤くなれば、画面を横目で見ただけでも気づく。気づけることを優先するという方針の、いちばん素直な適用です。上の画面写真の右下がこのパネルで、実測で 1000ppm を超えた区間がそのまま赤く塗られています。この赤は検証用のダミーではなく、部屋の空気の実測値です。

💡 なぜ 1000ppm なのか

室内 CO2 濃度の目安としてよく使われる値です。外気がおおよそ 400ppm 台、人が居る部屋では 700〜1000ppm あたりまで上がり、換気が足りないとさらに上がっていきます。この基板を作った動機がまさに「なんとなく空気が悪い気がする」だったので、換気の判断に使える閾値をそのまま画面に持ち込みました。

凡例には最新値と最大値を出すようにしています。CO2 は「今どうか」だけでなく「今日いちばん悪かったときにどこまで行ったか」が知りたい量だからです。平均は出していません。1日を平均すると、寝ている間の上昇がきれいに薄まってしまいます。

⑤ 粉塵 — 4系列を重ねて、粒径の差を見る

SEN63C は粉塵を PM1.0 / PM2.5 / PM4.0 / PM10 の4つの粒径区分で出してきます。これも1枚に重ねました。

4つは独立した量ではなく、包含関係にあります(PM10 は 10μm 以下の粒子すべてを指します)。だから常に PM1.0 ≦ PM2.5 ≦ PM4.0 ≦ PM10 の順に並ぶはずで、この順序が崩れたらセンサーかパース処理の異常だと分かる。重ねて描く価値はここにあります。

単位は µg/m³。Grafana の単位 ID で言うと conμgm3 です。

⚠️ 単位 ID は推測で書かない

この µg/m³ の単位 ID は、動いている Grafana 13.0.2 が持っている単位の一覧と実際に照合して確定させました。ここは推測で書くと沈黙して壊れる場所です(単位が効かないだけで、エラーは出ません)。

他に使った celsius / humidity / pressurehpa / lux / ppm / dBm / s も、同じ方法で実在を確認しています。バージョンごとにバンドルされる単位は変わるので、手元の Grafana を見るのが唯一の正解です。

⑥ デバイス死活 — 「何秒黙っているか」を数字にする

ここから2枚はテーブルです。そしてこのダッシュボードでいちばん設計に手間がかかったのがパネル6でした。

見たいのは2つ。「機体が online と言っているか」と、「実際に何秒データが来ていないか」です。この2つは別物です。前者は機体の自己申告(MQTT の state トピック)で、後者はサーバー側から見た事実です。自己申告が online のまま固まっている状態を見抜けないなら、死活監視の意味がありません。

そこで、2本のクエリを投げて横に結合しています。

// 最終受信からの経過秒。ダッシュボードの時間範囲に依存させないため range は固定 -30d。
from(bucket: "home_sensors")
  |> range(start: -30d)
  |> filter(fn: (r) => r._measurement == "env")
  |> filter(fn: (r) => r.device_id =~ /^${device:regex}$/)
  |> group(columns: ["device_id"])
  |> sort(columns: ["_time"])
  |> last(column: "_value")
  |> map(fn: (r) => ({ device_id: r.device_id, elapsed_s: float(v: int(v: now()) - int(v: r._time)) / 1000000000.0 }))
  |> keep(columns: ["device_id", "elapsed_s"])
  |> group()

要点が3つあります。

1つめ。range-30d に固定してあります。 他のパネルは Grafana の時間ピッカーに従いますが、このパネルだけは従いません。理由は単純で、表示期間を「直近1時間」にした瞬間に、3時間前から黙っている機体が表から消えるからです。死活を見たいのに、死んでいる機体ほど見えなくなる。死活監視は、画面の時間範囲と独立していなければ意味がありません。

2つめ。経過秒は now() との差で自前計算しています。 int(v: now()) - int(v: r._time) はナノ秒単位の整数差なので、1000000000.0 で割って秒に直しています。Flux の時刻はナノ秒精度なので、この割り算を忘れると 10 億倍の値が並びます。

3つめ。結合は外部結合(joinByFieldouter)です。 telemetry は来ているのに state が無い機体、逆に state だけ retain されていて telemetry が1件も来ていない機体、そのどちらも表から消えてほしくないので外部結合にしました。片方しか無い側は空欄になり、その空欄自体が状態を語ります。

表示側の色分けはこうです。

表示
最終受信からの経過 150 秒で黄・300 秒で赤(セルの背景を塗る)
state(最新) online は緑・offline は赤・値が無ければ「(state未受信)」

閾値の 150 秒・300 秒は、送信間隔 60 秒の 2.5 倍と 5 倍です。1回落としたくらいでは赤くしない。ただし5回連続で来ていないなら、それはもう異常です。閾値は送信間隔から決める、という当たり前を明示的にやっています。

⑦ センサー健全性 — 状態文字列を表にする

第2回で、基板は state トピックにセンサーごとの状態ok / error / absent)を送るようにしました。パネル7はそれを機体×センサーの表にします。

Flux 側では pivot を使って、行=機体・列=センサー名の形に組み替えています。

  |> filter(fn: (r) => r._field =~ /^sensors_/)
  |> group(columns: ["device_id", "_field"])
  |> last(column: "_value")
  |> group()
  |> pivot(rowKey: ["device_id"], columnKey: ["_field"], valueColumn: "_value")

そのうえで、Grafana 側の変換で sensors_ という接頭辞を正規表現で落とし、列名を bme280 / bh1750 / sen63c にしています。

このパネルは、センサー名を1つもハードコードしていません。 _field =~ /^sensors_/ で拾って pivot で列にするので、新しいセンサーを載せた基板が増えたら、列が勝手に増えます。 第3回で Telegraf の json_string_fieldssensors_* とワイルドカードを書いたのと、まったく同じ発想です。「増えたときに何もしなくていい」形を、入口から出口まで一貫させる。

⑧ 電波・稼働 — 集計関数を1つだけ変えている

最後は Wi-Fi の電波強度(RSSI・dBm)と連続稼働時間(uptime・秒)です。RSSI が左軸、uptime は右軸の破線。

このパネルだけ、2本のクエリで集計関数が違います。

系列 集計 理由
rssi 平均mean 電波強度は常にふらつく。窓の代表値は平均が妥当
uptime_s 最大max 単調増加する量。窓内の平均を取ると実際より小さい値になる

uptime は「起動してから何秒経ったか」なので、時間とともに必ず増えます。ここで平均を取ると、1分の窓の中では約 30 秒ぶん低い値が出る。単調増加する量に平均を使うのは、意味を取り違えた集計です。

そして uptime を見る目的は「今どれだけ連続稼働しているか」ではありません。右肩上がりの直線が、突然0に落ちるのを見つけることです。それは再起動が起きた証拠で、電源かウォッチドッグかを疑う入口になります。「落ちたこと」は落ちた後には残らないので、グラフに刻んでおく必要があります。

凡例には最新値と最小値を出しています。RSSI は「いちばん悪かったとき」が知りたい量だからです。

📌 集計関数の選び方(このダッシュボードの結論)
量の性質 使う集計
連続的に変動する測定値 平均 温度・湿度・気圧・照度・CO2・粉塵・RSSI
単調増加するカウンタ 最大 uptime
状態・現在値 最後の値 state・センサー健全性・最終受信時刻

8パネル中7つが平均ですが、残り1つを平均にすると静かに間違ったグラフになります。集計関数は「その量が何を意味するか」で決まるのであって、既定値で決まるものではありません。


🕳️ 欠測はグラフの穴として出す — 第2回の設計がここで効く

第2回で、送信する JSON についてこう決めました。値が取れなかったセンサーは、キーごと丸ごと省略する。 null も、-999 のようなセンチネル値も、nan も送らない、と。

この判断が、いまグラフの見え方として返ってきます。

flowchart TD subgraph BAD["センチネル値を送っていた場合"] B1["基板: co2 = -999 を送信"] B2["DB: -999 が保存される"] B3["グラフ: -999 まで
ドンと落ちる線が描かれる"] B1 --> B2 --> B3 end subgraph GOOD["キーごと省略(採用)"] G1["基板: co2 のキーを送らない"] G2["DB: その時刻に co2 の点が無い"] G3["グラフ: 線が途切れる
=欠測がそのまま見える"] G1 --> G2 --> G3 end style BAD fill:#ffebee style GOOD fill:#e8f5e9

センチネル値を送る設計だと、可視化の段階で必ず後始末が発生します。 グラフに -999 の谷が描かれ、平均値が壊れ、それを避けるために「−900 以下を除外する」条件をクエリに足すことになる。8パネル全部に、同じ除外条件を書く羽目になります。

キーを送らない設計なら、その後始末が丸ごと発生しません。点が無いところには、点が描かれないだけです。

グラフ側の設定も、この方針に合わせてあります。

設定 意味
createEmpty false データが無い窓を、空の点で埋めない
insertNulls 180000 ms(3分) 点の間隔が3分を超えたら、線を繋がずに切る
spanNulls false 欠けたところを線でまたがない

insertNulls の 3 分は、送信間隔 60 秒の3倍です。1回落としたくらいでは線を繋いだままにし、2回続けて欠測したら線を切る。パネル6の閾値と同じで、これも送信間隔から決めています。

結果として、グラフの穴がそのまま「その時間、そのセンサーの値が来ていなかった」ことの証拠になります。データの欠けを埋めて滑らかに見せるのではなく、欠けていることを見せる。第2回で「フィールド単位で値の有効性を見てキーを落とす」と決めた数行が、最終的にこの見え方を作っています。

そしていまのところ、実運用のグラフに穴は出ていません。 上の画面写真の温湿度4本も、6時間ぶんが1度も途切れずに繋がっています。穴が出ないこと自体が、60 秒間隔の送信と Telegraf の取り込みが取りこぼしなく回っている証拠です。欠測が見える作りにしておくと、「穴が無い」ということもまた読み取れる情報になります。

📌 欠測が「見える」ことの実用的な価値

このセンサー基板では、欠測はセンサー単位ではなくフィールド単位で起きます。 CO2 のウォームアップ中は co2 だけが消え、粉塵センサーが起動していなければそのセンサー由来の7つのキーがまとめて消える。

つまり、グラフの穴の「形」を見れば、何が起きているかが分かります。 1本だけ切れていればそのセンサーの問題、7本まとめて切れていればモジュールごとの問題、全部切れていれば基板か Wi-Fi の問題です。欠測を埋めてしまうと、この情報が消えます。


🔁 基板が増えても、パネルは1つも触らない

このダッシュボードは、いま実機が1枚しかない状態で作っています。 でも 2 枚目・3 枚目を載せることは最初から分かっている。そこで、基板が増えたときにパネルを編集しなくて済む形にしました。

仕掛けは変数3つです。

変数 役割
ds datasource データソースの選択。UID をダッシュボードに焼き込まないため
device custom 表示は日本語名・クエリには device_id。複数選択と All に対応
discovered query InfluxDB に実在する device_id の一覧。台帳への登録漏れの検出用

ds — データソースの UID を JSON に埋めない

Grafana のダッシュボード JSON は、素直に作るとデータソースの UID(環境ごとに違う識別子)が埋め込まれます。 その JSON を別の環境に持っていくと、当然どこにも繋がりません。

ds という datasource 型の変数を挟み、全パネルがそれを参照するようにすれば、JSON 側に UID は1つも出てきません。 取り込んだ先で画面上のドロップダウンから選ぶだけで動きます。ダッシュボードを1ファイルで持ち運べる形にしておく、という判断です。

device — 表示名とクエリ値を分ける

device 変数の中身は、こういう文字列です。

sensor_001 : sensor_001,sensor_002 : sensor_002,【検証用】ダミーA : sensor_998,【検証用】ダミーB : sensor_999

Grafana の custom 変数は 表示名 : 値 の形式が使えます。これで、画面のドロップダウンには日本語名が出て、クエリには device_id が渡るという分離ができます。

ここが、第3回の「名前を DB に入れない」という設計と繋がります。InfluxDB の tag は device_id だけ。「リビング」という名前は Grafana の変数定義にしか存在しないので、部屋を模様替えして名前を変えても、過去データの系列は1本のままです。

そして、この文字列は手で書きません。サーバー側の台帳 devices.yaml から生成スクリプトが吐きます。

devices:
  sensor_001:
    name: "未設定"
    location: "未設定"
    purpose: "環境モニタ"
    hardware: "ESP32-S3 / BME280 + BH1750 + SEN63C"

台帳が single source of truth で、Grafana の変数はその写しにすぎない、という構造です。

1本のクエリで「1台」「複数台」「全部」に対応する

全パネルのクエリに共通で入っているのが、この1行です。

|> filter(fn: (r) => r.device_id =~ /^${device:regex}$/)

${device:regex} は、画面で選んだ機体を正規表現に展開する Grafana の記法です。

画面での選択 展開のされ方 クエリの意味
1台だけ選択 単一の ID その機体だけ
2台選択 縦棒で連結された選択肢 どちらか
All 全候補の連結 全部

選択の数だけクエリを書き分ける必要がありません。 =~(正規表現マッチ)と組み合わせた1行で、3パターン全部を賄えます。

Repeat — パネルのほうが増える

パネル1「最新値」だけは、機体ごとに1枚ずつ欲しいパネルです。5台ぶんの現在値を1枚に詰め込んでも読めません。

そこで、このパネルには repeat: device を設定してあります。device 変数で選択された機体の数だけ、Grafana がパネルを自動で複製します。パネルのタイトルも ${device:text} にしてあるので、複製されたパネルにはそれぞれの日本語名が入ります:text は値ではなく表示名を返す記法です)。

flowchart LR P0["パネル定義は1つだけ
repeat: device"] --> V["device 変数
3台を選択"] V --> P1["最新値 — リビング"] V --> P2["最新値 — 寝室"] V --> P3["最新値 — 書斎"] style P0 fill:#e3f2fd

discovered — 登録漏れを画面で見つける

3つめの変数 discovered は、フィルタには一切使いません。InfluxDB に実際にデータを送ってきている device_id の一覧を出すだけの変数です。

import "influxdata/influxdb/schema"
schema.tagValues(bucket: "home_sensors", tag: "device_id", start: -30d)

これが要るのは、台帳への登録を忘れる未来が確実に来るからです。新しい基板にファームウェアを焼いてプロビジョニングし、データは飛んでいるのに devices.yaml に書き忘れた——このとき device 変数のドロップダウンにその機体は出てきません。画面上、その基板は存在しないことになります。

discovered を1つ置いておけば、「ここには出ているのに選択肢に無い」=登録漏れ、と画面だけで判断できます。データが来ていないのか、台帳に書いていないだけなのか。この2つを切り分けられないと、原因を追うたびにサーバーへ入る羽目になります。

✅ 結果:基板を1枚足したときの作業
./add-sensor.sh sensor_003    # MQTT アカウントの発行
vi devices.yaml               # 名前・場所・用途を記入
./gen-grafana-devices.sh      # 出力をコピーして device 変数に貼る

パネルの編集は0件です。 グラフは ${device:regex} のフィルタで自動的に増え、最新値パネルは Repeat で自動的に複製されます。台帳に3行足して、変数を貼り直すだけ。

「増えることが分かっているものを、増えても作業が増えない形にしておく」——8パネル作ることより、この仕込みのほうが後で効くはずです。


⏱️ 60秒間隔に、画面のほうを合わせる

第2回で送信間隔を 60 秒に決めました。ダッシュボード側も、これに合わせて解像度を揃える必要があります。

合わせないとどうなるか。 Grafana は表示期間と画面幅から集計の窓(v.windowPeriod)を自動で決めます。表示期間を「直近30分」に絞ると、この窓が 10 秒などに縮みます。ところがデータは1分に1点しかない。6個の窓のうち5個が空になり、createEmpty: false の効果でその窓は結果に出ず、櫛の歯が抜けたようなグラフになります。データは正常なのに、画面だけが壊れて見える状態です。

対策を4段重ねにしました。

対策 設定 効果
① パネルの Min interval 全8パネルに 1m Grafana が窓を1分未満にしない
② クエリ側の保険 各クエリの先頭に1行(下記) ①が外れても1分を下回らない
③ 時間ピッカーの制限 更新間隔の選択肢を 1m 以上のみに 10 秒更新を選べなくする
④ 自動更新 1分 送信間隔と一致させる

②の1行がこれです。

win = if int(v: v.windowPeriod) < 60000000000 then 1m else v.windowPeriod

60000000000 はナノ秒で 60 秒です。Grafana が渡してきた窓が1分未満だったら、問答無用で 1m に持ち上げる。 以降のクエリはこの win を使います。

①だけでも足りるはずですが、パネルをコピーして新しいものを作ったときに Min interval の設定を引き継ぎ忘れるのは、ありがちな事故です。クエリ本文に書いてあれば、コピーしても一緒に付いていきます。設定ではなくクエリに書くというのは、そういう保険です。

そして③。これは設定というより意思表示に近い。1分に1点しか来ないデータに対して「5秒ごとに更新」を選べる状態にしておくと、いつか誰か(十中八九、自分)が選びます。選べないようにしておくのが、いちばん確実な対策です。

Telegraf 側は、結局1行も変えなかった

送信間隔を 60 秒にしたとき、取り込み側の設定も変える必要があるのではと考えて確認しましたが、結論は「変更不要」でした。

設定 60秒間隔との関係
interval 10s mqtt_consumerメッセージ到着で動くサービス入力。この値はゲートにならない
flush_interval 10s 空のフラッシュが増えるだけ。書き込み遅延が小さいままなので、むしろこのほうがよい
metric_buffer_limit 100000 1台が1分に1メトリクスなら、5台でも約2週間ぶんを保持できる
MQTT keepalive 60秒 送信がない間もクライアントが PINGREQ を送るので切断されない

「上流の間隔を変えたら下流も全部見直し」ではなく、どこがイベント駆動でどこが周期駆動かを見る。 Telegraf の interval は周期駆動の入力プラグイン(CPU 使用率の取得など)のための値で、MQTT の購読には効きません。ここを取り違えると、意味のない設定変更を積み上げることになります。


💥 やらかし:可視化を作って、取り込みの穴に気づいた

パネル6と7を作ろうとして、必要なデータが DB に1件も入っていないことに気づきました。

第3回で、こういう設計にしていました。telemetry(測定値)は数値のみ。 バージョン番号・IP アドレス・センサーの状態といった文字列は、state という別のトピックへ分離する。InfluxDB の型衝突を構造的に避けるための、正しい判断です。

問題は、その state を InfluxDB に取り込む設定を書いていなかったことです。分離はしたが、分離した先を貯めていなかった。

内容
事象 パネル6(デバイス死活)・パネル7(センサー健全性)は state の文字列が必要。しかし env measurement には数値しか無く、文字列を入れる measurement 自体が存在しなかった
原因 取り込み設定(Telegraf)が home/sensor/+/telemetry だけを購読しており、state トピックを購読していなかった
対処 mqtt_consumer の入力をもう1本追加。home/sensor/+/state を購読し、device_state という別 measurement へ入れる。json_string_fieldssensors_* を指定して、ネストした状態をフィールドへ展開する
確認 Telegraf 再起動後、mqtt_consumer (2x) として両方の接続が Connected・エラー/警告0件。device_statestatus / fw / ip / sensors_* が入ることを実測
✅ 対策:貯め始めた日に、パネルを1枚だけ作る

env にデータが入り始めた時点で、パネルを1枚だけ置いてクエリを投げてみる。それだけで「その _field が DB に存在しない」ことがその場で分かります。出口のクエリが、そのまま取り込み設定のテストになります。


💥 もう1つのやらかし:存在しない単位 ID を書いていた

地味ですが、再発しやすいほうの失敗も書いておきます。µg/m³ の単位 ID を conmugm3 と書いていました。この ID は実在しません。正しくは conμgm3 で、mu の部分が ASCII 3文字ではなくギリシャ文字の μ 1文字です。

内容
事象 粉塵パネルの単位に conmugm3 を指定していた。Grafana 13.0.2 がバンドルしている単位の一覧に、その ID は無い
原因 一覧を見ずに、それらしい綴りを推測で書いた。µ を ASCII の mu に置き換える書き方が頭にあった
なぜ気づきにくいか 存在しない単位 ID を書いてもエラーにならない。JSON は通り、パネルも描画され、ただ軸に単位が付かないだけ。沈黙して壊れるタイプの誤りです
対処 使っている8種を1つずつバンドルの一覧と突き合わせた。誤りはこの1件だけで、残り7種はそのままで問題なし。⑤ の callout に書いた照合は、この作業のことです

⑤ 粉塵の節で「単位 ID は推測で書かない」と書いたのは、これを踏んだからです。

✅ 確認手順:単位 ID はパネル編集画面の Unit 欄から引く

単位 ID は、パネルの編集画面にある Unit のドロップダウンで検索して、出てきたものだけを使うのが確実です。JSON に直接書くなら、µg/m³ のように非 ASCII が入る ID はコピー&ペーストで持ってくる。手で打つと、ギリシャ文字の μ が ASCII の mu に化けます。


🔎 検証:Flux クエリ 17 本を全部叩いた

画面写真1枚では見えない部分も含めて、何をどこまで確かめたのかを数字で書いておきます。

確認項目 結果
Flux クエリ 17 本(パネル側 16 本+変数 1 本) 17/17 が HTTP 200・データあり。Grafana に渡すのと同じ read 専用トークンで実行
ダッシュボード JSON の構文 パースを通して検証 OK。8パネル・変数3つを確認
Grafana の単位 ID 使用した8種すべてを 13.0.2 のバンドルと実測照合
state の取り込み device_statestatus / fw / ip / sensors_* が入ることを実測
Telegraf 再起動後 mqtt_consumer (2x) で両方 Connected・エラー/警告0件
データソース登録とダッシュボードの Import 完了。実データがパネルに描画されることを画面で確認(上の画面写真)

パネル側が 16 本というのは、パネル1が5本(温度・湿度・気圧・照度・CO2)、パネル2が2本、パネル6が2本……と積み上げた実数です。

この 17 本は、Grafana で開く前に確かめたものです。「画面で見ていないのに、なぜクエリが正しいと言えるのか」——理由はこうです。Grafana がやっているのは、パネルの JSON に書かれた Flux 文字列を InfluxDB の API に投げることです。同じ文字列を、同じトークンで、同じ API に投げれば、同じ結果が返ります。 変数(${device:regex} など)を実際の値に置き換えたうえで 17 本すべてを実行し、HTTP 200 かつ行が返ってくることまで確認しました。

もちろんこれで確かめられるのは「クエリがデータを返すこと」までです。軸の割り当てや色の閾値が意図どおりに見えるかは、画面を開くまで分かりません。 そこは Import のあとに実際の画面で確認しました(上の画面写真)。先にクエリを潰しておいたぶん、画面で見る項目を「見え方」だけに絞れます。

なお、動作確認のために 実機のない sensor_998 / sensor_999 という2台ぶんの合成データを24時間ぶん投入してあります。

検証用データ 内容 確かめられること
sensor_998 全センサー正常 通常表示・Repeat の複製
sensor_999 途中から粉塵センサー由来の7キーが丸ごと欠測+ CO2 が 1000ppm 超 欠測の穴・閾値の赤・センサー健全性の error 表示

「異常のときにどう見えるか」を、異常が起きるのを待たずに確認できるようにしたものです。実機を設置して確認が済んだら、この2台は削除します。


⚠️ ここまでが自動、ここからは手作業

ここまでの作業のうち、最後まで自動化できなかったものが2つあります。データソースの登録と、ダッシュボードの取り込み(Import)です。どちらも人間がブラウザで操作する必要があり、自動化を試みたうえでできないと結論して、最後は画面から手で入れました。

理由を分解するとこうです。

内容
Grafana の管理者パスワード Grafana 自身の DB の中にあり、外から参照できない。Compose 側の環境変数は削除済み
Grafana の API 未認証では 401。認証するには上のパスワードが要る
設定ファイルによる自動投入 Grafana は uid 472 で動いており、シークレットを置いた 600・uid 1000 のファイルを読めない

3つめが地味に効いています。第3回で「シークレットは 600 のファイルに置き、docker inspect に出さない」方式に統一しましたが、その方式のままだと Grafana からは読めないわけです。

ここで選択肢は2つありました。

選択肢A:ファイル権限を緩めて自動化する 選択肢B:人間が画面で入れる(採用)
トークンの置き場所 ディスク上に平文(権限を緩めた状態で) Grafana の DB に暗号化して保存
手間 初回のみ自動 初回のみ手作業(5分・sudo 不要)
リポジトリへの混入リスク 設定ファイル経由で入りうる ファイルに残らない

5分の手作業を惜しんで、平文のトークンをディスクに置く。 これは明らかに割に合いません。選択肢Bにしました。

代わりに、画面のどの欄に何を入れるかを項目単位まで書いた手順書を先に用意し、そのとおりに入れました。ここで詰まりやすいのは、値そのものより画面の作りのほうです。

入力項目 注意
Query language Flux 既定は InfluxQL。切り替えないと「接続は成功するがグラフが空」になる
URL http://influxdb:8086 ホスト側の 127.0.0.1:8086 ではない(Compose 内部の名前)
Organization neuralhub 綴り違いは 400 で弾かれる
Default Bucket home_sensors
Min time interval 1m 送信間隔 60 秒に合わせる
Token 画面に貼るだけ ファイルにも Git にも残さない

Import 直後に見たのは、この4点です。いずれも上の画面写真に写っている範囲で、そのまま通りました。

  • データが流れている — 選んだ機体の実データが、線と数字としてパネルに出る
  • device 変数が効いている — 選んだ機体だけに絞られ、パネルのタイトルにもその名前が入る最新値 — sensor_001
  • CO2 の閾値が色になる — 1000ppm を超えた区間の面が赤く塗られ、現在値の数字も赤くなる
  • 単位が付いている°C / %H / hPa / lux / ppm が軸と数字に出る。照合した単位 ID が効いている証拠です

ここから先、検証用データで意図的に作った異常——sensor_999 の粉塵欠測と、センサー健全性の error——が異常として見えるかどうかも、同じ考え方で確かめる項目に入れてあります。「正常に見えること」ではなく「異常が異常に見えること」を受け入れ基準にする、という考え方です。

✅ Import 画面でデータソースを選ぶだけで繋がる

取り込む JSON には、データソースの UID がどこにも書かれていませんds 変数の効果です)。Import の画面でドロップダウンから InfluxDB-home を選べば、それだけで8パネル全部がそこを向きます。

UID を焼き込んだ JSON を別の環境へ持ち込むと、ここで「Datasource not found」をパネルの数だけ潰して回ることになります。持ち出す予定が無くても、UID を変数に逃がしておくのは安い保険です。


✅ まとめ

📌 この記事のポイント
  • Grafana はデータを持たない。 ダッシュボードは JSON 1ファイルで、消しても測定データは消えない
  • 集計関数は量の性質で決まる。 変動する測定値は平均、単調増加するカウンタは最大、状態は最後の値。ここを既定のままにすると静かに間違う
  • 閾値は線ではなく色で。 線は見落とすが、面が赤くなれば横目でも気づく
  • 死活監視のクエリだけ、時間範囲を固定する。 画面の時間範囲に従わせると、死んでいる機体ほど表から消える
  • 閾値も欠測判定も、送信間隔から決める。 60 秒なら 150 秒(2.5倍)で黄・300 秒(5倍)で赤・3分(3倍)で線を切る
  • 欠測を埋めない。 キーごと省略する設計にしておけば、可視化側に除外条件を1つも書かずに済み、穴の形が原因を語る
  • 増えるものは、増えても作業が増えない形にする。 変数の正規表現展開と Repeat で、基板追加時のパネル編集は0件
  • 出口を作るまで、入口の不足は分からない。 可視化を作り始めて初めて、取り込み設計の穴が見つかった
  • 5分の手作業を惜しんで、平文のトークンをディスクに置かない

🏁 連載の締め:4回で何ができたか

全4回で、こういうものができました。

作ったもの 今も効いている判断
#1 ESP32-S3 センサー基板(KiCad → JLCPCB → 手はんだ) 温湿度を2系統載せた。パネル2の重ね描きはこれが前提
#2 ファームウェア(自前 I2C ドライバ・MQTT 送信) 欠測はキーごと省略。グラフの穴が欠測の証拠になる
#3 サーバー(Mosquitto / Telegraf / InfluxDB) tag は device_id だけ。名前を変えても系列が割れない
#4 ダッシュボード(Grafana・8パネル) パネルを触らずに機体が増える構成

書き終えてみると、4回を貫いていたのは同じ考え方でした。

📌 この連載で通した6つの方針
  1. 全基板で同じバイナリ。 機体差は NVS に置き、リビルドせずに増やす
  2. telemetry は数値のみ。 文字列は別トピック・別 measurement に分ける
  3. 欠測はキーごと省略。 null もセンチネル値も送らない
  4. 時刻はサーバーの受信時刻。 基板は時刻を送らない
  5. 1デバイス=1アカウント。 鍵が漏れても他の機体になりすませない
  6. ネットワークはベストエフォート。 Wi-Fi もブローカーも落ちてよく、センサーの読み取りは止めない

どれも「あとで困る形を、最初から作らない」という一点に集約されます。そしてその効果が確認できるのは、たいてい何段も先の工程でした。第2回で決めた欠測の扱いが第4回のグラフの見え方を決め、第3回で名前を DB に入れなかったことが今回の変数設計を成立させている。構造の判断は、遅れて効いてくるということを、4回かけて確認した連載でもありました。

残っている作業と、これからやりたいことも書いておきます。

内容
残作業 検証用ダミーデータ(sensor_998 / sensor_999)の削除。実機を設置しての最終確認
すぐやること 基板を設置して devices.yaml の「未設定」を埋める。2枚目を組んで、パネルを触らずに増えることを実際に確かめる
その先 同じ経路に赤外線リモコン基板を載せる。取り込み側はすでに home/ir/+/state を購読する設定にしてある

1枚の基板が、部屋のグラフになるところまで来ました。ここから先は、測ったものを使う番です。長い連載にお付き合いいただき、ありがとうございました。


関連記事