この記事は M5Stack Advent Calendar 2020 の19日目の記事です。
以前、M5Stackをカメラの有線リモコン化する実験を行った。
今回、(ブレッドボードではなく)プロトモジュールを使った実装と、簡単なタイマーリモコンとして使うためのプログラムを組んだので紹介したい。
目次 [hide]
作りたいもの
よくあるタイマーリモコンはこんな感じ:
タイマーリモコンはカメラメーカーが純正品を出しているが、Amazonとかを探せば安い互換品がたくさん出てくる。筆者が持っているのもそういうものの一つだ。
タイマーリモコンとして必要な機能は
- ディレイ(セルフタイマー)
- シャッタースピード(押下時間)
- インターバル
- 回数
の4項目の調整と、それをスタート・ストップできることである。
これらの4項目と、実際にシャッターが押下されるタイミングの関係は次のようになる:

このほか、普通の有線リモコン(レリーズ)にある
- シャッターの押下
- シャッターの押しっぱなし(ロック、バルブ)
も欲しい。
M5StackにはLCDディスプレイが備わっているので、設定項目の表示は問題ない。一方、操作用のボタンは3つだけでは心許ない。そこで、独自にレバースイッチを実装することにした。
完成品
出来上がったハードウェアはこちら


M5Stackと積み重ねた様子は以下のようになる:

カメラとの接続には、別途2.5mmステレオ端子のついたケーブルを使う。カメラ側も2.5mmステレオ端子であれば
のような汎用品が使えるし、そうでなければAmazonでそれっぽいケーブルを入手できる。以下の写真の右上が2.5mmステレオケーブルで、左下は、オリンパスのRM-UC1互換ケーブルだ。

カメラに接続した様子は以下のようになる:


動作の様子を簡単な動画にしてみた:
以下、実装について。
プロトモジュールを使った実装
今回は試作ということで、公式のプロトモジュールを使って実装することにした。
まず、有線リモコンとして使うために
- 2.5mmステレオジャック
- aitendoで買った表面実装用のものを使用。数年前に購入したもので、型番はよくわからない
- フォトカプラ(2回路)
- 数年前に購入したTLP621(生産終了)を使用
- フォトカプラ用の(電流制限)抵抗×2
- 手持ちの200Ωを使用
は最低限実装する必要がある。この他、操作用のインターフェースがボタン3つだけでは心許ないので、追加の操作インターフェースとして
- レバースイッチ:上下と押下
- レバースイッチ用のプルアップ抵抗×3
- 手持ちの100kΩを使用
を実装した。
回路図は

という感じになる。
当初、シャッター制御用のピンに19番と23番を使っていたが、それはMISO/MOSIで使用されるピンで、LCDの制御と干渉してしまった。その場のノリで適当に配線したらダメですね。
タイマーリモコン化プログラム
やっつけで書いたので、かなり汚いコードになっている(せめてコメントを書け)。
#include <M5Stack.h> | |
#include <utility/M5Timer.h> | |
const int SHUTTER_PIN = 12; | |
const int FOCUS_PIN = 5; | |
const int LEVER_UP = 13; | |
const int LEVER_PUSH = 0; | |
const int LEVER_DOWN = 34; | |
Button LeverUp(LEVER_UP, true, 10); | |
Button LeverPush(LEVER_PUSH, true, 10); | |
Button LeverDown(LEVER_DOWN, true, 10); | |
bool changed = true; | |
// LCD: 320x240 | |
const uint8_t TEXT_SIZE = 3; | |
const int16_t LINE_HEIGHT = TEXT_SIZE * 8; | |
const int16_t CHAR_WIDTH = TEXT_SIZE * 6; | |
enum Selection { | |
SEL_DELAY = 0, | |
SEL_LONG, | |
SEL_INTERVAL, | |
SEL_REPEAT, | |
SEL_MAX = SEL_REPEAT | |
} selection = SEL_DELAY; | |
bool valueSelected = false; | |
int delayValue = 0; | |
int longValue = 1; | |
int intervalValue = 1; | |
int repeatValue = 1; | |
M5Timer timer; | |
bool isTimerRunning = false; | |
bool isLocked = false; | |
bool isShutterPressed = false; | |
int timerId = -1; | |
int count = 0; | |
void setup() { | |
M5.begin(); | |
M5.Power.begin(); | |
pinMode(SHUTTER_PIN, OUTPUT); | |
pinMode(FOCUS_PIN, OUTPUT); | |
M5.Lcd.setTextSize(TEXT_SIZE); | |
} | |
void stopShutter() | |
{ | |
digitalWrite(SHUTTER_PIN, LOW); | |
digitalWrite(FOCUS_PIN, LOW); | |
isShutterPressed = false; | |
if (repeatValue == 0 || count < repeatValue) { | |
int delta = intervalValue - longValue; | |
timerId = timer.setTimeout(delta <= 0 ? 1 : (long)delta * 1000L, startShutter); | |
} else { | |
timerId = -1; | |
isTimerRunning = false; | |
} | |
changed = true; | |
} | |
void startShutter() | |
{ | |
++count; | |
changed = true; | |
digitalWrite(FOCUS_PIN, HIGH); | |
digitalWrite(SHUTTER_PIN, HIGH); | |
isShutterPressed = true; | |
timerId = timer.setTimeout(longValue == 0 ? 1 : (long)longValue * 1000L, stopShutter); | |
} | |
void loop() { | |
timer.run(); | |
M5.update(); | |
LeverUp.read(); | |
LeverPush.read(); | |
LeverDown.read(); | |
if (LeverPush.wasPressed()) { | |
valueSelected = !valueSelected; | |
changed = true; | |
} | |
if (LeverUp.wasPressed()) { | |
if (valueSelected) { | |
switch (selection) { | |
case SEL_DELAY: | |
delayValue += 1; | |
break; | |
case SEL_LONG: | |
longValue += 1; | |
break; | |
case SEL_INTERVAL: | |
intervalValue += 1; | |
break; | |
case SEL_REPEAT: | |
repeatValue += 1; | |
break; | |
} | |
changed = true; | |
} else { | |
if (selection > 0) { | |
selection = (Selection)(selection - 1); | |
changed = true; | |
} | |
} | |
} else if (LeverDown.wasPressed()) { | |
if (valueSelected) { | |
switch (selection) { | |
case SEL_DELAY: | |
if (delayValue > 0) { | |
delayValue -= 1; | |
} | |
break; | |
case SEL_LONG: | |
if (longValue > 0) { | |
longValue -= 1; | |
} | |
break; | |
case SEL_INTERVAL: | |
if (intervalValue > 0) { | |
intervalValue -= 1; | |
} | |
break; | |
case SEL_REPEAT: | |
if (intervalValue > 0) { | |
repeatValue -= 1; | |
} | |
break; | |
} | |
changed = true; | |
} else { | |
if (selection < SEL_MAX) { | |
selection = (Selection)(selection + 1); | |
changed = true; | |
} | |
} | |
} | |
if (M5.BtnA.wasPressed() || M5.BtnA.wasReleased() || M5.BtnB.wasPressed() || M5.BtnB.wasReleased() || M5.BtnC.wasPressed() || M5.BtnC.wasReleased()) { | |
changed = true; | |
} | |
if (changed) { | |
M5.Lcd.setTextColor(TFT_RED); | |
M5.Lcd.fillScreen(TFT_BLACK); | |
// M5.Lcd.fillRect(0, 0, CHAR_WIDTH * 8, LINE_HEIGHT * 4, TFT_DARKGREY); | |
M5.Lcd.setCursor(0, 0); | |
if (selection == SEL_DELAY && !valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.print("DELAY"); | |
M5.Lcd.setCursor(CHAR_WIDTH * 9, LINE_HEIGHT * 0); | |
if (selection == SEL_DELAY && valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.printf("%d s", delayValue); | |
M5.Lcd.println(); | |
if (selection == SEL_LONG && !valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.print("LONG"); | |
M5.Lcd.setCursor(CHAR_WIDTH * 9, LINE_HEIGHT * 1); | |
if (selection == SEL_LONG && valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.printf("%d s", longValue); // or inf | |
M5.Lcd.println(); | |
if (selection == SEL_INTERVAL && !valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.print("INTERVAL"); | |
M5.Lcd.setCursor(CHAR_WIDTH * 9, LINE_HEIGHT * 2); | |
if (selection == SEL_INTERVAL && valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.printf("%d s", intervalValue); | |
M5.Lcd.println(); | |
if (selection == SEL_REPEAT && !valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
M5.Lcd.print("REPEAT"); | |
M5.Lcd.setCursor(CHAR_WIDTH * 9, LINE_HEIGHT * 3); | |
if (selection == SEL_REPEAT && valueSelected) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
if (repeatValue == 0) { | |
M5.Lcd.println("INF"); | |
} else { | |
M5.Lcd.println(repeatValue); | |
} | |
M5.Lcd.setCursor(60 - 5 * CHAR_WIDTH / 2, 240 - LINE_HEIGHT); | |
if (M5.BtnA.isPressed()) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
if (!isTimerRunning) { | |
M5.Lcd.print("START"); | |
} else { | |
M5.Lcd.print("STOP"); | |
} | |
M5.Lcd.setCursor(160 - CHAR_WIDTH / 2, 240 - LINE_HEIGHT); | |
if (M5.BtnB.isPressed() || isShutterPressed) { | |
M5.Lcd.fillCircle(160, 240 - LINE_HEIGHT, (int32_t)(LINE_HEIGHT * 0.8), TFT_RED); | |
} else { | |
M5.Lcd.drawCircle(160, 240 - LINE_HEIGHT, (int32_t)(LINE_HEIGHT * 0.8), TFT_RED); | |
} | |
M5.Lcd.setCursor(260 - (isLocked ? 6 : 4) * CHAR_WIDTH / 2, 240 - LINE_HEIGHT); | |
if (M5.BtnC.isPressed()) { | |
M5.Lcd.setTextColor(TFT_BLACK, TFT_RED); | |
} else { | |
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK); | |
} | |
if (isLocked) { | |
M5.Lcd.print("UNLOCK"); | |
} else { | |
M5.Lcd.print("LOCK"); | |
} | |
changed = false; | |
} | |
if (M5.BtnA.wasReleased()) { | |
if (isTimerRunning) { | |
timer.deleteTimer(timerId); | |
timerId = -1; | |
isTimerRunning = false; | |
changed = true; | |
isShutterPressed = false; | |
digitalWrite(SHUTTER_PIN, LOW); | |
digitalWrite(FOCUS_PIN, LOW); | |
} else { | |
count = 0; | |
isTimerRunning = true; | |
timerId = timer.setTimeout((long)delayValue * 1000L, startShutter); | |
changed = true; | |
} | |
} | |
if (M5.BtnB.wasPressed()) { | |
isShutterPressed = true; | |
digitalWrite(FOCUS_PIN, HIGH); | |
digitalWrite(SHUTTER_PIN, HIGH); | |
} else if (M5.BtnB.wasReleased() && !isLocked) { | |
isShutterPressed = false; | |
digitalWrite(SHUTTER_PIN, LOW); | |
digitalWrite(FOCUS_PIN, LOW); | |
changed = true; | |
} | |
if (M5.BtnC.wasPressed()) { | |
if (isLocked) { | |
if (M5.BtnB.isReleased()) { | |
isShutterPressed = false; | |
digitalWrite(SHUTTER_PIN, LOW); | |
digitalWrite(FOCUS_PIN, LOW); | |
} | |
isLocked = false; | |
} else { | |
if (M5.BtnB.isReleased()) { | |
isShutterPressed = true; | |
digitalWrite(FOCUS_PIN, HIGH); | |
digitalWrite(SHUTTER_PIN, HIGH); | |
} | |
isLocked = true; | |
} | |
changed = true; | |
} | |
delayMicroseconds(10); | |
} |
今後の方向性
タイマーリモコンとしての機能は揃ったが、ここで留まって良いものか。否である。「最強のカメラリモコン」を作るためにできることはまだまだある。
まず、せっかくESP32の載ったM5Stackを使っているのだから、Wi-Fiを使ってスマホからの操作を可能にしたい。これはプログラム次第でできるだろう。
関連研究:
次に、M5Stackを手持ちしたりカメラからぶら下げるのではなく、カメラのアクセサリーシューに装着できると良さそうである。これはアクリル板等で適当に工作すればできそうだ。
カメラを制御する方法には今回使った有線(3極)以外にもいくつかある。具体的には赤外線、USB、Wi-Fi/Bluetoothだ。
このうち、(プロトコルが広く知られているという意味で)扱いが楽そうなのは赤外線だ。今回作ったモジュールに赤外線LEDをつけて無線リモコンとして使えるようにすると「最強のカメラリモコン」に一歩近づけるだろう。
(USBはソフトウェア的に大変そうな気がするし、Wi-Fi/Bluetoothはごく最近のカメラじゃないと搭載されていない&解析の必要があるという欠点がある。)
関連: