M5Stackでモールス信号を鳴らしつつLEDを光らせてみました。
文字列をモールス信号に変換して、
Arduinoの digitalWrite()
や tone()
を使って出力するライブラリははいくつかあったのですが、
ボード依存なものが多かったのと、M5Stackを使った並列処理で実装したかったので自作してみました。
結果、モールス信号を鳴らすという内容より、 並列処理の内容がメインになってしまっています。
こちらが動画になります。
なぜ並列処理?
最近は低価格でマルチコアを搭載しているマイコンが増えてきています。 今回使用しているM5Stack(ESP32)やRaspberry Pi Picoは2コアですし、 少し高価ですがSonyのSPRESENSEは6コアです。
個人的には、マイコンを使った開発の際に並列処理を使うメリットは、複雑なタイマー制御が減る点だと思います。
Arduinoのコードを書いている時に「センサの値が閾値を超えた瞬間にアクチュエータを動かしたい」ということは度々あると思います。
しかし、アクチュエータは単純に digitalWrite(pin, HIGH)
などで済むことは少ないです。
大抵の場合は「モータをn秒正転させた後に、n秒停止し、n秒反転させる」などの複雑なアクションになります。
さらに、アクチュエータを動作させている間も、他のアクチュエータを動作させたり、センサを監視したいことがあります。
このような制御を実装する際には、タイマーを使った複雑なコードになりがちです。
そんな時にマルチコアを使うと「コア0ではセンサを監視しつつ、コア1ではアクチュエータを動かす」といった制御が楽になります。
シーケンス図にするとこんな感じになります。
sequenceDiagram participant Core0 as Core0
(センサを監視するコア) participant Queue as Queue
(アクチュエータを動かす命令が格納されるキュー) participant Core1 as Core1
(アクチュエータを動かすコア) Note over Queue: [ ] Note over Core0: センサの値が閾値を超えた Core0->>Queue: キューにデータAを追加 Note over Queue: [ A ] Core1->>Queue: キューを監視 Queue->>Core1: キューからデータAを取り出す Note over Queue: [ ] Note over Core1: アクチュエータを動かし始める Note over Core0: センサの値が閾値を超えた Core0->>Queue: キューにデータBを追加 Note over Queue: [ B ] Note over Core1: アクチュエータを動かし終わる Core1->>Queue: キューを監視 Queue->>Core1: キューからデータBを取り出す Note over Queue: [ ] Note over Core1: アクチュエータを動かし始める Note over Core1: アクチュエータを動かし終わる
また、M5Stackでマルチコアを扱う実装はFreeRTOSが使われているので、 Arduino UNOのようなシングルコアのマイコンでも、 Arduino_FreeRTOS_Libraryを使用するば、 少しコードを書き変えるだけでタスク管理を実現できます。
モールス信号について
モールス信号の仕様についてはこちらをご覧ください。
国際モールス符号は短点(・)と長点(-)を組み合わせて、アルファベット・数字・記号を表現する。長点1つは短点3つ分の長さに相当し、各点の間は短点1つ分の間隔をあける。また、文字間隔は短点3つ分、語間隔は短点7つ分あけて区別する。
日本語のモールス信号もあるようですが、 今回は英語のモールスコードにのみ対応することとします。
実装について
こちらがソースコードになります。
https://github.com/enkatsu/M5MorseCodeTalker
それでは簡単に解説していきます。
codes.h
文字からモールスコードへのマッピングについては、 少しアレンジしましたがほとんどこちらのライブラリを参考にしました。
https://github.com/ktauchathuranga/MorseEncoder/tree/main
const char *const morseCodes[36] PROGMEM = {
".-", // A
"-...", // B
"-.-.", // C
"-..", // D
".", // E
"..-.", // F
"--.", // G
"....", // H
"..", // I
".---", // J
"-.-", // K
".-..", // L
"--", // M
"-.", // N
"---", // O
".--.", // P
"--.-", // Q
".-.", // R
"...", // S
"-", // T
"..-", // U
"...-", // V
".--", // W
"-..-", // X
"-.--", // Y
"--..", // Z
"-----", // 0
".----", // 1
"..---", // 2
"...--", // 3
"....-", // 4
".....", // 5
"-....", // 6
"--...", // 7
"---..", // 8
"----." // 9
};
const char specialChars[] = ".,:;?=/!-_\"()$@&+";
const char *const morseSpecialChars[17] PROGMEM = {
".-.-.-", // .
"--..--", // ,
"---...", // :
"-.-.-.", // ;
"..--..", // ?
"-...-", // =
"-..-.", // /
"-.-.--", // !
"-....-", // -
"..--.-", // _
".-..-.", // "
"-.--.", // (
"-.--.-", // )
"...-..-", // $
".--.-.", // @
".-...", // &
".-.-." // +
};
// モールスコードの最大長(本当は7っぽいけどキリがよく8とする)
const size_t MORSE_CODE_MAX_SIZE = 8;
encode.h
モールスコードのエンコードに関する関数は別ファイルに分けました。 重要なのはここ。
キューの取り扱いについてはこちらのページが参考になりました。
https://lang-ship.com/reference/unofficial/M5StickC/Functions/freertos/queue/
#ifndef MorseEncoder_h
#define MorseEncoder_h
#include "codes.h"
#include <M5Stack.h>
enum class Code {
SHORT, // 単点
LONG, // 長点
SPACE, // スペース(単語間の区切り)
DELIMITER, // 文字間の区切り
};
/**
* charをenumに変換する関数
*/
Code charToCode(char c) {
if (c == '.') {
return Code::SHORT;
} else if (c == '-') {
return Code::LONG;
}
}
/**
* モールスコードをエンコードする関数
* 第一引数に変換する文字を渡す。
* 第二引数に渡したポインタに変換結果のコードが格納され、
* 第三引数に渡した数値型に変換結果のコードの長さが格納される。
*/
int encodeCharToMorseCode(char character, Code* codePtr, size_t& length) {
// *** A-Z ***
char upper = toupper(character);
if (upper >= 'A' && upper <= 'Z') {
int index = upper - 'A';
length = strlen(morseCodes[index]);
const char* morse = reinterpret_cast<const char*>(pgm_read_dword(&morseCodes[index]));
for (int j = 0; morse[j] != '\0'; j++) {
codePtr[j] = charToCode(morse[j]);
}
return 0;
}
// *** 0-9 ***
if (character >= '0' && character <= '9') {
int index = character - '0' + 26;
length = strlen(morseCodes[index]);
const char* morse = reinterpret_cast<const char*>(pgm_read_dword(&morseCodes[index]));
for (int j = 0; morse[j] != '\0'; j++) {
codePtr[j] = charToCode(morse[j]);
}
return 0;
}
// *** スペース ***
if (character == ' ') {
length = 1;
codePtr[0] = Code::SPACE;
return 0;
}
// *** 記号 ***
int index = -1;
for (uint32_t j = 0; j < sizeof(specialChars) - 1; j++) {
if (character == specialChars[j]) {
index = j;
break;
}
}
if (index >= 0 && index < 17) {
length = strlen(morseSpecialChars[index]);
const char* morse = reinterpret_cast<const char*>(pgm_read_dword(&morseSpecialChars[index]));
for (int j = 0; morse[j] != '\0'; j++) {
codePtr[j] = charToCode(morse[j]);
}
return 0;
}
return 1;
}
/**
* メッセージをモールス信号にエンコードしてキューに追加する関数
* 第一引数に格納したいキュー、第二引数に変換したい文字列を渡す
*/
void encodeMessageToMorseAndEnqueue(QueueHandle_t& queue, const char* message) {
for (int i = 0; message[i] != '\0'; i++) {
size_t len = 0;
Code* codePtr = (Code*)malloc(sizeof(Code) * MORSE_CODE_MAX_SIZE);
int result = encodeCharToMorseCode(message[i], codePtr, len);
for (int j = 0; j < len; j++) {
int ret = xQueueSend(queue, &codePtr[j], 0);
}
free(codePtr);
Code delimiter = Code::DELIMITER;
int ret = xQueueSend(queue, &delimiter, 0);
}
}
#endif
M5MorseCodeTalker.ino
これがArduinoのメインファイルです。
Aボタンを押す、もしくはシリアル通信でデータを受け取った際に、 上記の関数を使ってキューにエンコードしたモールスコードを追加します。 CPUのコア0を使ってキューを監視し、 点の種類に応じた長さの音を鳴らしつつLEDを光らせます。
コアの指定に関してはこちらの記事が参考になりました。
無線系の処理がPRO_CPU_NUMで動いていますので、 無線を利用している場合にはPRO_CPU_NUMであまりタスクを実行しないほうが好ましいです。 逆に無線を利用していない場合には、PRO_CPU_NUMのコアはあまり利用されていないので、 積極的に使ったほうがいいと思います。
https://lang-ship.com/blog/work/esp32-freertos-l02-taskcreate/
#include <M5Stack.h>
#include "MorseEncoder.h"
const uint16_t wpm = 15; // Word / Minutes
const uint16_t unitTime = 1200 / wpm; // 単点を鳴らす時間
const uint32_t CODE_QUEUE_LENGTH = 128; // モールスコードを溜め込むキューの長さ
QueueHandle_t codeQueue; // モールスコードを溜め込むキュー
const int LED_PIN = 22;
/**
* キューで受け取ったモールスコードに応じた処理を行うタスクで使用する関数
*/
void morseTask(void* arg) {
Code code;
while (1) {
BaseType_t ret = xQueueReceive(codeQueue, &code, portMAX_DELAY);
if (ret) {
switch (code) {
case Code::SHORT:
M5.Speaker.tone(440, unitTime * 1);
digitalWrite(LED_PIN, HIGH);
delay(unitTime * 1);
M5.Speaker.mute();
digitalWrite(LED_PIN, LOW);
break;
case Code::LONG:
M5.Speaker.tone(440, unitTime * 3);
digitalWrite(LED_PIN, HIGH);
delay(unitTime * 3);
M5.Speaker.mute();
digitalWrite(LED_PIN, LOW);
break;
case Code::SPACE:
delay(unitTime * 6);
break;
case Code::DELIMITER:
delay(unitTime * 2);
break;
default:
break;
}
delay(unitTime * 1);
}
delay(1);
}
}
/**
* ボタンのイベントやシリアル通信の処理を行うタスクで使用する関数
*/
void inputTask(void* arg) {
while (1) {
M5.update();
// Aボタンを押したら "hello" のモールス信号をキューに溜める
if (M5.BtnA.wasReleased()) {
encodeMessageToMorseAndEnqueue(codeQueue, "hello");
}
// シリアルモニタからメッセージを受け取った場合、
// 受け取った文字列をキューに溜める
if (Serial.available() > 0) {
String message = Serial.readStringUntil('\n');
Serial.println(message);
encodeMessageToMorseAndEnqueue(codeQueue, message.c_str());
}
delay(1);
}
}
void setup() {
Serial.begin(115200);
M5.begin();
pinMode(LED_PIN, OUTPUT);
// キューを初期化
codeQueue = xQueueCreate(CODE_QUEUE_LENGTH, sizeof(Code));
// キューを監視して受け取ったモールスコードに応じた処理を行うタスクを作成(PRO_CPU_NUMはコア0)
xTaskCreatePinnedToCore(morseTask, "morseTask", 8192, NULL, 1, &taskHandle[0], PRO_CPU_NUM);
// 文字列をモールス信号にエンコードしてキューに追加するタスクを作成(APP_CPU_NUMはコア1)
xTaskCreatePinnedToCore(inputTask, "inputTask", 8192, NULL, 1, &taskHandle[1], APP_CPU_NUM);
}
void loop() {
}
まとめ
普段ならPythonやJavaなどで実装して、 dictやMapで文字とモールスコードの対応関係を作るのですが、 実装がマイコンだとリソースを贅沢に使えないですね。 ですが、たまにC/C++を書くのは楽しいです。
何年か前に参加したワークショップで講師の電子工作界隈のベテラン作家さんが、 M5StickのコードをFreeRTOSで実装していたのを思い出しました。 当時はあまり知らなかったのですが、あらためて良い経験だったなと思いました。
まだ、改善点はありそうですがとりあえず投稿しました。