M5Stack の Proto Module に収まる Real Time Clock を制作する

M5Stack は多機能なマイコンモジュールである。スピーカーとか microSD スロットとかがはじめからついているが、リアルタイムクロック (RTC) がついておらず、時計として使うには 毎回 Wi-Fi 経由で NTP を用いて時刻情報を取得する とか、iPhone などと BLE で接続し Current Time Service を用いて時刻情報を取得する とかのテクニックが必要である。これでは不便なので Proto モジュール 上に DS1302 という IC を使用して RTC を構築した。

ポイントは数点ある。

  1. GPIO のうちどのポートを使うか。
  2. ボタン電池をどうやって Proto モジュールに収めるか。

1つ目については M5 Stack ハマりどころ に詳しい。要は:

  • 使うべきではないポート
    • GPIO 25, Audioと共有
    • GPIO 23, LCDと共有
    • GPIO 19, SDと共有
    • GPIO 18, LCDと共有
    • GPIO 3, TXDと共有
    • GPIO 1, RXDと共有
    • GPIO 0, PULLUP BOOTと共有
  • 注意して使うポート
    • GPIO 34-39, 入力オンリー
    • GPIO 2, [GPIO 0]が0の時、これも0でリセットされるとダウンロードモードになる。
    • GPIO 12, 起動時に1だと LDOが1.8V, 0だと3.3Vになる。
    • GPIO 15 , GPIO 5 標準でPULLUP 起動時に設定すると SDIOスレーブタイミングの設定。

ひとまず今回は SCLK = 13, IO = 5, CE = 17 とすることに決めた。

2つ目についてはコイン電池ホルダーをいくつか試し、タカチ電機工業 表面実装型 コイン電池ホルダー SMTUシリーズ の CR2032 用 ならば入ったのでそれを利用した。 Proto モジュール内部で許容される高さはだいたい 5.5 mm ぐらいっぽい(上のホルダーは 5.4 mm)。

下に配線図と実際の写真を載せる。めちゃめちゃ汚い配線だが許容した。

f:id:KainokiKaede:20200828150239p:plain
M5Stack Proto Module RTC 配線図

f:id:KainokiKaede:20200827230116j:plain
M5Stack Proto Module RTC 写真

いろいろなところからコードをお借りしてガッチャンしてとりあえず動くコードを書いた。

#include <M5Stack.h>
#include <DS1302.h>
// DS1302 用のモジュールを https://github.com/msparks/arduino-ds1302 からダウンロードして使用可能にしておく。

#define incPin 39                   // (+) Inc. button
#define decPin 38                   // (−) Dec. button
#define entPin 37                   // Enter button


// Timer function: https://lang-ship.com/blog/work/esp32-timer/
hw_timer_t * timer = NULL;
void IRAM_ATTR onTimer() {
  // M5 LCD の左上のカーソル位置は (0, 0) で、右下のカーソル位置は (312, 232) である。
  // 1文字の大きさが (6,8) であることと液晶サイズが (320,240) であることとに依拠していると思われる。
  // これをはみ出していたら clear する。
  if (M5.Lcd.getCursorX() >= 320 || M5.Lcd.getCursorY() >= 240) {
    M5.Lcd.clear();
    M5.Lcd.setCursor(0,0);  // https://lang-ship.com/reference/unofficial/M5StickC/Tips/M5Display/
  }
}



// DS1302
// https://github.com/msparks/arduino-ds1302/blob/master/examples/set_clock/set_clock.ino を改変。

namespace {

// Set the appropriate digital I/O pin connections. These are the pin
// assignments for the Arduino as well for as the DS1302 chip. See the DS1302
// datasheet:
//
//   http://datasheets.maximintegrated.com/en/ds/DS1302.pdf
const int kCePin   = 17;  // Chip Enable
const int kIoPin   = 5;  // Input/Output
const int kSclkPin = 13;  // Serial Clock

// Create a DS1302 object.
DS1302 rtc(kCePin, kIoPin, kSclkPin);

String dayAsString(const Time::Day day) {
  switch (day) {
    case Time::kSunday: return "Sunday";
    case Time::kMonday: return "Monday";
    case Time::kTuesday: return "Tuesday";
    case Time::kWednesday: return "Wednesday";
    case Time::kThursday: return "Thursday";
    case Time::kFriday: return "Friday";
    case Time::kSaturday: return "Saturday";
  }
  return "(unknown day)";
}

void printTime() {
  // Get the current time and date from the chip.
  Time t = rtc.time();

  // Name the day of the week.
  const String day = dayAsString(t.day);

  // Format the time and date and insert into the temporary buffer.
  char buf[50];
  snprintf(buf, sizeof(buf), "%s %04d-%02d-%02d %02d:%02d:%02d",
           day.c_str(),
           t.yr, t.mon, t.date,
           t.hr, t.min, t.sec);

  // Print the formatted string to serial so we can see the time.
  M5.Lcd.println(buf);
}

}  // namespace



//曜日を求める。 https://edu.clipper.co.jp/pg-2-47.html
// 0 = 日曜日
int subZeller( int y, int m, int d )
{
    if( m < 3 ) {
        y--; m += 12;
    }
    return ( y + y/4 - y/100 + y/400 + ( 13*m + 8 )/5 + d )%7;
}

Time::Day subZellerForDS1302Library( int y, int m, int d)
{
    switch (subZeller(y, m, d)) {
      case 0:
        return Time::kSunday;
      case 1:
        return Time::kMonday;
      case 2:
        return Time::kTuesday;
      case 3:
        return Time::kWednesday;
      case 4:
        return Time::kThursday;
      case 5:
        return Time::kFriday;
      case 6:
        return Time::kSaturday;
    }
}



// 時計の設定。 http://radiopench.blog96.fc2.com/blog-entry-923.html を改変。

char buff[10];                     // 文字列操作バッファ
String yymmdd = "yyyy/mm/dd";      // 年月日文字列
String hhmmss = "hh:mm/ss";        // 時分秒文字列
int yy, mo, dd, hh, mi, ss;        // 時刻の要素

void getDateTime(){                // RTCに値を読む、日時の文字列を作成する
  int x;
  yymmdd = "";
  hhmmss = "";
  Time t = rtc.time();

  x =  t.yr%100;           // 年
  yy = x;
  sprintf(buff, "20%02d", x);      // 20に続いて右詰め2桁、1桁なら先頭にゼロ
  yymmdd += buff;

  x = t.mon;            // 月
  mo = x;
  sprintf(buff, "/%02d", x);       // / に続けて右詰め2桁
  yymmdd += buff;
  x = t.date;            // 日
  dd = x;
  sprintf(buff, "/%02d", x);       // / に続けて右詰め2桁
  yymmdd += buff;                  // 年月日の文字列完成(ex:2019/02/28)

  x =  t.hr;           // 時
  hh = x;
  sprintf(buff, "%02d", x);        // 右詰め2桁
  hhmmss += buff;
  x = t.min;            // 分
  mi = x;
  sprintf(buff, ":%02d", x);       // : に続けて右詰め2桁、
  hhmmss += buff;
  x = t.sec;            // 秒
  sprintf(buff, ":%02d", x);       // :に続けて右詰め2桁
  hhmmss += buff;                  // 時分秒の文字列完成(ex:01:02:03)
}

void oledDisp2Chr(int x, int y, int val) {  // OLEDの指定場所に2桁の値を表示
  sprintf(buff, "%02d", val);               // データーを10進2桁0フィル文字列に変換
  M5.Lcd.setCursor(x, y);                     // カーソルを指定位置に合わせて
  M5.Lcd.print(buff);                         // 数値を書き込み
}

int oledRW(int x, int y, int d, int stepD, int minD, int maxD) { // OLEから値を入力
  // OLEDの指定位置に2桁右詰めで変数の値を表示。ボタン操作で値を増減し、
  // Ent入力で値を確定し戻り値として返す。表示位置の左上をx, y 座標で指定
  // 操作位置は下線で表示。値は上下限の範囲でサーキュレート。文字サイズは2倍角(12x16画素)
  // 引数:x座標、y座標、変更したい変数、変更ステップ量、下限値、上限値

  oledDisp2Chr(x, y, d);                    // 画面の指定位置に数値を2桁表示(下線付き)
  while (digitalRead(entPin) == LOW) {      // enterボタンが押されていたら離されるまで待つ
  }
  delay(30);
  while (digitalRead(entPin) == HIGH) {     // enterボタンが押されるまで以下を実行

    if (digitalRead(incPin) == 0) {         // + ボタンが押されていたら
      d = d + stepD;                        // x を指定ステップ増加
      if (d > maxD) {                       // 上限超えたら下限へサキュレート
        d = minD;
      }
      oledDisp2Chr(x, y, d);                // 画面の指定位置に数値を2桁表示(下線付き)
      while (digitalRead(incPin) == 0) {    // + ボタンが離されるまで待つ
      }
      delay(30);
    }

    if (digitalRead(decPin) == 0) {         // - ボタンが押されていたら
      d = d - stepD;                        // x を指定ステップ減らす
      if (d < minD) {                       // 下限以下なら上限へサーキュレート
        d = maxD;
      }
      oledDisp2Chr(x, y, d);                // 画面の指定位置に数値を2桁表示(下線付き)
      while (digitalRead(decPin) == 0) {    // - ボタンが離されるまで待つ
      }
      delay(30);
    }
  }
  delay(30);
  return d;                                 // 戻り値
}

void clockAdjust() {                        // OLEDとボタンスイッチで時刻を合わせる
  M5.Lcd.println("Clock adj.");               // 時刻合わせ開始表示

  while (digitalRead(entPin) == LOW) {      // entボタンが離されるまで待つ
  }
  getDateTime();                            // 現在時刻を取得

  M5.Lcd.clear();                           // 画面を消して
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.println(yymmdd);                     // 現在の年月日を表示

  hhmmss[6] = '-';                          // 秒の桁に--を表示
  hhmmss[7] = '-';
  M5.Lcd.setCursor(13, 16);                   //  (表示位置要調整)
  M5.Lcd.println(hhmmss);                     // 時刻表示

  // x, y座標, 値, ステップ, 下限, 上限を指定して時計の設定値を入力
  yy = oledRW(12,  0, yy, 1, 0, 40);        // 年の値を入力
  mo = oledRW(30,  0, mo, 1, 1, 12);        // 月の入力
  dd = oledRW(48,  0, dd, 1, 1, 31);        // 日の入力。存在しない日(ex:2/31)も入力可能だが動作は不定
  hh = oledRW(13, 16, hh, 1, 0, 23);        // 時 (表示位置要調整)
  mi = oledRW(31, 16, mi, 1, 0, 59);        // 分 (表示位置要調整)

  // Initialize a new chip by turning off write protection and clearing the
  // clock halt flag. These methods needn't always be called. See the DS1302
  // datasheet for details.
  rtc.writeProtect(false);
  rtc.halt(false);

  // Make a new time object to set the date and time.
  // Sunday, September 22, 2013 at 01:38:50.
  Time t(2000+yy, int(mo), int(dd), int(hh), int(mi), 0, subZellerForDS1302Library(2000+yy, mo, dd));

  // Set the time and date on the chip.
  rtc.time(t);
}







void setup() {
  // put your setup code here, to run once:
  M5.begin();

  // Mute Speaker Noise: https://asukiaaa.blogspot.com/2020/03/m5stack-disable-speaker.html
  M5.Speaker.begin();
  M5.Speaker.mute();

  // Set ESP32 Timer: https://lang-ship.com/blog/work/esp32-timer/
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);  // us
  timerAlarmEnable(timer);



  if (digitalRead(entPin) == LOW){ // 起動時にEnt.ボタンが押されていたら
    clockAdjust();                 // OLED画面と押しボタンを使って時刻合わせ。
  }
  
}

void loop() {
  // put your main code here, to run repeatedly:
  printTime();
  delay(1000);
}

ふつうに起動するとずっと現在時刻を表示しつづける。ボタンC(一番右のボタン)を押しながら電源を入れると時計設定モードに入ることができる。

Misc

Proto モジュール、GND を引き出すことが想定されてないとしか思えない配置でつらい。

はじめは PCF8523 という IC を使用しようと思ったが、この IC の i2c address 0x68 と M5Stack Gray に標準搭載されている MPU9250 の i2c address 0x68 がぶつかってしまい使用不可であった。

ちなみに M5Stick-C ならば RTC がビルトインされているようだ。M5Stack にも載せてくれればよかったのに。

2021-08-16 追記

M5Stack 用 RTC モジュール基盤が Switch Science で販売されている。IC は PCF8563 で、I2C アドレスは 0x51 のようであり MPU9250 とのアドレス衝突もなさそう。