学問の小部屋

ここは学問の黒板です。

オルゴール時報つき時計の製作

Arduinoの学習の一環で何か実用的なものを作れないかと考えたところ、自動演奏楽器による時報つき時計を考案した。時報つき時計はいわゆる目覚まし時計であり、スマートフォンのアラームなどでも対応可能である。今回は、オルゴールをステッピングモーターで回し、一周ごとを時報として使用することをコンセプトにした。オルゴールは連続回転を前提としたゼンマイ駆動の楽器であり、本来は一周ごとに止める方法は想定されておらず、一曲のはじめと終わりを正確に毎回鳴らすには、角度指定による回転が必要となる。(PWM音再生による電子オルゴールならば実現は容易であるが、モーターを使って実物のオルゴールを回す自動演奏楽器を作るところがポイントである。)
部品構成は以下の通りである。それぞれ注意点があるので、各項目で記述する。

eBayで購入した。互換品につきUSBシリアル変換チップがCH341SERという廉価品に変更されており、ドライバインストールが必要である。開発環境はwindows10であったが、旧来のドライバは問題なく動作した。

  • Tiny RTC DS1307基板

eBayで購入した。I2C接続でリアルタイムクロックを取得できる基板であるが、本基板は電子回路設計が間違っており、そのままではバッテリーバックアップがまともに機能しない。
"Tiny RTC I2C Module" issue
で議論されているように、回路構成が間違っていてVBATから十分な電位が供給されないので、VCCを外した途端にRTCの電源が落ちてしまい、バックアップが切れて時刻がリセットされる。本基板については、web上に様々な使用記事が掲載されており、例えばLIR2032用Tiny RTC I2C モジュールをCR2032用へのカスタマイズ方法 | 気分はメイカーズなど、充電可能なLIR2032ではなくCR2032を使用するという記事が散見される。どういうわけか、web上の本基板についての日本語記事を探してもバッテリーバックアップが動作しないといった指摘がなかった。世間的には、基板を接続して動いた動いた!でそれ以上テストするのをやめてしまうのかもしれない。本質的には、バッテリーで動作するようにすること自体が大切であり、どの電池を使うかは二の次である。クォーツ駆動のリアルタイムクロックは電気で時刻を保持するものなので、AC通電していないときに時刻を失ってしまっては、時計として実用に耐えない。故に、本回路で充電が不可能であれば、VCCで駆動するのではなく、常にバッテリー駆動するような設計にするのが適切な使用法である。
まずVBATとバッテリーを絶縁し、さらにI2C通信端子のプルアップ電位がチップの供給電位と合うように、VCCとバッテリーの+端子側を直結する。このようにしないと、電源電位は3.3Vなのにプルアップ電位は5Vなどと、バイアス電流が流れすぎる意図しない構成になる。具体的には、D1、R4、R6の3つの部品を取り外し、R5の基板内側とD1の基板内側のランドをショートする。

  • 双方向ロジックレベルコンバータ

千石電商で購入した。RTC基板がCR2032の3.3V駆動でArduinoのディジタルピンは5V駆動なので、正規動作させるには、レベルコンバータを挟む必要がある。例えば5V Arduino+3.3V I2Cデバイス+双方向ロジックレベルコンバータを試す - Qiitaに使用例がある。実験ではレベルコンバータなしでも特に問題なく動作したが、意図しない高圧がRTCチップのポートにかかることで壊れるリスクがあるので、レベルコンバータ基板を挿入した。

Amazon JPで購入した。ステッピングモーターの入門品として有名な製品で、Arduinoのディジタルピンに直接ドライバ基板(という名前のトランジスタアレイ基板)を接続して使用する。例えば"ステッピングモーター (28BYJ-48)"に作例がある。本モーターは電源の配線とディジタルピンを4つ消費する必要があり、トルクも弱いので、本格的なモーター制御には向かないが、小型オルゴールを回す程度であれば十分に実用になる。
専用のステッピングモータードライバが用意されていないので、自分で制御ルーチンを作る必要がある。そういった状況を踏まえ、制御にはCheapStepper.hのライブラリを使用した。このライブラリにはモーター動作を止めたときの終了処理が入っておらず、ディジタルピンのどれかに電圧がかかりっぱなしになるので、setStop関数として全停止状態を定義し、モーター動作が不要なときは全制御ピンにlowを出力するようにした。

  • 8桁7セグLED

Amazonで購入した。7セグLEDは定番の時計表示デバイスである。RTCから得られる時刻情報に対して、西暦年、月、日、曜日、時、分、秒をすべて表示するのに、8桁のLED基板2つで必要十分な表示領域が確保できた。購入品は月表示の2桁目に不良があり、右下のバーが点灯しないようであったが、さして重要な表示部ではないのでそのまま使用している。7セグLEDを使用する作例はArduino+MAX7219で8桁7セグLEDを簡単に扱う - Qiitaなどにある。
LEDドライバMAX7219を使用すると、基板を8枚までカスケード接続できるようになる。カスケード接続の番号は、ドライバのインスタンスを定義するところでデバイスIDを指定する。ID番号が大きくなるほど、遠い方のデバイスが指定される。(なぜかこの情報も、なかなか見つからなった)

【MM801-FD】ウエストミンスターの鐘 ♪試聴無料 18弁ムーブメント オルゴールギャラリーから購入した。まずゼンマイを取り外したところ、モーターをオルゴールの回転軸に直結することは難しかったので、ゼンマイのシャフトにモーターを接続し、ピニオンギヤを介して回す方法を選択した。シャフトとの接続ははじめ強力接着剤を使用したが保持力が足りなかったので、圧着端子を挿入してかしめることで、機械的に接続した。オルゴールの動作原理上、ドラムの突起に響板が引っ掛かるときに強力な制動がかかり回転速度が遅くなるので、響板が引っ掛かる深さがなるべく浅くなるように調整が必要であった。調整を終えても今回使用したモーターではトルクが不足気味ではあったので、もう少し強いモーターを使用する方が滑らかな演奏が可能となりそうである。

  • 時刻リセット用トグルスイッチ

ArduinoからRTCに時刻を書き込むときは、windowsの時計情報をビルド時にハードコードし、ブート時にその値を書き込むことになる。ブート時に毎回時刻をリセットしては意味がないので、トグルスイッチのオンオフをディジタルピンで検出し、オフ時のみ時刻の書き込みが動作するようにした。

以上の構成によりハードウェアを構築し、以下のプログラムをArduinoIDEで作成した。本プログラムではroundconditionのプリプロセッサで1分に1回オルゴールが鳴るようにしてあるが、鳴らす時刻を指定することで特定時刻のみでの動作が可能となる。また、Arduinoはマルチスレッド動作ができないので、ifを分けることで割込み動作させ、時刻表示とモーター動作を交互に動作させている。このような使用法は、本来は推奨されない。モーター動作中に割込みが入ることになり、意図通りの角度の回転が実現できなくなるので、回転時間、スピード、角度指定の3つのパラメータからちょうどオルゴール一周分になるように調整した。以下の例ではシリアルモニタにも時刻を出力しているが、シリアル出力をやめるだけでもバランスが変わり、再調整が必要となる。Arduinoのディジタルピンを直接モータードライバとして使用していることが原因なので、別の専用モータードライバ基板があるモーターを用意するか、Arduinoを2つ使用して片方をモーター専用にするかのいずれかが理想的な実装方法である。今回はコストとの兼ね合いもあり、割込み動作で実装した。完成品の動作は
Arduinoによるオルゴール時報つきディジタル時計 - YouTube
で視聴可能である。

#include
#include "RTClib.h"
#include "CheapStepper.h"
#include "LedControl.h"

RTC_DS1307 RTC;
//RTCデータ初期値
#define rtcresetPin 5
unsigned int yearvalue = 2000;
unsigned int monthvalue = 0;
unsigned int dayofweek = 100;
unsigned int dayvalue = 100;
unsigned int hourvalue = 100;
unsigned int minvalue = 100;
unsigned int secondvalue = 100;
#define time_adj 15//秒の調整

// モーター設定
#define motorPin1 9 // Blue - 28BYJ48 pin 1
#define motorPin2 10 // Pink - 28BYJ48 pin 2
#define motorPin3 11 // Yellow - 28BYJ48 pin 3
#define motorPin4 12 // Orange - 28BYJ48 pin 4
// 回転設定
#define roundspeed 22
#define rounddegree 13
#define roundtime 13
// 回転条件設定
#define effectivedayofweek 8// < 6で平日のみ
#define roundcondition (timenow.second() % 60) < roundtime
CheapStepper stepper(motorPin1, motorPin2, motorPin3, motorPin4);

// LED設定
#define LedDataPin 6
#define LedClockPin 8
#define LedLoadPin 7
#define cascades 2 //1 to 8
#define intensity 8 //0 to 8
#define disp1 1
#define disp2 0
LedControl lc = LedControl(LedDataPin, LedClockPin, LedLoadPin, cascades);

void setup () {
//declare the motor pins as outputs
pinMode(rtcresetPin, INPUT_PULLUP);
pinMode(motorPin1, OUTPUT);
pinMode(motorPin2, OUTPUT);
pinMode(motorPin3, OUTPUT);
pinMode(motorPin4, OUTPUT);
stepper.setRpm(roundspeed);

Serial.begin(9600);
Wire.begin();
Serial.println("Arduino Start!");

delay(300);
RTC.begin();
delay(300);
Serial.println("RTC begin");
if (! RTC.isrunning()) {//RTCが起動していないとき
Serial.println("RTC is NOT running!");
if (digitalRead(rtcresetPin)) {
Serial.println(" RTC is adjusted");
// following line sets the RTC to the date & time this sketch was compiled
RTC.adjust(DateTime(__DATE__, __TIME__));
DateTime nowtmp = RTC.now();
RTC.adjust(DateTime(nowtmp.unixtime() + time_adj));//ブート時間分の時刻調整
}
}
else {
Serial.println("RTC is running!");
if (digitalRead(rtcresetPin)) {
Serial.print("pin ");
Serial.print(rtcresetPin);
Serial.println(" is low. RTC is adjusted");

RTC.adjust(DateTime(__DATE__, __TIME__));
DateTime nowtmp = RTC.now();
RTC.adjust(DateTime(nowtmp.unixtime() + time_adj));//ブート時間分の時刻調整
}
else {
Serial.println("RTC is NOT adjusted");
}
}
Serial.println("RTC setup end");
/*---------------- LED setup --------------------*/
/*
The MAX72XX is in power-saving mode on startup,
we have to do a wakeup call
*/
// LED初期化
for (int index = 0; index < lc.getDeviceCount(); index++) {
lc.shutdown(index, false);
}
/* Set the brightness to a medium values */
lc.setIntensity(0, intensity);
lc.setIntensity(1, intensity);
/* and clear the display */
lc.clearDisplay(disp1);
lc.clearDisplay(disp2);
lc.setChar(disp1, 6, '-', false);//常に-を表示する
}
void loop () {
DateTime timenow = RTC.now();
LEDshowtime(timenow);
serialshowtime(timenow);
stepper.run();
if (timenow.dayOfTheWeek() < effectivedayofweek) { //曜日出力が平日のとき=6(土曜)未満
if (roundcondition) {//時刻0秒をトリガにオルゴール回転
stepper.newMoveDegreesCCW(rounddegree);
}
else {
setStop();//全停止
}
}
}
//////////////////////////////////////////////////////////////////////////////////
void setStop()//モータードライブピン全停止
{
digitalWrite(motorPin1, 0);
digitalWrite(motorPin2, 0);
digitalWrite(motorPin3, 0);
digitalWrite(motorPin4, 0);
}
void LEDshowtime(DateTime timenow)
{
if (secondvalue != timenow.second() ) { //秒が変わったときだけ描画更新する
unsigned int seconds[2];
secondvalue = timenow.second();
seconds[0] = secondvalue % 10; secondvalue /= 10;//1桁目
seconds[1] = secondvalue % 10; secondvalue /= 10;//2桁目
lc.setDigit(disp1, 1, seconds[1], false);
lc.setDigit(disp1, 0, seconds[0], false);
}
if (minvalue != timenow.minute() ) { //分が変わったときだけ描画更新する
unsigned int mins[2];
minvalue = timenow.minute();
mins[0] = minvalue % 10; minvalue /= 10;//1桁目
mins[1] = minvalue % 10; minvalue /= 10;//2桁目
lc.setDigit(disp1, 3, mins[1], false);
lc.setDigit(disp1, 2, mins[0], true);
}
if (minvalue != timenow.hour() ) { //時間が変わったときだけ描画更新する
unsigned int hours[2];
hourvalue = timenow.hour();
hours[0] = hourvalue % 10; hourvalue /= 10;//1桁目
hours[1] = hourvalue % 10; hourvalue /= 10;//2桁目
lc.setDigit(disp1, 5, hours[1], false);
lc.setDigit(disp1, 4, hours[0], true);
}
if (dayvalue != timenow.day() ) { //日が変わったときだけ描画更新する
unsigned int days[2];
dayvalue = timenow.day();
days[0] = dayvalue % 10; dayvalue /= 10;//1桁目
days[1] = dayvalue % 10; dayvalue /= 10;//2桁目
lc.setDigit(disp2, 1, days[1], false);
lc.setDigit(disp2, 0, days[0], false);
}
if (dayofweek != timenow.dayOfTheWeek() ) { //曜日が変わったときだけ描画更新する
dayofweek = timenow.dayOfTheWeek();
lc.setDigit(disp1, 7, timenow.dayOfTheWeek(), false);
}
if (monthvalue != timenow.month() ) { //月が変わったときだけ描画更新する
unsigned int months[2];
monthvalue = timenow.month();
months[0] = monthvalue % 10; monthvalue /= 10;//1桁目
months[1] = monthvalue % 10; monthvalue /= 10;//2桁目
lc.setDigit(disp2, 3, months[1], false);
lc.setDigit(disp2, 2, months[0], true);
}

if (monthvalue != timenow.month() ) { //年が変わったときだけ描画更新する
yearvalue = timenow.year();
unsigned int years[4];
years[0] = yearvalue % 10; yearvalue /= 10;//1桁目
years[1] = yearvalue % 10; yearvalue /= 10;//2桁目
years[2] = yearvalue % 10; yearvalue /= 10;//3桁目
years[3] = yearvalue % 10;//4桁目
lc.setDigit(disp2, 7, years[3], false);
lc.setDigit(disp2, 6, years[2], false);
lc.setDigit(disp2, 5, years[1], false);
lc.setDigit(disp2, 4, years[0], true);
}
}
void serialshowtime(DateTime timenow) {
if (secondvalue != timenow.second() ) { //秒が変わったときだけ描画更新する
secondvalue = timenow.second();
Serial.print(timenow.year(), DEC);
Serial.print('/');
Serial.print(timenow.month(), DEC);
Serial.print('/');
Serial.print(timenow.day(), DEC);
Serial.print(' ');
Serial.print(timenow.hour(), DEC);
Serial.print(':');
Serial.print(timenow.minute(), DEC);
Serial.print(':');
Serial.print(timenow.second(), DEC);
// Serial.println(); //空白
Serial.println(""); // 改行
// Serial.println("-----"); //文字列
// delay(1000);//wait 1s
}
}