はじめに
ニキシー管時計づくりにチャレンジしてみました。
前回、4 つのニキシー管で数字の表示を行いました。
あとはその数字を現在時刻にできれば、時計システムの完成です。
他の記事はこちらから
- part 0:事前学習&準備編
- part 1:昇圧チョッパ編
- part 2:ニキシー管点灯編
- part 3:NTPとRTCで時間管理編
- part 4:プログラム完成編
- part 5:comming soon...(?)
そこで今回は、NTP による時間の取得と、RTC への書き込み&読み出しをテストしてみたいと思います。
NTP による時刻の取得
NTP とは
Network Time Protocol という名前がそのままです。ネットワークを通じ、現在時刻を取得します。
時刻の取得
ESP32 で時間を取得してみます。次のようなプログラムを書き込みました。ファイル数は 2 つです
( GitHub のリンク)
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include <time.h>
class NTP {
private:
const char* SSID;
const char* PASS;
const char* NTP_SERVER_1 = "ntp.nict.jp";
const char* NTP_SERVER_2 = "time.google.com";
const char* NTP_SERVER_3 = "ntp.jst.mfeed.ad.jp";
const uint32_t GMT_OFFSET = 3600*9;
const uint8_t DATLGIHT_OFFSET = 0;
const uint8_t CONN_TRY_TIMES = 10;
const uint8_t CONN_TRY_INTVL = 500;
bool is_configured = false;
public:
NTP(const char* _SSID, const char* _PASS) : SSID(_SSID), PASS(_PASS) {};
void setup() {
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
/* 接続試行 */
for(uint8_t i = 0; i < CONN_TRY_TIMES; ++i) {
if(WiFi.status() == WL_CONNECTED) {
/* 時刻を設定 */
configTime(GMT_OFFSET, DATLGIHT_OFFSET, NTP_SERVER_1, NTP_SERVER_2, NTP_SERVER_3);
is_configured = true;
return;
}
delay(CONN_TRY_INTVL);
}
}
void getTime(struct tm *tm) {
/* 秒数が更新された瞬間の時刻を格納 */
getLocalTime(tm);
uint8_t previous_sec = tm->tm_sec;
do {
getLocalTime(tm);
}while(tm->tm_sec == previous_sec); // 秒が変わるまで待つ
}
void disconnect() const {
WiFi.disconnect();
}
bool getIsConfigured() const {return is_configured;}
};
#include "NTP.h"
const char* SSID = "YOUR_SSID";
const char* PASS = "YOUR_PASSWORD";
NTP ntp(SSID, PASS); // NTPクラスのインスタンスを生成
struct tm tm; // 時刻保存用の構造体
void setup() {
Serial.begin(115200);
ntp.setup();
if(ntp.getIsConfigured()) { // Wi-Fi接続成功時
ntp.getTime(&tm);
Serial.println(&tm, "%A, %B %d %Y %H:%M:%S");
ntp.disconnect();
}
else { // 失敗時
Serial.println("Could not connect to network.");
}
}
void loop() {
/* none */
}
基本となるソースコードは、IDE のサンプルコード「SimpleTime」(「ファイル」→「スケッチ例」→「ESP32」→「Time」→「SimpleTime」)です。関数や定数の詳しい説明はこちらの記事が大変わかりやすいです。
このプログラムを基にクラス化した NTP クラスが、NTP.h に定義されています。
プログラムの流れは、まず ntp.setup() によって Wi-Fi への接続に 10 回チャレンジします。
成功すれば NTP サーバと通信して ESP32 の内部時刻を設定し、現在時刻を ntp.getTime() によって取り出し表示する、という感じです。接続に失敗した場合はエラーメッセージを出力します。
すこし説明したいのは、時刻の取り出しを行う getTime() です。
void getTime(struct tm *tm) {
/* 秒数が更新された瞬間の時刻を格納 */
getLocalTime(tm);
uint8_t previous_sec = tm->tm_sec;
do {
getLocalTime(tm);
}while(tm->tm_sec == previous_sec); // 秒が変わるまで待つ
}
時刻の格納には構造体 tm を使います。しかし tm に格納できるのは秒までなので、getLocalTime() によって NTP と同期した ESP32 の内部時間を取得しても、タイミングによっては最大 0.999... 秒の誤差が生じます。
configTime() による時刻の設定はそこそこ精度が高い(少なくとも秒単位ではズレない)らしいので、可能な限り秒の値が切り替わった瞬間を tm に保存したいです。
そこで、一度 getLocalTime() で取得した時刻の秒数を保存しておき、その秒数と異なる値(つまり 1 増えた値)が手に入るまで、do-while 文による時刻の取得を繰り返します。これによって、可能な限り 1 秒進んだ直後に時間を表示しています。
次章で扱う RTC に時間を設定する際、なるべく正確な時間を使いたいと考えこのようなプログラムにしました。
実際に動かしてみます。
ネットワークに接続できた場合は次のように、現在時刻がシリアルモニタに表示されます。
( NICT の日本標準時と並べています)
また失敗した場合はエラーメッセージが表示されます。
時刻の取得は思ったより簡単でした!
RTC による時刻の管理
RTC とは
Real Time Clock と呼ばれる時計のようなものです。内部に発振回路を持ち、正確に時刻を進めます。
ESP32 も内部に RTC を持ちますが、精度があまり良くないようなので外部に取り付けます。
リンク(秋月): RX8900
RX8900 は資料が充実しています。DIP 化モジュールの秋月のページから取扱説明書が、小型パッケージ版の秋月のページからアプリケーションマニュアルやサンプルスケッチがダウンロードできます。
加えて、こちらのページのプログラムを参考にさせていただきました。
時刻の保存と取り出し
さて、時刻の保存と取り出しをやってみます。
まずは回路図を考えます。アプリケーションマニュアルのピンアサインを参考に、次のような回路を組みました。
2023/05/29 追記
RX8900のVBATに電流制限抵抗が付いてません!適宜接続してください。
データの書き込み、読み出しには SDA ピンと SCL ピンを使います( I2C 通信)。内部で時間が進んだときは /INT ピンが変化するようです(割り込み用)。あとは FOUT から基準クロック信号を出力させる場合 FOE を HIGH にするようですが、使わないので LOW にしています。
また、RX8900 はボタン電池などを接続する用のピン VBAT が儲けられています。多くの RTC は電源の切り替え回路を外に取り付けると思いますが、RX8900 は自動で切り替えてくれるようです。ただし電流の逆流を防ぐためにダイオードを取り付けています。
使用するボタン電池は CR2032 です。ケースと一緒に秋月で購入しました。
簡単なのでサクッと回路を作ります。
次のプログラムを書き込みます( 3 つのファイルに分かれています)。
( GitHub のリンク)
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <time.h>
/* ピン情報をまとめる構造体 */
struct RTCPinInfo {
uint8_t scl;
uint8_t sda;
uint8_t n_int;
};
class RTC {
private:
const RTCPinInfo PIN;
constexpr static uint8_t RTC_ADRS = 0x32; // RX8900のアドレス
constexpr static uint8_t DATE_TIME_REG = 0x00; // 時間が格納されている領域の先頭アドレス
constexpr static uint8_t EXTENSION_REG = 0x0D; //-------------------------------//
constexpr static uint8_t FLAG_REG = 0x0E; // 各種設定用の3つのレジスタのアドレス //
constexpr static uint8_t CONTROL_REG = 0x0F; //-------------------------------//
public:
RTC(RTCPinInfo rtcPinInfo);
void setup();
void setISR(void (*isr)()) const; // 時刻更新割り込み時に実行する関数の設定
void setDateTime(struct tm *tm); // 時間の保存
void getDateTime(struct tm *tm) const; // 時間の取得
private:
void setRegs(uint8_t addr, uint8_t sz, uint8_t *data) const; // レジスタに値を設定
void getRegs(uint8_t addr, uint8_t sz, uint8_t *data) const; // レジスタの値を取得
uint8_t dec2bcd(uint8_t dec) const; // 10進数→BCD
uint8_t bcd2dec(uint8_t bcd) const; // BCD→10進数
};
#include "RTC.h"
RTC::RTC(RTCPinInfo _PIN) : PIN(_PIN) {}
void RTC::setup() {
uint8_t extreg = 0x20; // 分が更新されるごとに割り込み
uint8_t flgreg = 0x00; // フラグのリセット
uint8_t conreg = 0x20; // 時間更新割り込み開始
uint8_t init_flgreg; // フラグレジスタの初期状態
struct tm init_time = {0, 0, 0, 1, 0, 100, 6, 0, 0}; // 初期時刻 2000/01/01/00:00:00(SUT)
delay(100);
pinMode(PIN.n_int, INPUT_PULLUP);
Wire.begin((int8_t)PIN.sda, (int8_t)PIN.scl);
/* バッテリーの状態確認 */
getRegs(FLAG_REG, 1, &init_flgreg); // フラグレジスタの値を取得
if (init_flgreg & 0x02) { // VLF(Voltage Low Flag)が1なら
setDateTime(&init_time); // 初期時刻を保存
}
else {/* 何もしない */}
/* 各レジスタに値を設定 */
setRegs(EXTENSION_REG, 1, &extreg);
setRegs(FLAG_REG, 1, &flgreg);
setRegs(CONTROL_REG, 1, &conreg);
}
void RTC::setISR(void (*isr)()) const {
attachInterrupt(digitalPinToInterrupt(PIN.n_int), isr, FALLING);
}
void RTC::setDateTime(struct tm *tm) {
uint8_t data[7];
data[0] = dec2bcd(tm->tm_sec % 60);
data[1] = dec2bcd(tm->tm_min);
data[2] = dec2bcd(tm->tm_hour);
data[3] = 1 << (tm->tm_wday);
data[4] = dec2bcd(tm->tm_mday);
data[5] = dec2bcd(tm->tm_mon + 1);
data[6] = dec2bcd(tm->tm_year - 100);
setRegs(DATE_TIME_REG, 7, data);
}
void RTC::getDateTime(struct tm *tm) const {
uint8_t data[7];
getRegs(DATE_TIME_REG, 7, data);
tm->tm_sec = bcd2dec(data[0] & 0x7f);
tm->tm_min = bcd2dec(data[1] & 0x7f);
tm->tm_hour = bcd2dec(data[2] & 0x3f);
tm->tm_wday = [&]{uint8_t wday = 0; while(data[3]>>=1){++wday;} return wday;}();
tm->tm_mday = bcd2dec(data[4] & 0x3f);
tm->tm_mon = bcd2dec(data[5] & 0x1f) - 1;
tm->tm_year = bcd2dec(data[6]) + 100;
}
void RTC::setRegs(uint8_t addr, uint8_t sz, uint8_t *data) const {
Wire.beginTransmission(RTC_ADRS);
Wire.write(addr);
Wire.write(data, sz);
Wire.endTransmission();
}
void RTC::getRegs(uint8_t addr, uint8_t sz, uint8_t *data) const {
Wire.beginTransmission(RTC_ADRS);
Wire.write(addr);
Wire.endTransmission();
Wire.requestFrom(RTC_ADRS, sz);
for (uint8_t i = 0; i < sz; i++) {
data[i] = Wire.read();
}
}
uint8_t RTC::dec2bcd(uint8_t dec) const {
return (((dec / 10) << 4) | (dec % 10));
}
uint8_t RTC::bcd2dec(uint8_t bcd) const {
return ((bcd >> 4) * 10 + (bcd & 0x0f));
}
#include "RTC.h"
const uint8_t PIN_SCL = 32;
const uint8_t PIN_SDA = 33;
const uint8_t PIN_N_INT = 27;
const RTCPinInfo rtcPinInfo {
PIN_SCL,
PIN_SDA,
PIN_N_INT
};
RTC rtc(rtcPinInfo); // RTCクラスのインスタンスを生成
bool tick_flag = false; // 時間更新割り込み発生フラグ
void setupTickFlag() {tick_flag = true;} // 時間更新割り込み時に実行する関数
struct tm tm; // 時刻保存用の構造体
void setup() {
rtc.setup();
rtc.setISR(setupTickFlag);
Serial.begin(115200);
}
void loop() {
if(tick_flag) {
rtc.getDateTime(&tm);
Serial.println(&tm, "%A, %B %d %Y %H:%M:%S");
tick_flag = false;
}
}
すこし長く見えますが、ほとんどが I2C 通信に関するものなので、あまり内容はありません。
順に解説していきます。
RTC.h と RTC.cpp
まずは、RTC.h と RTC.cpp を見ていきます。
このヘッダファイルと cpp ファイルは、RTC と I2C 通信を行い、時刻の読み書きを行います。
RTC.h の最初は必要なライブラリのインクルードと構造体の宣言です。
Wire.h は I2C 通信に必要です。また、time.h には構造体 tm が定義されており、時刻を格納するために使います。
#include <Arduino.h>
#include <Wire.h>
#include <time.h>
/* ピン情報をまとめる構造体 */
struct RTCPinInfo {
uint8_t scl;
uint8_t sda;
uint8_t n_int;
};
次に定義されているのが、RTC.h および RTC.cpp のメインとなる RTC クラスです。
class RTC {
private:
const RTCPinInfo PIN;
constexpr static uint8_t RTC_ADRS = 0x32; // RX8900のアドレス
constexpr static uint8_t DATE_TIME_REG = 0x00; // 時間が格納されている領域の先頭アドレス
constexpr static uint8_t EXTENSION_REG = 0x0D; //-------------------------------//
constexpr static uint8_t FLAG_REG = 0x0E; // 各種設定用の3つのレジスタのアドレス //
constexpr static uint8_t CONTROL_REG = 0x0F; //-------------------------------//
public:
RTC(RTCPinInfo rtcPinInfo);
void setup();
void setISR(void (*isr)()) const; // 時刻更新割り込み時に実行する関数の設定
void setDateTime(struct tm *tm); // 時間の保存
void getDateTime(struct tm *tm) const; // 時間の取得
private:
void setRegs(uint8_t addr, uint8_t sz, uint8_t *data) const; // レジスタに値を設定
void getRegs(uint8_t addr, uint8_t sz, uint8_t *data) const; // レジスタの値を取得
uint8_t dec2bcd(uint8_t dec) const; // 10進数→BCD
uint8_t bcd2dec(uint8_t bcd) const; // BCD→10進数
};
RTC クラスは、次のようなメンバ変数(定数)を持ちます。
-
PIN
通信に必要な 3 つのピンをまとめた構造体 RTCPinInfo のオブジェクト -
RTC_ADRS
I2C 通信時に必要となる RX8900 のアドレス。サンプルスケッチを参照 -
DATE_TIME_REG
時刻が格納されている領域の先頭アドレス(「秒」のアドレス) -
EXTENSION_REG 、FLAG_REG 、CONTROL_REG
アラームや割り込み設定などといった RTC の設定を行うためのレジスタのアドレス
次に関数を説明します。
最初に定義しているのはコンストラクタです。内容は簡潔で、RTCPinInfo 構造体を引数にとり、その値でメンバ変数 PIN を初期化しています。
RTC::RTC(RTCPinInfo _PIN) : PIN(_PIN) {}
次に初期設定を行うメンバ関数 setup() です。
void RTC::setup() {
uint8_t extreg = 0x20; // 分が更新されるごとに割り込み
uint8_t flgreg = 0x00; // フラグのリセット
uint8_t conreg = 0x21; // 時間更新割り込み開始
uint8_t init_flgreg; // フラグレジスタの初期状態
struct tm init_time = {0, 0, 0, 1, 0, 100, 6, 0, 0}; // 初期時刻 2000/01/01/00:00:00(SUT)
delay(100);
pinMode(PIN.n_int, INPUT_PULLUP);
Wire.begin((int8_t)PIN.sda, (int8_t)PIN.scl);
/* バッテリーの状態確認 */
getRegs(FLAG_REG, 1, &init_flgreg); // フラグレジスタの値を取得
if (init_flgreg & 0x02) { // VLF(Voltage Low Flag)が1なら
setDateTime(&init_time); // 初期時刻を保存
}
else {/* 何もしない */}
/* 各レジスタに値を設定 */
setRegs(EXTENSION_REG, 1, &extreg);
setRegs(FLAG_REG, 1, &flgreg);
setRegs(CONTROL_REG, 1, &conreg);
}
最初に定義している 3 つのレジスタにそれぞれ書き込む値( extreg 、flgreg 、conreg )は、アプリケーションマニュアル(秋月のページからダウンロード可能)とにらめっこしながら決めました。
マニュアルの 10 ページあたりの内容と見比べていただければ、各種の機能にどのような設定をしているかがわかると思います。
このプログラムで重要な設定は、
- 分が更新されるたびに割り込みを発生させる( USEL = 1 )
- 割り込みのみ作動させ、アラーム、タイマーは作動させない( UIE = 1 、AIE = TIE = 0 )
ぐらいです。
変数の定義の後、RTCPinInfo 構造体の PIN の値を使って設定を行っています。
PIN.n_int は RTC の /INT ピンに繋がっていて、割り込み発生時に値が変わります。プルアップ回路の代わりに内部プルアップを使い設定します。
また I2C 通信を始めるため、Wire.begin() 関数を呼び出します。I2C 通信に必要な 2 つのピン情報を、int8_t にキャストして与えます。これで I2C 通信が可能になります。
(キャストしているのは、begin() の引数が int 型だからです。このキャストをしないとまともに通信ができません。見事にハマって原因特定のために丸 1 日要しました.....)
その後、VLF というフラグレジスタ内の 1 つの値を確認しています。
VLF( Voltage Low Flag )が 1 だった場合、ボタン電池による電源供給ができていなかったことになります。
その場合は初期時刻( 2000/01/01/00:00:00(SUT) )を書き込むことにしています。
最後に、定義した各レジスタへの値を I2C 通信によって書き込み、終了です。
他に特筆すべきメンバ関数に、setISR() がありますが
void RTC::setISR(void (*isr)()) const {
attachInterrupt(digitalPinToInterrupt(PIN.n_int), isr, FALLING);
}
これは特定のピンの状態変化によって呼び出す関数を設定する、attachInterrupt() をラップしているだけです。
分が更新されたとき、RTC の /INT ピンが立ち下がります。その時に呼びたい関数のポインタを引数にとり、PIN.n_int と立ち下がりを表す FALLING を指定して attachInterrupt() で設定します。
なお ISR とは、割り込みルーチン( Interrupt Service Routine )の略です。割り込みの際呼び出される処理をこう呼ぶそうです。
残りのメンバ関数は、紹介したサイトやサンプルスケッチなどから I2C 通信を行う部分を抜き取り、部分的に手を加えたものなので、詳しい説明は省きます。
setDateTime() や getDateTime() は、構造体 tm のオブジェクトを引数にとり、その内容を RTC に書き込んだり、逆に RTC の内容を tm に書き込みます。
rtc_test について
定義した RTC クラスを使って 1 分ごとに画面に時刻を表示します。
まずはピン番号の定義と構造体を作成し、その構造体を用いて RTC クラスのインスタンスを生成します
const uint8_t PIN_SCL = 32;
const uint8_t PIN_SDA = 33;
const uint8_t PIN_N_INT = 27;
const RTCPinInfo rtcPinInfo {
PIN_SCL,
PIN_SDA,
PIN_N_INT
};
RTC rtc(rtcPinInfo); // RTCクラスのインスタンスを生成
また、割り込み検知用のフラグ tick_flag と、割り込み発生時に呼ばれ、tick_flag を立てる関数 setupTickFlag() を定義します。
bool tick_flag = false; // 時間更新割り込み発生フラグ
void setupTickFlag() {tick_flag = true;} // 時間更新割り込み時に実行する関数
setup() では、RTC クラスの setup() を呼び出し、setupTickFlag() を setISR() で割り込みがあった際に呼び出されるよう設定します。
void setup() {
rtc.setup();
rtc.setISR(setupTickFlag);
Serial.begin(115200);
}
loop() では、tick_flag を監視し、フラグが立った場合は RTC から時刻を取得してシリアルモニタに表示します。
void loop() {
if(tick_flag) {
rtc.getDateTime(&tm);
Serial.println(&tm, "%A, %B %d %Y %H:%M:%S");
tick_flag = false;
}
}
保存された時刻データが無い場合でテスト
まず、ボタン電池を接続していない、つまり RTC 内に正常なデータが残っていない状態でテストします。
VLF が 1 なので、初期時刻が書き込まれるはずです。
初期時刻が RTC 書き込まれ、1 分ごとに時刻が表示されます。正常に割り込みが起こっていることも確認できました。
次のテストのため、実行中にボタン電池を接続します。これで、RTC 内のデータが保存されるようになるはずです。
その後、ESP32 への電源供給を断ち、5 分間放置しました。
保存された時刻データが有る場合でテスト
再度 ESP32 を起動させ、RTC から時刻を読み込めるかを確認します。
ボタン電池によって内部でカウントが進められていることと、保存されたデータが読み出せることが確認できました!
NTP と RTC で時刻を管理する
NTP による時刻の取得と、RTC への保存を組み合わせてみます。
回路は RTC の回路と同じです。
以下のプログラムを書き込みます。NTP.h 、RTC.h と RTC.cpp は引き続き使います。
( GitHub のリンク)
#include "NTP.h"
#include "RTC.h"
#include <WiFi.h>
const char* SSID = "YOUR_SSID";
const char* PASS = "YOUR_PASSWORD";
NTP ntp(SSID, PASS); // NTPクラスのインスタンスを生成
const uint8_t PIN_SCL = 32;
const uint8_t PIN_SDA = 33;
const uint8_t PIN_N_INT = 27;
const RTCPinInfo rtcPinInfo {PIN_SCL, PIN_SDA, PIN_N_INT};
RTC rtc(rtcPinInfo); // RTCクラスのインスタンスを生成
bool tick_flag = false; // 時間更新割り込み発生フラグ
void setupTickFlag() {tick_flag = true;} // 時間更新割り込み時に実行する関数
struct tm tm; // 時刻保存用の構造体
void setup() {
Serial.begin(115200);
ntp.setup();
rtc.setup();
rtc.setISR(setupTickFlag);
if(ntp.getIsConfigured()) { // Wi-Fi接続成功時
ntp.getTime(&tm);
rtc.setDateTime(&tm); // RTCに時刻を保存
Serial.println(&tm, "%A, %B %d %Y %H:%M:%S");
ntp.disconnect();
}
else { // Wi-Fi接続失敗時
Serial.println("Could not connect to network.");
}
}
void loop() {
if(tick_flag) {
rtc.getDateTime(&tm);
Serial.println(&tm, "%A, %B %d %Y %H:%M:%S");
tick_flag = false;
}
}
ほとんどそのまま NTP のプログラムと RTC のプログラムを合体させています。
NTP で取得した時刻を RTC に保存しています。
テストしてみます。
まずは Wi-Fi に接続できる状況で動作させてみます。
NTP によって取得された時刻の表示と、時刻を保存した RTC からの割り込みによる表示を確認できました。
次に、接続している Wi-Fi をオフにして実行してみます。
ネットワーク接続エラーの表示がされていますが、RTC にはボタン電池によって時刻データが保持されていたため正常に動作しています。
最後に、RTC への電源供給を断ち、再度実行してみます。
この場合は初期時刻が書き込まれています。
NTP と RTC を組み合わせて、時計システムに必要な時刻の取得と表示ができました!!!
おわりに
情報に恵まれていたおかげで、NTP と RTC はどっちも想像以上に簡単でした。ありがたいですね。
次回は、今回の時刻の取得とニキシー管点灯のプログラムと合体させ、とうとうニキシー管に時刻を表示します!