ESP32 において NTP の時刻同期を捕まえて RTC を更新する


はじめに

 IoT 機器は、何らかの方法でインターネットに接続されますが、正確な時刻を保持するための NTP の利用には、恵まれたネットワークや電源が必要です。恵まれていなければ RTC などが必要になります。ここでは、M5Atom1 を台材に RTC と NTP とを補完的に使用する具体的なコーディングについて検討しました。作成したコードや設計データは、GitHub2 にあります。

  • IoT: Internet of Things
  • NTP: Network Time Protocol
  • RTC: Real Time Clock

1. ESP32 - M5Atom

 M5Atom は、ESP323 を搭載し Wi-Fi 経由で NTP を利用できます。RTC は内蔵していないので GPIO から I2C バスを取り出し、RTC を外付けします。RTC 基板の詳細は、Qiita「M5Atom, M5Stack Core 用の I2C リアルタイムクロック基板を作って動かす」4を参照ください。

  • Wi-Fi: Wireless Fidelity(無線によるローカルエリアネットワーク技術)
  • GPIO: General Purpose Input/Output
  • I2C: Inter Integrated Circuit(フィリップス社で開発されたシリアルバス)

2. Arduino - ESP32

 ESP32 には、Aruduino IDE5 上でコーディングを行うためのコンパイラ環境やライブラリ6が用意されています。M5Atom にもこれらを利用したライブラリ7が用意されています。ESP32 の開発環境は ESP-IDF に基づいており、そのプログラミングガイド8があります。時刻に関する機能の説明は、プログラミングガイドの System Time9 に書かれています。

  • IDE: Integrated Development Environment
  • ESP-IDF: Espressif IoT Development Framework

3. RTC

 水晶振動子等を用いて高精度に時刻をカウントする周辺機器です。電源断の状態でも電池等によるバックアップにより時刻をカウントし続けます。水晶振動子の精度や温度変化の影響で時刻にずれが生じるため、適切な間隔で時刻を合わせる必要があります。

電源投入時、RTC の現在時刻を装置本体へ反映する

 装置本体の電源が投入された際に、RTC から現在時刻を読み出して装置本体に設定します。

  1. rtcx.ReadTime()
     RTC が保持する時刻を読み出す自作の関数 rtcx.ReadTime()[^13] は、構造体 struct tm 型への参照を引数とし、メンバ変数の時・分・秒・年・月・日・曜日に直接代入する作りにしました。

  2. mktime()
     struct tm 型の時・分・秒・年・月・日から UNIX 時刻への換算には、関数 mktime() を使用します。日本標準時(JST)からの変換を指定するため、環境変数 TZ に "JST-9" を予め設定しておきます。mktime() において曜日は無視されます。

  3. settimeofday()
     装置本体への時刻の設定には、関数 settimeofday() を使用します。引数は、構造体 struct timeval 型への参照です。メンバ変数 tv_sec(秒)に UNIX 時刻を設定し、tv_usec(マイクロ秒)には便宜上 0 を設定します。settimeofday() は、秒より下の桁も更新しますが、装置本体の時刻について、秒より下の桁を時刻を、例えば RTC の秒更新のタイミングで 0 に合わせるなどは困難であり、割り切りが必要と思います。struct timeval や settimeofday() を使用するために #include <sys/time.h> が必要です。

  4. Serial.print()
     struct tm 型の時刻を指定様式で直接出力できる Serial.print(), Serial.println() が便利です。バッファの長さ 64 文字に収まらない場合、出力が不定になります。1 文字も出力されない現象を経験しました。Wednesday, September で確認しておく必要があります。

  • UNIX 時刻: 協定世界時(UTC)での 1970 年 1 月 1 日午前 0 時 0 分 0 秒から形式的な経過秒数
  • UTC: Coordinated Universal Time
  • JST: Japan Standard Time
settimeofday.cpp
#include <M5Atom.h>
#include <sys/time.h>  // for struct timeval

// for SNTP and RTC
const char* time_zone  = "JST-9";

void setup()
{
  ...
  struct tm tm_init;

  setenv("TZ", time_zone, 1);
  tzset();  // Assign the local timezone from setenv for mktime()
  if (rtcx.ReadTime(&tm_init) == 0) {
    struct timeval tv = { mktime(&tm_init), 0 };
    settimeofday(&tv, NULL);
  }
  getLocalTime(&tm_init);
  Serial.print(&tm_init, "Set time from RTCx: %A, %B %d %Y %H:%M:%S\n");
  ...
}

4. SNTP

 NTP クライアント(装置本体の時刻を合わせる)の設定は、関数 configTZtime() で行います。ネットワークに関するなんらかの処理が事前に必要な模様で、例えば WiFi.begin() を実行しておかないと実行時にエラーとなりリブートを繰り返すことになりました。WiFi.begin() 相当の処理を一度実行すれば、その後 WiFi.disconnect(true, true) として Wi-Fi インタフェースが見えなくなった状態でも、configTZTime() はエラーになりません。(※その後、改善されたかは未確認です。)

SNTP による時刻同期と RTC への反映

 SNTP による時刻同期の様子は、#include <sntp.h> で提供される機能で得ることができます。これらの機能は Espressif 社の提供する arduino-ESP326 1.0.6 以降で利用できます。M5Stack 社が提供しているボードマネージャ 2.0.010 で利用できます。※執筆時最新の arduino-ESP32 は 2.0.1 ですが、M5Stack ライブラリ自体が対応していない模様です。

  1. configTzTime()
     SNTP にタイムゾーンと NTP サーバを指定し SNTP を有効化します。その後、時刻同期が非同期に行われます。

  2. sntp_get_sync_mode()
     時刻同期のモード(2 種類)を返します。sntp_set_sync_mode() で変更できます。
    2.1. SNTP_SYNC_MODE_IMMED(デフォルト)
     装置本体に SNTP の時刻を即座に反映します。
    2.2. SNTP_SYNC_MODE_SMOOTH
     装置本体の時刻を SNTP の時刻に徐々に近づけます。35 分以上差がある場合には即座に反映します。

  3. sntp_get_sync_status()
     時刻同期の状態(3 種類)を返します。頻繁に実行することで、時刻同期直後のタイミングに近づくことができます。
    3.1. SNTP_SYNC_STATUS_RESET
     時刻同期していないか、または次の時刻同期処理を待機しています。
    3.2. SNTP_SYNC_STATUS_IN_PROGRESS
     時刻同期が SNTP_SYNC_MODE_SMOOTH モードで進行中です。
    3.3. SNTP_SYNC_STATUS_COMPLETED
     過去に時刻同期が完了しています。sntp_get_sync_status() を実行すると、状態は SNTP_SYNC_TIME_RESET に変わります。

  4. sntp_get_sync_interval()
     時刻同期を行う周期を返します、sntp_set_sync_interval() で変更できます、デフォルトで 1 時間(3600,000ms)毎の時刻同期が表示されます。

  5. sntp_set_time_sync_notification_cb()
     時刻同期処理の実行直後に呼び出されるコールバック関数を指定します。コールバック関数が呼び出された場合、SNTP_SYNC_STATUS_COMPLETED か、または SNTP_SYNC_STATUS_IN_PROGRESS が期待できます。

  6. SntpTimeSyncNotificationCallback()
     コールバック関数を独自に定義しました。呼び出された時に SNTP_SYNC_STATUS_COMPLETED であれば、現在時刻を RTC に反映します。時刻同期直後の時刻を RTC に反映できます。コールバック関数内での I2C(Wire)の使用には制限がある模様で、コールバック関数の外で処理する様にしました。

  7. getLocalTime()
     装置本体の時刻を読み出します。引数で参照される struct tm 型にローカル時刻(例えば JST)が代入されます。getLocalTime() は、時刻が 2016 年以降の場合 true を返します。戻り値は NTP 時刻同期完了の判定によく使われます。今回の場合、予め RTC の時刻を反映しているため、NTP に関係なく true が返ってしまいます。getLocalTime() の戻り値の検査は不要となります。

  • SNTP: Simple NTP(NTP のサブセット)
configtztime.cpp
#include <M5Atom.h>
#include <sys/time.h>  // for struct timeval
#include <sntp.h>    // for sntp_sync_status 

// for SNTP and RTC
const char* time_zone  = "JST-9";
const char* ntp_server = "pool.ntp.org";

// Callback: set NTP sync-time into RTC
void SntpTimeSyncNotificationCallback(struct timeval *tv)
{
  sntp_sync_status_t sntp_sync_status = sntp_get_sync_status();
  if (sntp_sync_status == SNTP_SYNC_STATUS_COMPLETED)
    sntp_sync_status_complete = true;
}

void RtcxUpdate()
{
  if (sntp_sync_status_complete) {
    sntp_sync_status_complete = false;

    struct tm tm_sync;
    getLocalTime(&tm_sync);
    rtcx.WriteTime(&tm_sync);
  }
}

void setup()
{
  ...
  configTzTime(time_zone, ntp_server);
  Serial.printf("setup: SNTP sync mode = %d (0:IMMED 1:SMOOTH)\n", sntp_get_sync_mode());
  Serial.printf("setup: SNTP sync interval = %dms\n", sntp_get_sync_interval());

  sntp_set_time_sync_notification_cb(SntpTimeSyncNotificationCallback);
  ...
}

void loop()
{
  ...
  RtcxUpdate();
  ...
}

SNTP による時刻同期の様子

 10 分毎に getLocalTime() による時刻と RTC の時刻を表示させています。1 時間毎に SNTP からのコールバックが発生し、SNTP_SYNC_STATUS_COMPLETED が表示されていることがわかります。

5. おわりに

 M5Atom をベースに目覚まし時計を作っていますが、RTC と NTP をどうしたら良いのかわかりませんでした。たどり着いた考え方は、以下です。このうち 1. から 4. を実現できます。

  1. 電源オンで、RTC から時刻を読み出し設定する。RTC の時刻が無効の場合は、仮の時刻を設定する
  2. Wi-Fi 接続にトライする
  3. Wi-Fi 接続の成否にかかわらず、NTP を設定する
  4. NTP から時刻同期のコールバックがあれば、RTC の時刻を更新する
  5. Wi-Fi 接続が失敗している場合、マニュアル操作での再接続を可能とする
  6. マニュアルでの時刻設定を可能とする

 RTC も NTP も古典的な技術であり、知っている人には当たり前、あるいはもっと良い方法があるのでしょう。車輪の再発明ですが、車輪の設計図をなかなか見つけられない状況です。