1.セントラル制御装置の所要機能
セントラル制御装置は、少なくとも以下のような機能を備えておく必要があります。
○絶対的な混信防止機能
複数の LoRa端末間で、計測結果送信時に衝突が発生しない仕組みが必要です。そのためには、計測タイミングになって計測指示を送信する際は、接続された端末に対して的確な間隔が設定されなければなりません。
○計測時刻の付加機能
個々の LoRa端末で時刻管理をするのはコストが高くつくので、端末からの受信時を計測時刻とします。そのために、正確な日時を取得する仕組みが必要です。
○計測データの欠測値への対応
温度湿度計測センサー(DHT11)は計測に失敗すると、計測値として "nan"が通知されます。数値項目にこれを記録するためには、欠測値を表す数値定数への変換が必要です。
○端末の故障への対応
端末が故障して通信を完了できないケースに対応する必要があります。一端末の故障発生がシステムダウンを引き起こさない対策と、異常発生を通知(表示)するなどの機能が必要です。
2.主要機能の実装方法
(1) Wi-Fiライブラリの組み込み
時刻合わせをするために NTP(Network Time Protocol)サーバーを利用することになります。NTPサーバーには Wi-Fiでインターネット接続するため、Wi-Fiライブラリを組み込む必要があります。ヘッダーファイル WiFi.hをインクルードし、初期化処理の先頭で Wi-Fiに接続します。
(2) 正確な時刻情報の取得
NTPサーバーと同期して正しい時刻情報を取得します。厳密にはネットワークによる遅延が生じるので「ほぼ正しい時刻」ということになります。いったん同期すると、以降は定期的に自動同期されて時刻調整が行われます。必要であれば、コールバック関数で同期の情況を観察できます。
同期した時刻は ESP32の内部クロックに反映されるので、getLocalTime()で取り出すことができます。それを
yyyy-mm-dd hh:mm:ss
の形式の文字列に編集して提供します。
(3) 計測データ欠測値の変換処理
文字型 "nan"で通知される計測値を、以後の数値型計測データ出力に合わせるため「-99.99」に置換します。ファイル等への出力後は、計測値の値が -99.99のものを欠測値として判定・処理することができます。
(4) 混信防止のための端末制御
ある端末に計測指示を送信すると、その端末からの計測データの受信を待って次の端末に計測指示を送信します。各端末に設定した端末識別番号(1から始まる重複のない連続番号)順に送信し、受信データが衝突することはありません。
(5) 端末の故障への対応
端末への計測指示を送信して応答を待ちますが、端末の故障など異常が発生すると先に進めなくなります。これを避けるために「計測タイムアウト時間」を設定します。所定の時間を経過しても応答がない場合は、タイムアウトメッセージを表示して待ち状態を解除し、次の端末に処理を進めます。
3.コードの解説
セントラル制御処理: esp32loraCentral.ino
setup()と loop()、送受信関数などの基本的な処理は、前章の testTransmitter.inoがベースになっています。説明が重複するかも知れませんが要点について解説します。
①ヘッダーファイルとデータ等の定義
・24~26行: 今回使用する新たなライブラリのヘッダーファイルです。
WiFi.h: WiFiライブラリを利用するためのヘッダーファイル。
time.h: 時間を扱うためのヘッダーファイル。
esp_sntp.h: NTP(Network Time Protocol)を利用するためのヘッダーファイル。
・30行目: 今回も2台の端末でテストしています。
・32行目: 計測間隔は10秒です。
・33行目: 計測指示から応答まで、5秒のタイムアウト制限を設定します。
・34~35行: 環境に合った値を設定してください。
・44~48行: 新たな関数のプロトタイプです。
・53行目: 計測指示の送信を要求するフラグです。
・54行目: 計測データの受信完了を表すフラグです。
#include "esp32_e220900t22s_jp_lib.h"
#include <WiFi.h>
#include "time.h"
#include "esp_sntp.h"
#define TERMINAL_NUMBER 0
#define LED_BUILTIN 2 // 内蔵LED
#define NUMBER_OF_TERMINALS 2 // 端末台数
#define SEND_DATA_SIZE 29 // 32-3
#define MEASURE_INTERVAL 10 // 計測間隔(整数:秒)
#define MEASURE_TIMEOUT 5 // 計測タイムアウト(整数:秒)
#define WIFI_SSID "your_ssid"
#define WIFI_PASSWORD "your_password"
CLoRa lora; // LoRaクラスのインスタンス
struct LoRaConfigItem_t config; // LoRaコンフィギュレーション
struct RecvFrameE220900T22SJP_t data; // 受信用構造体
// Function prototype
void LoRaRecvTask(void *pvParameters); // 受信タスク
void LoRaSendTask(void *pvParameters); // 送信タスク
void startWiFi(const char*, const char*); // WiFi接続
void prepareDayTime(void); // NTP時刻との同期
void timeavailable(struct timeval*); // 時刻同期コールバック関数
char* getDayTime(void); // 現在の日時の取得
void strrep(char *, const char *, // 文字列の置換
const char *);
void timeavailable(struct timeval*); // 時刻同期用コールバック
unsigned long prev_time = 0; // 前回の計測時刻
bool doSendSignal = false; // シグナル送信せよ!
bool receiveCompleted = false; // データ受信完了!
unsigned long sendCtr = 0; // 計測指示回数
②setup()、loop()、送受信関数などの基本的な処理
・66行目: Wi-Fiに接続します。
・67行目: NTPサーバーと同期して正しい日時情報を取得します。
・70行目: LoRaモジュールの既定のコンフィギュレーションを取得します。
・74~75行: コンフィギュレーションに自身のアドレス(端末識別番号)を設定してモジュールを初期化します。
・81~82行: 従来どおり、ここでも送受信はマルチタスクで行います。
・85行目: 端末をウェークアップさせるために、計測指示は WORモードで送信します。
・95~96行: お馴染みの計測時間到来のチェックで、到来すれば doSendSignalを trueにします。
・111行目: 受信データが届くと以下を実行します。
・113~115行: 受信データを取り出して計測日時と合わせて編集し、欠測値は -99.99に置換して表示します。
・116行目: 受信完了フラグを ONにします。
・132~133行: 送信要求フラグが ONなら、それをリセットして以下を実行します。
・136行目: 端末識別番号 1から端末数だけ以下を順次実行します。
・137~138行: 対象端末のデバイスアドレス(端末識別番号)を設定して計測指示を送信します。
・149行目: 経過時間が計測タイムアウト値を超えるか受信が完了していれば以下を実行します。
・150~151行: 受信完了でなければタイムアウトなので、その旨を表示します。
・152~153行: 受信完了フラグを OFFにして、次の送信要求待ちに戻ります。
/*****************************************************************************
* Predetermined Sequence *
*****************************************************************************/
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
SerialMon.begin(115200);
delay(1000);
// WiFiに接続して NTP時刻と同期させる
startWiFi(WIFI_SSID, WIFI_PASSWORD);
prepareDayTime();
// 既定のLoRaコンフィギュレーションを取得して
lora.SetDefaultConfigValue(config);
delay(10);
// E220-900T22S(JP)を初期設定する
config.own_address = TERMINAL_NUMBER;
if (lora.InitLoRaModule(config)) {
SerialMon.printf("LoRa init error\n");
return;
}
// マルチタスクを設定して
xTaskCreateUniversal(LoRaRecvTask, "LoRaRecvTask", 8192, NULL, 1, NULL, APP_CPU_NUM);
xTaskCreateUniversal(LoRaSendTask, "LoRaSendTask", 8192, NULL, 1, NULL, APP_CPU_NUM);
// WOR送信モードへ移行する
lora.SwitchToWORSendingMode();
delay(10);
SerialMon.println("Begin sending message!");
}
void loop() {
int differ;
unsigned long now = millis();
differ = (now - prev_time) / 1000;
if (differ >= (int)MEASURE_INTERVAL && doSendSignal == false) {
doSendSignal = true;
prev_time = now;
Serial.printf("> Request to send message .... %d\n", now / 1000);
}
delay(1000);
}
/*****************************************************************************/
/*
受信タスク
*/
void LoRaRecvTask(void *pvParameters) {
char str[256];
while (1) {
if (lora.ReceiveFrame(&data) == 0) { // 受信データが届くと表示処理を行う
data.recv_data[data.recv_data_len] = '\0';
sprintf(str, "Receive data: %s (%s)", data.recv_data, getDayTime());
strrep(str, "nan", "-99.99"); // 欠測値の処置
SerialMon.println(str);
receiveCompleted = true;
digitalWrite(LED_BUILTIN, HIGH);
delay(200);
digitalWrite(LED_BUILTIN, LOW);
}
delay(1);
}
}
/*
送信タスク
*/
void LoRaSendTask(void *pvParameters) {
char msg[SEND_DATA_SIZE] = { 0 };
while (1) {
if (doSendSignal) { // 送信要求があると送信処理を行う
doSendSignal = false;
sprintf(msg, "Go ahead! (%ld)", ++sendCtr);
for (int i=1; i<=NUMBER_OF_TERMINALS; i++) {
config.target_address = i;
int state = lora.SendFrame(config, (uint8_t *)msg, sizeof(msg));
if (state == 0)
SerialMon.printf("Terminal-%d kick off succeeded!\n", i);
else
SerialMon.printf("Terminal-%d kick off failed!\n", i);
// 計測端末のタイムアウトを監視する
unsigned long pivotTime = millis();
while (1) {
unsigned long now = millis();
int differ = (now - pivotTime) / 1000;
if (differ >= (int)MEASURE_TIMEOUT || receiveCompleted) {
if (!receiveCompleted)
SerialMon.printf("<< Timeout occurred! Terminal No.%d >>\n", i);
receiveCompleted = false;
break;
}
delay(10);
}
}
}
delay(1);
}
}
③今回追加した関数
・166行目: Wi-Fi接続関数です。
・167~168行: SSIDとパスワードで Wi-Fiに接続して完了を待ちます。
・178行目: NTPとの同期間数です。
・179行目: 同期時に実行するコールバック関数を指定しています。
・181行目: この関数で NTPサーバーと同期します。
第1引数: グリニッジ標準時間と日本時間との差を秒数で指定。 +9(時間) x 3600(秒/時間)
第2引数: 夏時間調整時差。 ゼロ。
第3引数: NTPサーバー第1候補。
第4引数: NTPサーバー第2候補。 第3候補まで指定可能。
・187行目: 時刻同期で呼び出されるコールバック関数です。
・196行目: 現在の日時文字列へのポインターを返す関数です。
・199行~201行: 日時を取得して”yyyy-mm-dd hh:nn:ss”の形式に編集します。
・209行目: 文字配列中の対象文字列を別の文字列に置き換える関数です。
/*
WiFiに接続する
*/
void startWiFi(const char *id, const char *pwd) {
WiFi.begin(id, pwd);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected!");
}
/*
NTPの時刻と同期する
*/
void prepareDayTime() {
sntp_set_time_sync_notification_cb(timeavailable);
const long gmtOffset_sec = 9 * 3600;
configTime(gmtOffset_sec, (int)0, "ntp.nict.jp", "time.nist.gov");
}
/*
時刻同期コールバック関数
*/
void timeavailable(struct timeval *t) {
Serial.println("Time adjustment from NTP!");
Serial.printf(" ==> %s\n", getDayTime());
}
/*
現在の日時文字列を取得する
形式: yyyy-mm-dd hh:nn:ss
*/
char* getDayTime() {
struct tm t;
static char buf[24] = {0};
if (getLocalTime(&t)) {
sprintf(buf, "%d-%02d-%02d %02d:%02d:%02d",
1900+t.tm_year, t.tm_mon+1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec);
} else { buf[0] = '\0'; }
return buf;
}
/*
該当する文字列をすべて別の文字列に置換する
*/
void strrep(char *targ, const char *from, const char *to)
{
char buf[256];
char *p;
while ((p = strstr(targ, from)) != NULL) {
*p = '\0';
p += strlen(from);
strcpy(buf, p);
strcat(targ, to);
strcat(targ, buf);
}
}
/* WiFiに接続する */ void startWiFi(const char *id, const char *pwd) { WiFi.begin(id, pwd); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi connected!"); } /* NTPの時刻と同期する */ void prepareDayTime() { sntp_set_time_sync_notification_cb(timeavailable); const long gmtOffset_sec = 9 * 3600; configTime(gmtOffset_sec, (int)0, "ntp.nict.jp", "time.nist.gov"); } /* 時刻同期コールバック関数 */ void timeavailable(struct timeval *t) { Serial.println("Time adjustment from NTP!"); Serial.printf(" ==> %s\n", getDayTime()); } /* 現在の日時文字列を取得する 形式: yyyy-mm-dd hh:nn:ss */ char* getDayTime() { struct tm t; static char buf[24] = {0}; if (getLocalTime(&t)) { sprintf(buf, "%d-%02d-%02d %02d:%02d:%02d", 1900+t.tm_year, t.tm_mon+1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec); } else { buf[0] = '\0'; } return buf; } /* 該当する文字列をすべて別の文字列に置換する */ void strrep(char *targ, const char *from, const char *to) { char buf[256]; char *p; while ((p = strstr(targ, from)) != NULL) { *p = '\0'; p += strlen(from); strcpy(buf, p); strcat(targ, to); strcat(targ, buf); } }
4.システムの動作確認
まず、esp32loraTerminal.inoを書き込んだ 2台の計測用端末の電源を入れます。この状態で端末は初回の setup()を実行してディープスリープに入っていますが、初回実行時のセンターへの送信がセンター側で受信されることはありません。
続いて今回開発した esp32loraCentralを書き込んだ ESP32をパソコンに USB接続し、Arduino IDEを立ち上げてシリアルモニターを観察します。ESP32のリセットボタンを押すと、Wi-Fiに接続して NTPサーバーと同期をとります。「Time ajustment from NTP!」の後に NTPと同期した日時が表示されます。その後は定期的に自動時刻合わせされますが、その時にはこのメッセージが表示されます(それを観察していることはないと思いますが)。

計測端末に計測指示メッセージ「Go ahead!」を送信すると、「Receive data:」に続いて受信データが表示されます。
Receive data: 01,0117,19.6,65.0 (2025-04-28 18:36:39)
最初の 01は計測端末の識別番号、次の4桁は計測端末の計測回数、続いて温度と湿度が表示されています。その後のカッコ内は計測日付と時刻です。これが端末台数分だけ、計測間隔ごとに繰り返されます。これは前回でも指摘しましたが、現在の計測間隔10秒は余裕がなく、端末台数に応じて計測間隔を十分にとる必要があることに注意してください。
端末に異常が発生して動作しなくなったケースを試すため、識別番号1の端末の電源を OFFにしてみます。するとシリアルモニターに異常メッセージ「<< Timeout occured! Terminal No.1 >>」が表示されます。その後もシステムは停止することなく、識別番号2の端末からの受信が継続されます。

以上でセントラル制御装置が完成です。と言っても、受信データは表示されるだけであり、これをファイルに記録するような処理の追加が必要になるでしょう。
しかしここまで出来上がったのだから、もうひと頑張りして、データベースサーバーに計測データを送信・保管するゲートウェイに格上げしてみたいと思います。次回最終回は『LoRa Gatewayの開発』です。 どうぞお楽しみに!