戻る

フィールドで複数の定点で温度・湿度センサ・人感センサの情報を無線で計測してグラフ表示しデータを保存するシステムをつくてみよう(高難易度)

ESP32(子機×3)+ ESP-NOW + MQTT + Node-RED + CSV保存
再現手順書(コードはコピペで進める完全版)

目的:初心者が、コードをカット&ペーストしながら、ステップ通りに進めるだけで同じシステムを再現できる手順書です。

構成:子機(field01/02/03:DHT11+PIR)→ESP-NOW→Gateway→MQTT→Node-RED(表示/保存/通信状態)

・Raspberry Pi 5(Mosquitto + Node-RED稼働)
・ESP32子機 ×3(field01/02/03)
・ESP32 Gateway ×1(MQTTへ転送)
・DHT11 ×3、PIR ×3、ジャンパ線、電源

2. 配線(子機1台あたり)

DHT11:DATA→GPIO4、VCC→3.3V、GND→GND
PIR:OUT→GPIO2、VCC→5V(または3.3V)、GND→GND
※ボードの D2/D3 表記はGPIO番号と違う場合があるので、必ずGPIO番号で合わせる。

3. Raspberry Pi:保存先ディレクトリ作成

mkdir -p /media/PI_SHARE_500G/sensor_logs
ls -ld /media/PI_SHARE_500G/sensor_logs

4. 重要:ESP-NOWは Wi-Fiチャンネル一致が必須

GatewayのSerialで表示された WiFi channel と子機の WIFI_CHANNEL を一致させる。
一致しないと ESP_ERR_ESPNOW_CHAN(Peer channel is not equal to the home channel)が出て送れない。

5. Gateway ESP32:コード(ESP-NOW受信→MQTT publish)

ESP32を中継点にすることで、遠距離の情報通信を可能とします。
Arduino IDEでGateway用ESP32に書き込む。Wi-Fi SSID/PASS、MQTT_HOST(RasPi IP)を自分の環境に合わせる。

ESP32WROOM, ESP32C3用のGateway コード

#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <PubSubClient.h>

// ====== Wi-Fi / MQTT 設定 ======
const char* WIFI_SSID = "your SSID";
const char* WIFI_PASS = "your password";
const char* MQTT_HOST = "192.168.50.150";
const int   MQTT_PORT = 1883;

WiFiClient espClient;
PubSubClient mqtt(espClient);

// 接続後に WiFi.channel() で上書き
int WIFI_CHANNEL = 1;

// ====== 子機と一致必須:受信パケット(314 bytes) ======
typedef struct __attribute__((packed)) {
  char topic[64];
  char payload[250];
} espnow_packet_t;

// ====== ユーティリティ ======
void printWifiInfo() {
  Serial.print("STA MAC : "); Serial.println(WiFi.macAddress());
  Serial.print("BSSID   : "); Serial.println(WiFi.BSSIDstr());
  Serial.print("IP      : "); Serial.println(WiFi.localIP());
  Serial.print("Gateway : "); Serial.println(WiFi.gatewayIP());
  Serial.print("Subnet  : "); Serial.println(WiFi.subnetMask());
  Serial.print("DNS     : "); Serial.println(WiFi.dnsIP());
  Serial.print("RSSI    : "); Serial.println(WiFi.RSSI());
  Serial.print("Channel : "); Serial.println(WiFi.channel());
}

void setupWiFi() {
  WiFi.mode(WIFI_STA);

  // 以前の接続情報も含めて消す(C3で効くことがある)
  WiFi.disconnect(true, true);
  delay(300);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  Serial.print("WiFi status="); Serial.println(WiFi.status());
  WIFI_CHANNEL = WiFi.channel();

  printWifiInfo();
}

void setupMqtt() {
  mqtt.setServer(MQTT_HOST, MQTT_PORT);

  while (!mqtt.connected()) {
    Serial.print("MQTT connecting...");
    // クライアントIDが被ると切れるのでMAC由来にする
    String cid = String("espnow_gateway_") + WiFi.macAddress();
    cid.replace(":", "");

    if (mqtt.connect(cid.c_str())) {
      Serial.println("connected");
    } else {
      Serial.print("failed, rc="); Serial.print(mqtt.state());
      Serial.println(" retry in 2s");
      delay(2000);
    }
  }
  Serial.print("MQTT connected=");
  Serial.println(mqtt.connected() ? "true" : "false");
}

void addBroadcastPeer() {
  uint8_t bc_addr[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; //ESP32C3
 // uint8_t bc_addr[6] = {0x08,0xB6,0x1F,0x7E,0xE3,0x54}; //ESP32WROOM

  if (esp_now_is_peer_exist(bc_addr)) {
    Serial.println("broadcast peer already exists");
    return;
  }

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, bc_addr, 6);
  peer.channel = WIFI_CHANNEL;
  peer.encrypt = false;

  esp_err_t r = esp_now_add_peer(&peer);
  Serial.print("add broadcast peer: ");
  Serial.println(r == ESP_OK ? "OK" : "FAIL");
}

void onRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
  Serial.print("[ESPNOW] recv len="); Serial.println(len);
  Serial.print("[ESPNOW] from=");
  for (int i = 0; i < 6; i++) {
    Serial.printf("%02X", info->src_addr[i]);
    if (i < 5) Serial.print(":");
  }
  Serial.println();

  if (len != (int)sizeof(espnow_packet_t)) {
    Serial.print("[ESPNOW] size mismatch. expected=");
    Serial.print(sizeof(espnow_packet_t));
    Serial.print(" got=");
    Serial.println(len);
    return;
  }

  espnow_packet_t pkt;
  memcpy(&pkt, data, sizeof(pkt));
  pkt.topic[sizeof(pkt.topic) - 1] = '\0';
  pkt.payload[sizeof(pkt.payload) - 1] = '\0';

  Serial.print("[ESPNOW] topic="); Serial.println(pkt.topic);
  Serial.print("[ESPNOW] payload="); Serial.println(pkt.payload);

  bool ok = mqtt.publish(pkt.topic, pkt.payload);
  Serial.print("[MQTT] publish="); Serial.println(ok ? "OK" : "FAIL");
}

void setupEspNow() {
  WIFI_CHANNEL = WiFi.channel();
  esp_wifi_set_channel(WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE);

  if (esp_now_init() != ESP_OK) {
    Serial.println("esp_now_init failed");
    return;
  }

  addBroadcastPeer();

  esp_now_register_recv_cb(onRecv);
  Serial.println("ESP-NOW Gateway ready");
}

void setup() {
  Serial.begin(115200);
  delay(300);

  setupWiFi();
  setupMqtt();
  setupEspNow();
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[WiFi] disconnected -> reconnect");
    setupWiFi();
    setupMqtt();
    esp_now_deinit();
    setupEspNow();
  }

  if (!mqtt.connected()) setupMqtt();
  mqtt.loop();
}

GatewayのSerialで以下が出ればOK

・WiFi channel: 11(例) → 子機の WIFI_CHANNEL に設定
・Gateway MAC: xx:xx:xx:xx:xx:xx → 子機の gatewayMac に設定
・MQTT connected=true / ESP-NOW Gateway ready

6. 子機ESP32(field01/02/03):コード(DHT11+PIR→ESP-NOW)

下のコードを貼り付け、NODEとgatewayMacとWIFI_CHANNELだけ変えて、field01/02/03それぞれに書き込む。
ここでは、ESP32C3を使用しています。
以下のコードでは、mac addresとWiFi channel を使用します。上記のgatewayのESP32を起動すると、mac addressとWiFi channelが表示されるので、メモして降りてください。

表示例:
WiFi channel: 11
uint8_t gatewayMac[] = {0x08,0xB6,0x1F,0x7E,0xE3,0x54}; //ESP32WROOM
uint8_t gatewayMac[] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; //ESP32C3

#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <DHT.h>
#include "esp_err.h"

// ===== 配線(GPIO直指定で確実)=====
#define DHTPIN  D3   // D3 = GPIO4 //ESP32 C3では D3, D2とする必要がある。
#define PIRPIN  D2   // D2 = GPIO2
#define DHTTYPE DHT11

// ===== 設定 =====
const char* NODE = "field01"; //この"field01"を例えば"field02" "field03" とすることでNODEの数を増やすことができます。
#define WIFI_CHANNEL 11

//uint8_t gatewayMac[] = {0x08,0xB6,0x1F,0x7E,0xE3,0x54}; //ESP32WROOM
uint8_t gatewayMac[] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; //ESP32C3

DHT dht(DHTPIN, DHTTYPE);

typedef struct __attribute__((packed)) {
  char topic[64];
  char payload[250];
} espnow_packet_t;

void sendPacket(const char* topic, const String& json) {
  espnow_packet_t pkt{};
  json.toCharArray(pkt.payload, sizeof(pkt.payload));
  strncpy(pkt.topic, topic, sizeof(pkt.topic) - 1);

  esp_err_t r = esp_now_send(gatewayMac, (uint8_t*)&pkt, sizeof(pkt));
  Serial.printf("SEND %s -> %s : %s (%d)\n",
                NODE, topic, esp_err_to_name(r), (int)r);
}

void setup() {
  Serial.begin(115200);
  delay(300);

  pinMode(PIRPIN, INPUT_PULLDOWN);
  dht.begin();

  // ===== Wi-Fiを「確実に初期化」して home channel を固定(C3対策)=====
  // ArduinoのWiFiラッパを使わず、IDF側を明示的に初期化する
  wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();

  esp_err_t r;

  r = esp_wifi_init(&cfg);
  Serial.printf("esp_wifi_init: %s (%d)\n", esp_err_to_name(r), (int)r);

  r = esp_wifi_set_storage(WIFI_STORAGE_RAM);
  Serial.printf("set_storage: %s (%d)\n", esp_err_to_name(r), (int)r);

  r = esp_wifi_set_mode(WIFI_MODE_STA);
  Serial.printf("set_mode: %s (%d)\n", esp_err_to_name(r), (int)r);

  r = esp_wifi_start();
  Serial.printf("esp_wifi_start: %s (%d)\n", esp_err_to_name(r), (int)r);

  // チャネル固定(home channelを一致させる)
  r = esp_wifi_set_channel(WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE);
  Serial.printf("set_channel(%d): %s (%d)\n", WIFI_CHANNEL, esp_err_to_name(r), (int)r);

  // ===== ESP-NOW =====
  r = esp_now_init();
  Serial.printf("esp_now_init: %s (%d)\n", esp_err_to_name(r), (int)r);
  if (r != ESP_OK) return;

  esp_now_peer_info_t peer{};
  memcpy(peer.peer_addr, gatewayMac, 6);
  peer.ifidx = WIFI_IF_STA;
  peer.channel = WIFI_CHANNEL;
  peer.encrypt = false;

  r = esp_now_add_peer(&peer);
  Serial.printf("add_peer: %s (%d)\n", esp_err_to_name(r), (int)r);

  Serial.println("field01 ready");
}

void loop() {
  static unsigned long lastDht = 0;
  static int lastPir = -1;

  int pir = digitalRead(PIRPIN);

  if (pir != lastPir) {
    lastPir = pir;
    String json = "{\"node\":\"" + String(NODE) + "\",\"pir\":" + String(pir) + "}";
    String topic = "sensors/" + String(NODE) + "/pir";
    sendPacket(topic.c_str(), json);
  }

  if (millis() - lastDht > 5000) {
    lastDht = millis();

    float t = dht.readTemperature();
    float h = dht.readHumidity();
    if (isnan(t) || isnan(h)) {
      Serial.println("DHT read failed");
      return;
    }

    String json = "{\"node\":\"" + String(NODE) +
                  "\",\"temp\":" + String(t,0) +
                  ",\"hum\":" + String(h,0) +
                  ",\"pir\":" + String(pir) + "}";

    String topic = "sensors/" + String(NODE) + "/dht11";
    sendPacket(topic.c_str(), json);
  }
}

7. Raspberry Pi:MQTT到達確認

Raspberry Piで以下を実行し、field01/02/03のJSONが流れればOK。

mosquitto_sub -h localhost -t “sensors/#” -v -C 12
(-C 12 とすると 12ラインが表示されると止まります。とると表示が続きます。)

例:
sensors/field03/dht11 {“node”:”field03″,”temp”:23,”hum”:47,”pir”:0}
sensors/field02/dht11 {“node”:”field02″,”temp”:25,”hum”:40,”pir”:0}
sensors/field01/dht11 {“node”:”field01″,”temp”:25,”hum”:44,”pir”:0}
sensors/field02/pir {“node”:”field02″,”pir”:1}
sensors/field02/pir {“node”:”field02″,”pir”:0}
sensors/field03/dht11 {“node”:”field03″,”temp”:23,”hum”:47,”pir”:0}

8. Node-RED:完全版フローJSONをImportしてDashboard/CSV/LinkHealthを作る

Node RedへのJSONコードの読み込み方法

JSONコードは、このページに下に挙げてある。

Node-RED右上(≡) → Import(読み込み)をクリックすると、Clipboardば表示されるので、そこにJSONを貼り付け、画面下のImport(読み込み)をクリックする。上記のノードのフローが表示される。

デプロイ( Deploy)をクリックすると実行され、結果は ダッシュボードに表示される。
Dashboard: http://192.168.50.150:1880/nodered/ui(例)
・Sensorsタブ:温度/湿度/PIR(安定化)
・LinkHealthタブ:最終受信からの経過秒(通信状態)
・CSV保存:左上にデータ保存の開始、終了のボタンがあります。デフォルトでは終了になっています。ボタンの右をクリックすると保存が開始します。左をクリックすると保存が終了します。データは、/media/PI_SHARE_500G/sensor_logs/session_20260105_152841.csv として保存される。csvのデータは、以下のように保存される。

タイムスタンプ ノード 温度 湿度 PIR 順に表示される。

timestamp,node,temp,hum,pir
2026-01-05T06:28:42.179Z,field02,25,38,0
2026-01-05T06:28:43.032Z,field01,24,43,0
2026-01-05T06:28:44.641Z,field03,23,44,1
2026-01-05T06:28:47.179Z,field02,25,38,0
2026-01-05T06:28:48.033Z,field01,25,43,0
2026-01-05T06:28:49.642Z,field03,24,44,1
2026-01-05T06:28:52.180Z,field02,25,38,0
2026-01-05T06:28:53.034Z,field01,25,43,0

9. LinkHealth(通信状態)の見方

温度湿度情報は、5秒ごとに送信しています。

・表示値=「最後にDHTメッセージを受信してからの秒数」
・0〜7秒:正常(DHT 5秒周期なら普通)
・10〜30秒:不安定(取りこぼし/遮蔽)
・30秒以上:ほぼ通信断
・999秒:Node-RED起動後に一度も受信していない(完全OFFLINE扱い)

10. よくあるエラーと対処

A) ESP_ERR_ESPNOW_CHAN:Gatewayと子機のチャンネル不一致 → WIFI_CHANNELを合わせる
B) DHT read failed:ピン違い/配線/電源 → GPIO4にDATAが来ているか確認
C) PIRが常に1:PIRの初期安定化時間や感度/保持時間設定の影響。Node-RED側でホールド処理も可能。

Node-RED フローJSON(完全版:Sensors + CSV + LinkHealth)

記録の開始・終了ボタン付きコード

[
  {
    "id": "480405ff253f2807",
    "type": "tab",
    "label": "ESP-NOW via MQTT - Session CSV",
    "disabled": false,
    "info": ""
  },
  {
    "id": "76b83d203765ef4b",
    "type": "ui_switch",
    "z": "480405ff253f2807",
    "name": "データ保存ON/OFF",
    "label": "データ保存(セッション1ファイル)",
    "tooltip": "",
    "group": "5bee89f211812235",
    "order": 1,
    "width": "10",
    "height": "2",
    "passthru": true,
    "decouple": "false",
    "topic": "save",
    "topicType": "str",
    "style": "",
    "onvalue": "true",
    "onvalueType": "bool",
    "onicon": "",
    "oncolor": "",
    "offvalue": "false",
    "offvalueType": "bool",
    "officon": "",
    "offcolor": "",
    "animate": true,
    "className": "",
    "x": 200,
    "y": 60,
    "wires": [
      [
        "419927fcb29cd1cc",
        "504a24ee9a82c618"
      ]
    ]
  },
  {
    "id": "504a24ee9a82c618",
    "type": "ui_toast",
    "z": "480405ff253f2807",
    "position": "top right",
    "displayTime": "3",
    "highlight": "",
    "sendall": true,
    "outputs": 0,
    "ok": "OK",
    "cancel": "",
    "raw": false,
    "topic": "",
    "name": "toast 保存状態",
    "x": 520,
    "y": 60,
    "wires": []
  },
  {
    "id": "419927fcb29cd1cc",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "Save control (make session filename + header)",
    "func": "// ui_switch: msg.payload = true/false\nconst on = !!msg.payload;\nflow.set(\"saveEnabled\", on);\n\nif (on) {\n  // セッション開始:ファイル名を固定\n  const d = new Date();\n  const pad = (n)=> String(n).padStart(2,\"0\");\n  const y = d.getFullYear();\n  const mo = pad(d.getMonth()+1);\n  const da = pad(d.getDate());\n  const hh = pad(d.getHours());\n  const mm = pad(d.getMinutes());\n  const ss = pad(d.getSeconds());\n\n  const fname = `/media/PI_SHARE_500G/sensor_logs/session_${y}${mo}${da}_${hh}${mm}${ss}.csv`;\n  flow.set(\"saveFile\", fname);\n\n  // ヘッダを1行書く\n  msg.filename = fname;\n  msg.payload = \"timestamp,node,temp,hum,pir\\n\";\n\n  // UI通知文\n  msg.toast = `保存開始: ${fname}`;\n  return msg;\n}\n\n// セッション停止\nflow.set(\"saveFile\", null);\nmsg.toast = \"保存停止\";\nreturn null;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 530,
    "y": 160,
    "wires": [
      [
        "bf702340f2caf765"
      ]
    ]
  },
  {
    "id": "805bbddd9659efa5",
    "type": "mqtt in",
    "z": "480405ff253f2807",
    "name": "MQTT IN sensors/+/dht11",
    "topic": "sensors/+/dht11",
    "qos": "0",
    "datatype": "auto",
    "broker": "mqtt_broker_local",
    "nl": false,
    "rap": true,
    "rh": 0,
    "inputs": 0,
    "x": 200,
    "y": 240,
    "wires": [
      [
        "61613131092844e4"
      ]
    ]
  },
  {
    "id": "fea62437776442e4",
    "type": "mqtt in",
    "z": "480405ff253f2807",
    "name": "MQTT IN sensors/+/pir",
    "topic": "sensors/+/pir",
    "qos": "0",
    "datatype": "auto",
    "broker": "mqtt_broker_local",
    "nl": false,
    "rap": true,
    "rh": 0,
    "inputs": 0,
    "x": 200,
    "y": 420,
    "wires": [
      [
        "8c33e919b27c12f7"
      ]
    ]
  },
  {
    "id": "61613131092844e4",
    "type": "json",
    "z": "480405ff253f2807",
    "name": "JSON parse (dht11)",
    "property": "payload",
    "action": "",
    "pretty": false,
    "x": 430,
    "y": 240,
    "wires": [
      [
        "658e87a0b5178054",
        "f00928d8de8c2b73",
        "d4f1f8a29d5d1c2a"
      ]
    ]
  },
  {
    "id": "8c33e919b27c12f7",
    "type": "json",
    "z": "480405ff253f2807",
    "name": "JSON parse (pir)",
    "property": "payload",
    "action": "",
    "pretty": false,
    "x": 430,
    "y": 420,
    "wires": [
      [
        "ba1922bb94b12715",
        "d4f1f8a29d5d1c2a"
      ]
    ]
  },

  {
    "id": "d4f1f8a29d5d1c2a",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "LinkHealth: update lastSeen (fieldxx)",
    "func": "// 受信したら、そのnodeの最終受信時刻を更新\n// msg.payload: { node: \"field01\" ... }\nconst p = msg.payload;\nif (p && p.node) {\n  flow.set(\"lastSeen_\" + p.node, Date.now());\n}\nreturn null;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 760,
    "y": 320,
    "wires": [
      []
    ]
  },
  {
    "id": "c8a6b4cb9f2c2f9e",
    "type": "inject",
    "z": "480405ff253f2807",
    "name": "LinkHealth tick (1s)",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "1",
    "crontab": "",
    "once": true,
    "onceDelay": "1",
    "topic": "",
    "payload": "1",
    "payloadType": "num",
    "x": 210,
    "y": 520,
    "wires": [
      [
        "2a43bfbd44c6a9a1"
      ]
    ]
  },
  {
    "id": "2a43bfbd44c6a9a1",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "LinkHealth: age(sec) field01/02/03",
    "func": "// 最終受信からの経過秒を作る(A案)\nconst now = Date.now();\nconst nodes = [\"field01\",\"field02\",\"field03\"];\n\nfunction ageSec(node){\n  const t = flow.get(\"lastSeen_\" + node);\n  if (!t) return 999; // まだ受信なし\n  return Math.max(0, Math.round((now - t)/1000));\n}\n\nreturn [\n  { payload: ageSec(nodes[0]) },\n  { payload: ageSec(nodes[1]) },\n  { payload: ageSec(nodes[2]) }\n];",
    "outputs": 3,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 520,
    "y": 520,
    "wires": [
      [
        "e8f1f3f6e5d00a11"
      ],
      [
        "b2b4e68737a3d5d0"
      ],
      [
        "e3de3b2d6b1d9d2b"
      ]
    ]
  },
  {
    "id": "e8f1f3f6e5d00a11",
    "type": "ui_gauge",
    "z": "480405ff253f2807",
    "name": "LinkHealth field01",
    "group": "5bee89f211812235",
    "order": 2,
    "width": "4",
    "height": "3",
    "gtype": "gage",
    "title": "受信状態 field01(経過秒)",
    "label": "sec",
    "format": "{{value}}",
    "min": 0,
    "max": 60,
    "colors": [
      "#00b500",
      "#e6e600",
      "#ca3838"
    ],
    "seg1": "10",
    "seg2": "30",
    "x": 840,
    "y": 500,
    "wires": []
  },
  {
    "id": "b2b4e68737a3d5d0",
    "type": "ui_gauge",
    "z": "480405ff253f2807",
    "name": "LinkHealth field02",
    "group": "5bee89f211812235",
    "order": 3,
    "width": "4",
    "height": "3",
    "gtype": "gage",
    "title": "受信状態 field02(経過秒)",
    "label": "sec",
    "format": "{{value}}",
    "min": 0,
    "max": 60,
    "colors": [
      "#00b500",
      "#e6e600",
      "#ca3838"
    ],
    "seg1": "10",
    "seg2": "30",
    "x": 840,
    "y": 540,
    "wires": []
  },
  {
    "id": "e3de3b2d6b1d9d2b",
    "type": "ui_gauge",
    "z": "480405ff253f2807",
    "name": "LinkHealth field03",
    "group": "5bee89f211812235",
    "order": 4,
    "width": "4",
    "height": "3",
    "gtype": "gage",
    "title": "受信状態 field03(経過秒)",
    "label": "sec",
    "format": "{{value}}",
    "min": 0,
    "max": 60,
    "colors": [
      "#00b500",
      "#e6e600",
      "#ca3838"
    ],
    "seg1": "10",
    "seg2": "30",
    "x": 840,
    "y": 580,
    "wires": []
  },

  {
    "id": "658e87a0b5178054",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "Split for UI (temp/hum/pir)",
    "func": "// payload例: { node, temp, hum, pir }\nconst p = msg.payload;\nif (!p || !p.node) return null;\n\nconst temp = Number(p.temp);\nconst hum  = Number(p.hum);\nconst pir  = Number(p.pir);\nif (Number.isNaN(temp) || Number.isNaN(hum)) return null;\n\nreturn [\n  { topic: p.node, payload: temp },\n  { topic: p.node, payload: hum  },\n  { topic: p.node, payload: Number.isNaN(pir) ? 0 : pir }\n];",
    "outputs": 3,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 660,
    "y": 240,
    "wires": [
      [
        "f73972a1ada50877"
      ],
      [
        "a3e45e29a93bab99"
      ],
      [
        "70a2820c6fcccb9b"
      ]
    ]
  },
  {
    "id": "ba1922bb94b12715",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "PIR event normalize",
    "func": "// payload例: { node, pir }\nconst p = msg.payload;\nif (!p || !p.node) return null;\nconst pir = Number(p.pir);\nif (Number.isNaN(pir)) return null;\nmsg.topic = p.node;\nmsg.payload = pir;\nreturn msg;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 670,
    "y": 420,
    "wires": [
      [
        "1477e0a5645df64d",
        "2f55ae1b047cd0f9"
      ]
    ]
  },
  {
    "id": "1477e0a5645df64d",
    "type": "rbe",
    "z": "480405ff253f2807",
    "name": "PIR: Block unless value changes (RBE)",
    "func": "rbe",
    "gap": "",
    "start": "",
    "inout": "out",
    "property": "payload",
    "x": 980,
    "y": 400,
    "wires": [
      [
        "70a2820c6fcccb9b"
      ]
    ]
  },
  {
    "id": "2f55ae1b047cd0f9",
    "type": "delay",
    "z": "480405ff253f2807",
    "name": "PIR: rate limit (1 msg/sec)",
    "pauseType": "rate",
    "timeout": "5",
    "timeoutUnits": "seconds",
    "rate": "1",
    "nbRateUnits": "1",
    "rateUnits": "second",
    "randomFirst": "1",
    "randomLast": "5",
    "randomUnits": "seconds",
    "drop": true,
    "allowrate": false,
    "outputs": 1,
    "x": 930,
    "y": 480,
    "wires": [
      [
        "70a2820c6fcccb9b"
      ]
    ]
  },
  {
    "id": "f00928d8de8c2b73",
    "type": "function",
    "z": "480405ff253f2807",
    "name": "To CSV line (only when saving)",
    "func": "// 保存ONのときだけ、セッションファイルへ追記\nconst saveEnabled = flow.get(\"saveEnabled\");\nif (!saveEnabled) return null;\n\nconst fname = flow.get(\"saveFile\");\nif (!fname) return null;\n\nconst p = msg.payload;\nif (!p || !p.node) return null;\n\n// ISO時刻(UTC)\nconst ts = new Date().toISOString();\n\nconst temp = (p.temp !== undefined) ? p.temp : \"\";\nconst hum  = (p.hum  !== undefined) ? p.hum  : \"\";\nconst pir  = (p.pir  !== undefined) ? p.pir  : \"\";\n\nif (temp === \"\" || hum === \"\") return null;\n\nmsg.payload = `${ts},${p.node},${temp},${hum},${pir}\\n`;\nmsg.filename = fname;\nreturn msg;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 730,
    "y": 600,
    "wires": [
      [
        "bf702340f2caf765"
      ]
    ]
  },
  {
    "id": "bf702340f2caf765",
    "type": "file",
    "z": "480405ff253f2807",
    "name": "Append CSV to SSD (session file)",
    "filename": "filename",
    "filenameType": "msg",
    "appendNewline": false,
    "createDir": false,
    "overwriteFile": "false",
    "encoding": "none",
    "x": 1060,
    "y": 600,
    "wires": [
      []
    ]
  },

  {
    "id": "f73972a1ada50877",
    "type": "switch",
    "z": "480405ff253f2807",
    "name": "Temp by node",
    "property": "topic",
    "propertyType": "msg",
    "rules": [
      { "t": "eq", "v": "field01", "vt": "str" },
      { "t": "eq", "v": "field02", "vt": "str" },
      { "t": "eq", "v": "field03", "vt": "str" }
    ],
    "checkall": "true",
    "repair": false,
    "outputs": 3,
    "x": 920,
    "y": 100,
    "wires": [
      [ "37c7bd02d94946cd" ],
      [ "1006ba188e9c3a8a" ],
      [ "00272f3aa0a5a548" ]
    ]
  },
  {
    "id": "a3e45e29a93bab99",
    "type": "switch",
    "z": "480405ff253f2807",
    "name": "Hum by node",
    "property": "topic",
    "propertyType": "msg",
    "rules": [
      { "t": "eq", "v": "field01", "vt": "str" },
      { "t": "eq", "v": "field02", "vt": "str" },
      { "t": "eq", "v": "field03", "vt": "str" }
    ],
    "checkall": "true",
    "repair": false,
    "outputs": 3,
    "x": 910,
    "y": 180,
    "wires": [
      [ "24a30a24564819eb" ],
      [ "3c393300d902efbd" ],
      [ "65d63031050a3cb7" ]
    ]
  },
  {
    "id": "70a2820c6fcccb9b",
    "type": "switch",
    "z": "480405ff253f2807",
    "name": "PIR by node",
    "property": "topic",
    "propertyType": "msg",
    "rules": [
      { "t": "eq", "v": "field01", "vt": "str" },
      { "t": "eq", "v": "field02", "vt": "str" },
      { "t": "eq", "v": "field03", "vt": "str" }
    ],
    "checkall": "true",
    "repair": false,
    "outputs": 3,
    "x": 910,
    "y": 260,
    "wires": [
      [ "677817f50702e6af" ],
      [ "455da7143a0341da" ],
      [ "9d43c7c5aa409096" ]
    ]
  },

  {
    "id": "37c7bd02d94946cd",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Temp field01",
    "group": "20d47d5c5f426713",
    "order": 1,
    "width": "10",
    "height": "6",
    "label": "Temperature (°C)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "-5",
    "ymax": "40",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1210,
    "y": 80,
    "wires": [ [] ]
  },
  {
    "id": "24a30a24564819eb",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Hum field01",
    "group": "20d47d5c5f426713",
    "order": 2,
    "width": "10",
    "height": "6",
    "label": "Humidity (%)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "100",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1210,
    "y": 160,
    "wires": [ [] ]
  },
  {
    "id": "677817f50702e6af",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "PIR field01",
    "group": "20d47d5c5f426713",
    "order": 3,
    "width": "10",
    "height": "3",
    "label": "PIR (0/1)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "step",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "1",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1210,
    "y": 260,
    "wires": [ [] ]
  },

  {
    "id": "1006ba188e9c3a8a",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Temp field02",
    "group": "988d68a591343f1b",
    "order": 1,
    "width": "10",
    "height": "6",
    "label": "Temperature (°C)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "-5",
    "ymax": "40",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 400,
    "wires": [ [] ]
  },
  {
    "id": "3c393300d902efbd",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Hum field02",
    "group": "988d68a591343f1b",
    "order": 2,
    "width": "10",
    "height": "6",
    "label": "Humidity (%)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "100",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 440,
    "wires": [ [] ]
  },
  {
    "id": "455da7143a0341da",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "PIR field02",
    "group": "988d68a591343f1b",
    "order": 3,
    "width": "10",
    "height": "3",
    "label": "PIR (0/1)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "step",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "1",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 480,
    "wires": [ [] ]
  },

  {
    "id": "00272f3aa0a5a548",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Temp field03",
    "group": "3632e3e784196e63",
    "order": 1,
    "width": "10",
    "height": "6",
    "label": "Temperature (°C)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "-5",
    "ymax": "40",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 580,
    "wires": [ [] ]
  },
  {
    "id": "65d63031050a3cb7",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "Hum field03",
    "group": "3632e3e784196e63",
    "order": 2,
    "width": "10",
    "height": "6",
    "label": "Humidity (%)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "linear",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "100",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 620,
    "wires": [ [] ]
  },
  {
    "id": "9d43c7c5aa409096",
    "type": "ui_chart",
    "z": "480405ff253f2807",
    "name": "PIR field03",
    "group": "3632e3e784196e63",
    "order": 3,
    "width": "10",
    "height": "3",
    "label": "PIR (0/1)",
    "chartType": "line",
    "legend": "false",
    "xformat": "HH:mm:ss",
    "interpolate": "step",
    "nodata": "",
    "dot": false,
    "ymin": "0",
    "ymax": "1",
    "removeOlder": "12",
    "removeOlderPoints": "",
    "removeOlderUnit": "60",
    "cutout": 0,
    "useOneColor": false,
    "useUTC": true,
    "outputs": 1,
    "useDifferentColor": false,
    "className": "",
    "x": 1350,
    "y": 660,
    "wires": [ [] ]
  },

  {
    "id": "5bee89f211812235",
    "type": "ui_group",
    "name": "Control",
    "tab": "440e309339065fd4",
    "order": 1,
    "disp": true,
    "width": "12",
    "collapse": false
  },
  {
    "id": "20d47d5c5f426713",
    "type": "ui_group",
    "name": "field01",
    "tab": "440e309339065fd4",
    "order": 2,
    "disp": true,
    "width": "12",
    "collapse": false
  },
  {
    "id": "988d68a591343f1b",
    "type": "ui_group",
    "name": "field02",
    "tab": "440e309339065fd4",
    "order": 3,
    "disp": true,
    "width": "12",
    "collapse": false
  },
  {
    "id": "3632e3e784196e63",
    "type": "ui_group",
    "name": "field03",
    "tab": "440e309339065fd4",
    "order": 4,
    "disp": true,
    "width": "12",
    "collapse": false
  },
  {
    "id": "440e309339065fd4",
    "type": "ui_tab",
    "name": "Field Sensors (Session)",
    "icon": "dashboard",
    "disabled": false,
    "hidden": false
  },
  {
    "id": "mqtt_broker_local",
    "type": "mqtt-broker",
    "name": "localhost",
    "broker": "127.0.0.1",
    "port": "1883",
    "clientid": "",
    "usetls": false,
    "protocolVersion": "4",
    "keepalive": "60",
    "cleansession": true,
    "birthTopic": "",
    "birthQos": "0",
    "birthPayload": "",
    "closeTopic": "",
    "closeQos": "",
    "closePayload": "",
    "willTopic": "",
    "willQos": "",
    "willPayload": ""
  }
]