ダイソーのリモコンライトを解析して、M5StickCから操作する

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

ダイソーで300円で販売されている、リモコン付きのイルミネーションライトの赤外線信号を解析してみました。

M5StickCを利用して、解析したリモコン信号を送信するスケッチも作ってみました。

売り場

ダイソーの家電のライトを売っているコーナーで発見しました。とはいっても、なかなか取り扱いがなく、3軒目ぐらいに発見しています。

リモコンが違うバージョンも販売されているようでした。

内容物

リモコンにはボタン電池が最初から入っていて、絶縁用のシートを抜き取ると利用できるようになります。

ライト本体は裏側を回すようにあけると、電池を入れることができます。手元にあった充電式の単3電池をとりあえず入れてみました。

こんな感じで光ります。ライト本体を押すことでスイッチのON、OFFもできますし、リモコンを使って操作もできます。

ただし、リモコンはONで電源をつけてからじゃないと、色が変わりませんでした。

リモコンの解析

IRremoteESP8266というライブラリを利用しました。ライブラリマネージャーより、事前にインストールしておいてください。

リモコン受信モジュールはM5StickCには搭載されていないので、接続する必要があります。

センサーはElegooのセットに入っていた物を利用しました。

どれを使ってもそう変わらないと思いますが、今であればGrove接続のM5Stack用赤外線送受信ユニットが送料を払っても安いと思います。

センサーのみで基板がついていないやつは、外付けの抵抗などが必要になる可能性があるのでおすすめしません。できればリモコンもついているものの方が、受信確認しやすいですが、今回のライトにもリモコンがあるのでリモコンなしでもいいのかもしれません。

赤外線受信M5StickC
GGND
R3.3V
YGPIO26

今回利用した受信モジュールは上記で接続を行いました。Grove接続のM5Stack用赤外線送受信ユニットだと、どこにつなげるのか考えなくてもいいので、楽そうですね。

リモコン受信スケッチ

/*
 * IRremoteESP8266: IRrecvDumpV2 - dump details of IR codes with IRrecv
 * An IR detector/demodulator must be connected to the input kRecvPin.
 *
 * Copyright 2009 Ken Shirriff, http://arcfn.com
 * Copyright 2017-2019 David Conran
 *
 * Example circuit diagram:
 *  https://github.com/crankyoldgit/IRremoteESP8266/wiki#ir-receiving
 *
 * Changes:
 *   Version 1.0 October, 2019
 *     - Internationalisation (i18n) support.
 *     - Stop displaying the legacy raw timing info.
 *   Version 0.5 June, 2019
 *     - Move A/C description to IRac.cpp.
 *   Version 0.4 July, 2018
 *     - Minor improvements and more A/C unit support.
 *   Version 0.3 November, 2017
 *     - Support for A/C decoding for some protocols.
 *   Version 0.2 April, 2017
 *     - Decode from a copy of the data so we can start capturing faster thus
 *       reduce the likelihood of miscaptures.
 * Based on Ken Shirriff's IrsendDemo Version 0.1 July, 2009,
 */

#include <Arduino.h>
#include <IRrecv.h>
#include <IRremoteESP8266.h>
#include <IRac.h>
#include <IRtext.h>
#include <IRutils.h>

// ==================== start of TUNEABLE PARAMETERS ====================
// An IR detector/demodulator is connected to GPIO pin 14
// e.g. D5 on a NodeMCU board.
// Note: GPIO 16 won't work on the ESP8266 as it does not have interrupts.
const uint16_t kRecvPin = 26;

// The Serial connection baud rate.
// i.e. Status message will be sent to the PC at this baud rate.
// Try to avoid slow speeds like 9600, as you will miss messages and
// cause other problems. 115200 (or faster) is recommended.
// NOTE: Make sure you set your Serial Monitor to the same speed.
const uint32_t kBaudRate = 115200;

// As this program is a special purpose capture/decoder, let us use a larger
// than normal buffer so we can handle Air Conditioner remote codes.
const uint16_t kCaptureBufferSize = 1024;

// kTimeout is the Nr. of milli-Seconds of no-more-data before we consider a
// message ended.
// This parameter is an interesting trade-off. The longer the timeout, the more
// complex a message it can capture. e.g. Some device protocols will send
// multiple message packets in quick succession, like Air Conditioner remotes.
// Air Coniditioner protocols often have a considerable gap (20-40+ms) between
// packets.
// The downside of a large timeout value is a lot of less complex protocols
// send multiple messages when the remote's button is held down. The gap between
// them is often also around 20+ms. This can result in the raw data be 2-3+
// times larger than needed as it has captured 2-3+ messages in a single
// capture. Setting a low timeout value can resolve this.
// So, choosing the best kTimeout value for your use particular case is
// quite nuanced. Good luck and happy hunting.
// NOTE: Don't exceed kMaxTimeoutMs. Typically 130ms.
#if DECODE_AC
// Some A/C units have gaps in their protocols of ~40ms. e.g. Kelvinator
// A value this large may swallow repeats of some protocols
const uint8_t kTimeout = 50;
#else   // DECODE_AC
// Suits most messages, while not swallowing many repeats.
const uint8_t kTimeout = 15;
#endif  // DECODE_AC
// Alternatives:
// const uint8_t kTimeout = 90;
// Suits messages with big gaps like XMP-1 & some aircon units, but can
// accidentally swallow repeated messages in the rawData[] output.
//
// const uint8_t kTimeout = kMaxTimeoutMs;
// This will set it to our currently allowed maximum.
// Values this high are problematic because it is roughly the typical boundary
// where most messages repeat.
// e.g. It will stop decoding a message and start sending it to serial at
//      precisely the time when the next message is likely to be transmitted,
//      and may miss it.

// Set the smallest sized "UNKNOWN" message packets we actually care about.
// This value helps reduce the false-positive detection rate of IR background
// noise as real messages. The chances of background IR noise getting detected
// as a message increases with the length of the kTimeout value. (See above)
// The downside of setting this message too large is you can miss some valid
// short messages for protocols that this library doesn't yet decode.
//
// Set higher if you get lots of random short UNKNOWN messages when nothing
// should be sending a message.
// Set lower if you are sure your setup is working, but it doesn't see messages
// from your device. (e.g. Other IR remotes work.)
// NOTE: Set this value very high to effectively turn off UNKNOWN detection.
const uint16_t kMinUnknownSize = 12;

// Legacy (No longer supported!)
//
// Change to `true` if you miss/need the old "Raw Timing[]" display.
#define LEGACY_TIMING_INFO false
// ==================== end of TUNEABLE PARAMETERS ====================

// Use turn on the save buffer feature for more complete capture coverage.
IRrecv irrecv(kRecvPin, kCaptureBufferSize, kTimeout, true);
decode_results results;  // Somewhere to store the results

// This section of code runs only once at start-up.
void setup() {
#if defined(ESP8266)
  Serial.begin(kBaudRate, SERIAL_8N1, SERIAL_TX_ONLY);
#else  // ESP8266
  Serial.begin(kBaudRate, SERIAL_8N1);
#endif  // ESP8266
  while (!Serial)  // Wait for the serial connection to be establised.
    delay(50);
  Serial.printf("\n" D_STR_IRRECVDUMP_STARTUP "\n", kRecvPin);
#if DECODE_HASH
  // Ignore messages with less than minimum on or off pulses.
  irrecv.setUnknownThreshold(kMinUnknownSize);
#endif  // DECODE_HASH
  irrecv.enableIRIn();  // Start the receiver
}

// The repeating section of the code
void loop() {
  // Check if the IR code has been received.
  if (irrecv.decode(&results)) {
    // Display a crude timestamp.
    uint32_t now = millis();
    Serial.printf(D_STR_TIMESTAMP " : %06u.%03u\n", now / 1000, now % 1000);
    // Check if we got an IR message that was to big for our capture buffer.
    if (results.overflow)
      Serial.printf(D_WARN_BUFFERFULL "\n", kCaptureBufferSize);
    // Display the library version the message was captured with.
    Serial.println(D_STR_LIBRARY "   : v" _IRREMOTEESP8266_VERSION_ "\n");
    // Display the basic output of what we found.
    Serial.print(resultToHumanReadableBasic(&results));
    // Display any extra A/C info if we have it.
    String description = IRAcUtils::resultAcToString(&results);
    if (description.length()) Serial.println(D_STR_MESGDESC ": " + description);
    yield();  // Feed the WDT as the text output can take a while to print.
#if LEGACY_TIMING_INFO
    // Output legacy RAW timing info of the result.
    Serial.println(resultToTimingInfo(&results));
    yield();  // Feed the WDT (again)
#endif  // LEGACY_TIMING_INFO
    // Output the results as source code
    Serial.println(resultToSourceCode(&results));
    Serial.println();    // Blank line between entries
    yield();             // Feed the WDT (again)
  }
}

IRremoteESP8266のスケッチ例にあるIRrecvDumpV2を利用しました。GPIOだけ14から26に変更しています。

Grove接続のM5Stack用赤外線送受信ユニットだとGPIO33が受信かな?

受信結果

Protocol  : NEC
Code      : 0x1FE48B7 (32 Bits)
uint16_t rawData[67] = {9144, 4478,  614, 522,  616, 526,  610, 522,  614, 522,  614, 522,  616, 522,  616, 522,  616, 1654,  590, 1654,  592, 1654,  590, 1654,  590, 1634,  610, 1632,  612, 1654,  590, 1656,  588, 522,  616, 522,  614, 1654,  590, 524,  614, 522,  612, 1656,  590, 524,  614, 520,  616, 522,  614, 1632,  612, 522,  616, 1654,  590, 1654,  590, 522,  614, 1654,  590, 1654,  590, 1654,  588};  // NEC 1FE48B7
uint32_t address = 0x80;
uint32_t command = 0x12;
uint64_t data = 0x1FE48B7;

上記のようなコードが受信できます。Protocolは標準的なNECですね。Codeの0x1FE48B7が受信したデータそのもので、それを分析するとaddressとcommandがわかります。

リモコンの配置

POWER ONPOWER OFFMODE
4H8HMulti Color
RedGreenBlue
OrangeLimeNavy
YellowAquaPink
WhiteSkybluePurple

横3個、縦6個の全部で18個のボタンがあります。色名はわたしがつけたものですので、間違っている可能性があります。リモコンの見た目と、実際のライトの色が違うのでちょっと名前付けが難しかったです。

コマンド

0x120x1A0x1E
0x010x020x03
0x040x050x06
0x070x080x09
0x0A0x1B0x1F
0x0C0x0D0x0E

addressはすべて0x80でしたので、commandだけ表にしました。番号が不自然に飛んでいますので、別の用途のリモコンを流用しているのかもしれません。

コード

0x1FE48B70x1FE58A70x1FE7887
0x1FE807F0x1FE40BF0x1FEC03F
0x1FE20DF0x1FEA05F0x1FE609F
0x1FEE01F0x1FE10EF0x1FE906F
0x1FE50AF0x1FED8270x1FEF807
0x1FE30CF0x1FEB04F0x1FE708F

生データであるコードものせておきます。

リモコンの赤外線送信

#include <M5StickC.h>
#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <IRutils.h>
 
const uint16_t kIrLed = 9;              // M5StickCはGPIO9にIRが内蔵
 
IRsend irsend(kIrLed);                  // IR送信を宣言
 
const uint32_t IR_ADDRESS = 0x0080;     // アドレス
 
// リモコンコード保存用構造体
struct REMOTE {
  char name[16];
  uint8_t command;
};
 
// リモコンコード一覧
REMOTE remote[] = {
  { "POWER ON"    , 0x12 },
  { "POWER OFF"   , 0x1A },
  { "MODE"        , 0x1E },

  { "4H"          , 0x01 },
  { "8H"          , 0x02 },
  { "Multi Color" , 0x03 },

  { "Red"         , 0x04 },
  { "Green"       , 0x05 },
  { "Blue"        , 0x06 },

  { "Orange"      , 0x07 },
  { "Lime"        , 0x08 },
  { "Navy"        , 0x09 },

  { "Yellow"      , 0x0A },
  { "Aqua"        , 0x1B },
  { "Pink"        , 0x1F },

  { "White"       , 0x0C },
  { "Skyblue"     , 0x0D },
  { "Purple"      , 0x0E },
};
 
int cursor = 0; // カーソル位置
 
void setup() {
  M5.begin();     // M5StickC初期化
  irsend.begin(); // IR初期化
 
  // リモコン項目表示
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setCursor(0, 8);
  for ( int i = 0 ; i < ( sizeof(remote) / sizeof(REMOTE) ) ; i++ ) {
    M5.Lcd.print((cursor == i) ? ">" : " ");
    M5.Lcd.println(remote[i].name);
  }
}
 
void loop() {
  M5.update();  // ボタン状態更新
 
  // M5ボタンで送信
  if ( M5.BtnA.wasPressed() ) {
    // 送信データ作成
    uint64_t send = irsend.encodeNEC(IR_ADDRESS, remote[cursor].command);

    // 送信
    irsend.sendNEC(send);
 
    // デバッグ表示
    Serial.printf("Send IR : 0x%08LX", send);
    Serial.printf("(address=0x%04X, ", IR_ADDRESS);
    Serial.printf("command=0x%02X)\n", remote[cursor].command);
  }
 
  // 右ボタンでカーソル移動
  if ( M5.BtnB.wasPressed() ) {
    cursor++;
    cursor = cursor % ( sizeof(remote) / sizeof(REMOTE) );
 
    // カーソル描画
    M5.Lcd.setCursor(0, 8);
    for ( int i = 0 ; i < ( sizeof(remote) / sizeof(REMOTE) ) ; i++ ) {
      M5.Lcd.println((cursor == i) ? ">" : " ");
    }
  }
 
  delay(100);
}

M5StickCでの送信例です。信号解析で利用した受信ユニットは必要ないので取り外します。

IR送信はGPIO9に接続されているので、内蔵の赤外線送信ユニットを使います。ただし、内蔵の赤外線送信ユニットは若干出力が弱いので、あまり遠くまで飛びません。

Grove接続のM5Stack用赤外線送受信ユニットを使って、GPIO32から送信したほうが、より遠距離まで信号は届くと思います。

    // 送信データ作成
    uint64_t send = irsend.encodeNEC(IR_ADDRESS, remote[cursor].command);

    // 送信
    irsend.sendNEC(send);

肝になるのが、上記の処理です。NECプロトコルだったのでNECとついている関数を利用します。encodeNEC()関数でアドレスとコマンドから、実際に送信するCodeを作成し、sendNEC()関数で送信します。

あとは描画やカーソル処理などになるので、たいした処理はやっていません。

コード指定

#include <M5StickC.h>
#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <IRutils.h>

const uint16_t kIrLed = 9;              // M5StickCはGPIO9にIRが内蔵

IRsend irsend(kIrLed);                  // IR送信を宣言

// リモコンコード保存用構造体
struct REMOTE {
  char name[16];
  uint64_t code;
};

// リモコンコード一覧
REMOTE remote[] = {
  { "POWER ON"    , 0x1FE48B7 },
  { "POWER OFF"   , 0x1FE58A7 },
  { "MODE"        , 0x1FE7887 },

  { "4H"          , 0x1FE807F },
  { "8H"          , 0x1FE40BF },
  { "Multi Color" , 0x1FEC03F },

  { "Red"         , 0x1FE20DF },
  { "Green"       , 0x1FEA05F },
  { "Blue"        , 0x1FE609F },

  { "Orange"      , 0x1FEE01F },
  { "Lime"        , 0x1FE10EF },
  { "Navy"        , 0x1FE906F },

  { "Yellow"      , 0x1FE50AF },
  { "Aqua"        , 0x1FED827 },
  { "Pink"        , 0x1FEF807 },

  { "White"       , 0x1FE30CF },
  { "Skyblue"     , 0x1FEB04F },
  { "Purple"      , 0x1FE708F },
};

int cursor = 0; // カーソル位置

void setup() {
  M5.begin();     // M5StickC初期化
  irsend.begin(); // IR初期化

  // リモコン項目表示
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setCursor(0, 8);
  for ( int i = 0 ; i < ( sizeof(remote) / sizeof(REMOTE) ) ; i++ ) {
    M5.Lcd.print((cursor == i) ? ">" : " ");
    M5.Lcd.println(remote[i].name);
  }
}

void loop() {
  M5.update();  // ボタン状態更新

  // M5ボタンで送信
  if ( M5.BtnA.wasPressed() ) {
    // 送信
    irsend.sendNEC(remote[cursor].code);

    // デバッグ表示
    Serial.printf("Send IR : 0x%08LX", remote[cursor].code);
  }

  // 右ボタンでカーソル移動
  if ( M5.BtnB.wasPressed() ) {
    cursor++;
    cursor = cursor % ( sizeof(remote) / sizeof(REMOTE) );

    // カーソル描画
    M5.Lcd.setCursor(0, 8);
    for ( int i = 0 ; i < ( sizeof(remote) / sizeof(REMOTE) ) ; i++ ) {
      M5.Lcd.println((cursor == i) ? ">" : " ");
    }
  }

  delay(100);
}

ほぼ変わっていませんが、チャンネルとコマンドの変わりにコードをそのまま使っている場合です。

    // 送信
    irsend.sendNEC(remote[cursor].code);

エンコードしなくてもよいので、送信はスッキリしています。コードを使うのかコマンドを使うのかは好みなので、どちらを利用しても構いません。

学習リモコン的な使い方の場合にはコードをそのまま使ったほうが楽だと思いますし、手で確認しているときにはコマンドがわかったほうが、目視で確認しやすいです。

分解

ネジ4本で止まっているだけなので、かんたんに分解することができます。

右下に赤外線受信ユニットがあって、左側に電源スイッチがありますね。さて、ここに赤外線受信ユニットがあるってことは、、、

赤外線ユニットに上にある3つのハンダからパターンを追っていくと、左が電源で真ん中がGND、右が信号みたいです。とりあえずGNDとGND、信号とGPIO26を接続して、リモコン信号を受信すると、、、なんと受信できました。

とはいえ、おすすめしない方法なのでマネはしないでください。電源が1.2Vのエネループが3本なので3.6Vとちょっと高めの信号が入ってきます。新品のアルカリ電池とかだと1.7Vが3本で5.1VなのでESP32が壊れる可能性があります。

そもそも、分解しないでください!

まとめ

いまは300円でもこんなものがあるんですね。中国だとリモコンと受信ユニットのセットで100円以下で買えるので、たしかにもろもろで300円にはなるのかもしれません。

ESP32のFreeRTOS入門 その4 割り込みと通知

概要

前回はマルチタスクを説明しました。今回は実際にマルチタスクを利用していくときに注意しなければならない割り込みと、排他制御の一つである通知を説明します。

割り込みとは?

タスクは定期的に動いていますが、何かをトリガーにして動くものを割り込みといいます。ハードウエア的な割り込みと、ソフトウエア的な割り込みがあります。

ESP32のハードウエア割り込みの場合にはCPUの内部にあるタイマーや、外部にあるGPIOやタッチセンサなどの区分があります。ソフトウエア割り込みは、タスクなどの中でトリガー条件を確認し、トリガーが発生すると呼び出すような処理になります。

ハードウエア割り込みは、基本的には優先度が一番高く、他のタスクが実行していても割り込み処理が優先して動きます。ソフトウエア割り込みは、トリガーを監視しているタスクの優先度に依存しますので、低い優先度で監視していると短時間のトリガー条件の場合、監視漏れが発生する可能性があります。

GPIO入力割り込み

volatile byte state = LOW;

void IRAM_ATTR onButton() {
  state = !state;
  Serial.println(state);
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}

void loop() {
  delay(1);
}

一番一般的なGPIOの入力レベルをトリガーにしたハードウエア割り込みの例を紹介します。onButton()関数がトリガーが発生した場合に実行される関数でISR(Interrupt Service Routine)などと呼ばれます。割り込みサービスルーチンや、割り込みハンドラなどとも呼ばれます。

void IRAM_ATTR onButton() {

ESP32はフラッシュ領域からメモリ(IRAM)にプログラムをロードして実行しますが、プログラムサイズが大きくなってくると、メモリにロードせずに、フラッシュにあるプログラムを直接実行することがあります。onButton()関数についているIRAM_ATTR属性は、フラッシュからではなく、メモリにロードして実行することを指示しています。

フラッシュ上にある関数を呼び出した場合には、呼び出しまでに時間がかかったり、他のプログラムがフラッシュにアクセスをしていた場合には呼び出しがキャンセルされる場合があるようです。

ただ、IRAM_ATTR属性をつけ忘れても、多くの場合フレッシュには配置されることは少なく、メモリにロードされてから実行されるので動いてしまいます。

このように割り込みやマルチタスクでは、属性を指定し忘れても多くの場合動いてしまうので注意してください。なんとなく動いているだけで、条件が変わると急に動かなくなります。その場合まったく関係ない場所を変更しているのに、割り込みやマルチタスクが動かなくなるので、原因を把握するのが非常に難しくなります。

volatile byte state = LOW;

次に、state変数にvolatile修飾子がついていますが、こちらも同じような理由でコンパイラの最適化を防ぐのと、変数の置き場所を指定しています。

上記によると、ISRの場合に変更する変数にはすべてvolatile修飾子をつけるように指示があります。

ただし、マルチタスクなどからも同時に更新がかかる可能性がある変数の場合には、他の排他制御を利用したほうが安全です。

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);

次に、実際に割り込みの設定をしているのが上記になります。外部プルアップされているGPIO37にボタンを接続して、ボタンを押すとLOWになる回路がつながっているM5StickCを利用した例です。

ISRとしてonButton()関数を指定して、トリガー条件としてFALLINGを指定しています。

Arduino DefineTechnical Reference Manualトリガー条件
0x00DISABLEDGPIO interrupt disable無効
0x01RISINGrising edge triggerLOWからHIGHに変化
0x02FALLINGfalling edge triggerHIGHからLOWに変化
0x03CHANGEany edge trigger信号レベルが変化
0x04ONLOWlow level triggerLOWのときに常に発生
0x05ONHIGHhigh level triggerHIGHのときに常に発生
0x0CONLOW_WEおそらくONLOWと同じ
0x0DONHIGH_WEおそらくONHIGHと同じ

上記がトリガー条件です。DISABLEDは実質使いませんので、RISINGかFALLING、CHANGEあたりをよく使います。ONLOWとONHIGHは常に割り込みが発生するので、緊急停止的なトリガー条件で使うものだと思います。

ONLOW_WEとONHIGH_WEは検索しても、使われている形跡がありませんでした。調べてみたところ、本来トリガー条件が3ビットなのですが、そのさらに上位1ビットも一緒に指定しようとしています。

GPIO_PINn_WAKEUP_ENABLE GPIO wake-up enable will only wake up the CPU from Light-sleep.(R/W)

上記がそのビットなのですが、ライトスリープからの復帰に使うためのフラグです。ただし、Arduinoのソースファイルを見たところ、トリガー条件は3ビットしか見ていませんでしたので、このフラグは無視されると思います。そのため、_WEのついている条件は指定しないでください。

割り込みの利点

#include <M5StickC.h>

byte state = LOW;

void setup() {
  M5.begin();
}

void loop() {
  M5.update();

  if ( M5.BtnA.wasReleased() ) {
    state = !state;
    Serial.println(state);
  }

  delay(100);
}

さて、M5StickCの例ですが割り込みを利用しない場合のサンプルです。ほとんど同じ動きですが、delay(100)ですので100ミリ秒に1度しかボタンの判定をしていません。そのためボタンを素早く連打すると判定漏れが発生します。

delay()の数値を小さくすることで、判定漏れが減りますが他に優先度のタスクがある場合には、そのタスクが重い処理をしていると、判定漏れがでてしまう可能性があります。このようにタスクでクリティカルな処理を行おうとすると、他のタスクの影響を受けてしまいます。そこで割り込みを利用することで、確実に判定をすることができます。

割り込みの盲点

上記のTickerクラスは、タイマー割り込みを使ったコールバックのように思えますが、内部的にはタイマー割り込みが発生したところで、セマフォという排他制御を使ってタイマー割り込みがあったことを保存し、別のタイマータスクで保存したタイマー割り込みの情報をもとに、コールバック関数を呼び出します。

このTickerクラスの場合には、複雑な排他処理を内部で実装してくれているのですが、最終的にはソフトウエア割り込みとして、タスクで処理されています。そのため、通信で利用しているタスクは、タイマータスクより優先順位が高いので通信の処理が優先され、タイマータスクは遅延して実行されます。

    int ret = xTaskCreatePinnedToCore(&timer_task, "esp_timer",
            ESP_TASK_TIMER_STACK, NULL, ESP_TASK_TIMER_PRIO, &s_timer_task, PRO_CPU_NUM);

上記がタイマータスクの作成部分のソースです。これを見るとPRO_CPUで優先度がESP_TASK_TIMER_PRIOで作成されています。

#define ESP_TASK_TIMER_PRIO           (ESP_TASK_PRIO_MAX - 3)

定義をみてみるとESP_TASK_PRIO_MAX(25)-3なので22ですね。無線系のタスクの優先順位が23なので、負けてしまいます。

/* Bt contoller Task */
/* controller */
#define ESP_TASK_BT_CONTROLLER_PRIO   (ESP_TASK_PRIO_MAX - 2)

みたところ、ライブラリの中では上記の無線が一番高い優先順位みたいです。(ESP_TASK_PRIO_MAX – 1)の24の最高優先順位でタスクを作らない限り、PRO_CPUでは無線のタスクが最上位になります。

割り込みの欠点

#include <M5StickC.h>

void IRAM_ATTR onButton() {
  Serial.println(M5.Axp.GetBatVoltage());
}

void setup() {
  M5.begin();

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}

void loop() {
  delay(1);
}

また、M5StickCの例ですみませんが、割り込みが発生したらI2Cで電源管理ICからバッテリー電圧を取得するスケッチです。

M5StickC initializing...OK
Guru Meditation Error: Core  1 panic'ed (Coprocessor exception)
Core 1 register dump:
PC      : 0x400d0ee6  PS      : 0x00060a31  A0      : 0x80080ec1  A1      : 0x3ffbe6b0  
A2      : 0x3ffc024c  A3      : 0x00000001  A4      : 0x00000020  A5      : 0x00000000  
A6      : 0x00000000  A7      : 0x3ffb8058  A8      : 0x800d0ee3  A9      : 0x3ffbe740  
A10     : 0x000010ef  A11     : 0x00000078  A12     : 0x80089b38  A13     : 0x3ffb1ee0  
A14     : 0x00000003  A15     : 0x00000000  SAR     : 0x00000017  EXCCAUSE: 0x00000004  
EXCVADDR: 0x00000000  LBEG    : 0x4000c46c  LEND    : 0x4000c477  LCOUNT  : 0x00000000  
Core 1 was running in ISR context:
EPC1    : 0x400d0ee6  EPC2    : 0x00000000  EPC3    : 0x00000000  EPC4    : 0x40086e7b

Backtrace: 0x400d0ee6:0x3ffbe6b0 0x40080ebe:0x3ffbe770 0x40080ebe:0x3ffbe790 0x40080f41:0x3ffbe7b0 0x40084ddd:0x3ffbe7d0 0x400ecb6b:0x3ffbc570 0x400d7663:0x3ffbc590 0x4008a1a6:0x3ffbc5b0 0x40088a05:0x3ffbc5d0

Rebooting...

上記が実行結果です。パニックを起こしてリブートがかかっています。便利な割り込みですが、ISRの関数からI2Cなどの複雑な処理を実行しようとするとパニックが発生して、リブートします。

Tickerクラスも、直接タイマー割り込みに対して、ユーザーが登録したコールバック関数をISRとして呼び出すと、I2Cアクセスなどで同じようにリブートしてしまうので、一度排他制御を行い、タスクから呼び出しているのだと思います。

割り込みの掟

  • ISRの関数にはIRAM_ATTR属性を必ずつける
  • ISRの関数からアクセスする変数にはvolatile修飾子を必ずつける
  • 必要最低限の処理だけを行う
  • 高度な処理は排他制御を使って保存し、別タスクで実行する
  • 呼び出す関数群にFromISRがついている関数があったらそちらを使う

上記が守るべきことです。特に重要なのが最低限の処理だけを行うことです。割り込み中は他のタスクの動作が中断されているので、とにかく素早く処理を終了させることが重要です。

また、高度な処理や時間がかかる処理については別タスクで実行をします。別タスクに情報を渡すためには、排他処理を利用する必要があります。排他処理にも種類がたくさんあり、種類ごとに特徴が違うので今後何回かにわけて紹介をしたいと思います。

今回は割り込みに関してですが、マルチタスクでも同じように排他処理は重要です。とにかく素早く処理を回すタスクと、外部への通信などのように時間がかかる処理を別タスクに分離し、タスク間のデータ共有のために排他処理を利用します。

FromISRについては、次の通知で説明します。

通知 – TaskNotify

タスクにはTaskNotifyという通知する機能があります。通知以外にも、便利な排他制御の仕組みがあるのですが一番シンプルな通知から説明したいと思います。

#include <M5StickC.h>

TaskHandle_t taskHandle;

void IRAM_ATTR onButton() {
  BaseType_t taskWoken;
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, &taskWoken);
}

void task1(void *pvParameters) {
  uint32_t ulNotifiedValue;
  while (1) {
    xTaskNotifyWait(0, 0, &ulNotifiedValue, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

void setup() {
  M5.begin();

  // Core1の優先度1で通知受信用タスク起動
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    &taskHandle,
    APP_CPU_NUM
  );

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}

void loop() {
  delay(1);
}

割り込みからI2Cにデータ取得してリブートがかかってしまったスケッチの通知利用版です。setup()関数から通知受信用タスクを作成しています。

void IRAM_ATTR onButton() {
  BaseType_t taskWoken;
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, &taskWoken);
}

ボタンを押したときの割り込み関数は上記の処理に変わっています。呼び出している関数にFromISRがついているので、割り込みのISRの関数からの呼び出し用の関数になります。通常はxTaskNotify()関数が別にあります。

ISR関数の特徴として、最後に引数が一つ追加されています。これは、通知した先のタスクがすぐに実行できる場合にはpdTRUEが入っています。通知先のタスクがあるCPUコアで、現在実行中のタスクと通知を受け取るタスクの優先順位を比べた結果みたいです。

通知の場合には、通知を受信するタスクの優先順位が低い場合には、なかなか通知を受信できない可能性があるので注意してください。

void task1(void *pvParameters) {
  uint32_t ulNotifiedValue;
  while (1) {
    xTaskNotifyWait(0, 0, &ulNotifiedValue, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

通知を受信するタスクです。xTaskNotifyWait()関数で通知の受信待ちをしています。引数がいろいろありますが、最後のportMAX_DELAYが重要で通知が来るまでブロックして待ち続ける設定です。

ここにはミリ秒単位で待つ設定ができますので、0を指定すると通知がないと即終了する関数にもなります。portMAX_DELAYの場合には通知が来た場合に、すぐに次の行が実行されるので、即時性が重要な場合には高めの優先順位にしてportMAX_DELAYで受信待ちをするのがよいと思います。

void task1(void *pvParameters) {
  while (1) {
    if (xTaskNotifyWait(0, 0, NULL, pdMS_TO_TICKS(0)) == pdTRUE) {
      Serial.println(M5.Axp.GetBatVoltage());
    }
    delay(1);
  }
}

ノンブロックで処理をする場合には、portMAX_DELAYの変わりに0を設定します。ここはミリ秒ではなくTick数を指定するので、本来はpdMS_TO_TICKS()関数を使って、ミリ秒からTick数に変換するほうが正しいFreeRTOSのお作法です。実際問題ESP32は1Tickが1ミリ秒固定なので、直値を書く場合が多いみたいです。また、0ミリ秒の場合にはどんな環境でも0Tickになるのもあると思います。

通知で受け渡しができる値

通知関数と通知受信待ち関数に引数がありましたが、受け渡しができる値の説明をしたいと思います。とはいえ、基本的には値は受け渡さない方が好ましいと思います。

通知側設定

変数名動作
0eNoAction通知の値を更新しません
1eSetBits通知の値にビットを設定
2eIncrement通知の値をインクリメント
3eSetValueWithOverwrite前回の通知を受け取っていない場合でも上書きします
4eSetValueWithoutOverwrite前回の通知を受け取っていた場合のみ上書きします

このeからはじまる変数みたいなのはFreeRTOSのenum定義です。スケッチ例ではeIncrementを指定しています。この場合通知関数でどんな通知を設定しても、通知を呼び出した回数がカウントアップされて渡されます。その他のオプションもありますが、数値の受け渡しをしたいのであれば通知以外の機能を使ったほうが楽なので、無理に使う必要はありません。

通知受信側設定

2つの数値を引数で設定しています。1つ目が実行前にクリアするビットで、2つ目が実行後にクリアするビットです。この2つの引数でxorすることで該当ビットのデータを落としているのだと思います。

eIncrementで使うのであれば両方変更無しの0で問題ありません。

簡易設定

void IRAM_ATTR onButton() {
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
}

void task1(void *pvParameters) {
  while (1) {
    xTaskNotifyWait(0, 0, NULL, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

引数のところはNULLに設定することで、値の受け渡しを行わなくすることができます。この他にtakeやgiveなどの引数が簡略化されているラッパー関数もありますが、あまり使う必要はないと思います。

通知の欠点

xTaskNotify()関数などで、通知を連続して送信しても1度しか通知は受け取ることができない場合があります。通知に間隔があいていれば複数回受信することも可能ですが、基本的には最後の通知を受信すると思って使ってください。

void IRAM_ATTR onButton() {
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
}

上記のような通知は、5回通知を受信するのではなく最後の通知のみ受信すると思ってください。通知は簡易的な仕組みなので、通知を受け取ったらデータを更新するみたいな用途には適しています。反面、通知を受け取った回数だけ処理をするみたいな場合には取りこぼしが発生する可能性があります。

データの受け渡しも苦手ですので、その場合にはキューなどを利用した方がかんたんに処理が実装できます。

資料

日本語リファレンス

関連ブログ

まとめ

割り込みは非常に難しいですが、便利な機能です。ISRからFromISRなどのない関数を呼んでも動いてしまうことが多いので、気をつけて呼び出す必要があります。

通知などの排他制御も、受信タスクのコアや優先順位を考えて利用をする必要があります。特に無線を使っているときにはPRO_CPUで優先度23のタスクが動いていることを意識して設計をする必要があります。

M5StickCでUIFlow入門 その6 P2PとMQTTとESP-NOWで無線通信

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

前回は加速度計と直接画面にグラフィック描画を行いました。今回は2台のM5StickCを利用しての無線通信を行いたいと思います。

複数のM5StickCが必要なことと、Desktop版では動作しない機能があるので注意してください。

複数台接続時の注意点

Desktop版をWindowsで実行したところ、複数起動ができませんでした。通信をする場合には基本的にはCloud版でネットに接続して行うほうがよさそうです。

UIFlowの複数台接続方法

Cloud版では、ブラウザのタブを複数開くことで、別のM5StickCに接続することができます。プログラムが同じであれば、一つのタブで接続先Api keyを変更することでも対応が可能だと思います。

NetworkのP2P通信(インターネット経由)

高度なブロックの中にある、NetworkのP2P通信を使った通信を最初にやります。最初にいっておくと、この通信はあまり使いやすくありません。

受信側

ラベルを画面に設置して、その画面にP2Pからの受信データを表示します。受信は非常に単純です。データがない場合には「None」と表示されました。

送信側

同じく画面に確認用のラベルを設置しました。送信するデータを固定値にするとシンプルですが、ボタンを押すごとにカウントアップした数値を送るようにしています。

送信先の指定は、Cloud版の接続のときに使うAPIキーを指定して送信します。このブロックで送信するとUIFlowのサーバー経由で対象のM5StickCにデータを送信することができます。

内部的にはMQTTを使っているみたいですが、P2Pブロックは基本的には使わないほうが良さそうです。同じような処理をしたいのであれば、自分でMQTTサーバーを構築したほうがいろいろやりたいことができると思います。

また、このP2Pは送信が完了するまで、処理が停止しますので、ボタンを連打しても送信が終わるまでは反応しませんので注意してください。

Desktop版の注意点

Desktop版の場合には端末がWi-Fiに接続されていませんので、上記のように自分で接続をする必要があります。ただWi-Fiに接続させているのであれば、Cloud版を使ったほうが楽なのでそもそもインターネット通信をするプログラムはDesktop版を使わないほうがよいと思います。

MQTT(インターネット経由)

MQTTとは、Message Queue Telemetry Transportの略で、メッセージをキューイングしてくれるプロトコルです。

MQTTサーバーにメッセージを送信すると、キューイングしてくれて、任意のタイミングで受信することができます。

P2Pとの違いは、自分でMQTTサーバーを準備することで、パソコンなど他のサービスからもメッセージを送受信することができます。

今回は、上記の無料で検証に利用することができるMQTTサーバーを利用させていただきました。このサーバーは検証用途で無料で使うことができます。ただし、パスワードは毎日リセットされていますので、毎日変更する必要があります。

上記がサンプルです。Setupで高度なブロックのMQTTにある「MQTTに接続するブロック」と「通信を開始する」ブロックを設置する必要があります。

「MQTTに接続するブロック」は横幅が非常に長いので、右クリックから「外部入力」を選ぶと上記のように縦に伸びて見やすくなります。クライアントIDは一意な文字列に設定する必要があります。上記のような固定値より、MACアドレスなどの方がよいのですが、UIFlowってMACアドレスとかチップIDとかの定数を提供してくれないので、ちょっと不便です。

さて、このMQTTはちょっと面倒なことがありまして、そのまま実行すると「mqtt need download…」とのエラーがでて動きません。右上のメニューからダンロードを選んで、プログラムをM5StickC内部に転送して、プログラムモードから、アプリモードへ変更して起動する必要があります。

ダウンロードボタンを押すと、自動的に転送と再起動が入るのですが、その後プログラムを更新しようとしても、更新できません。

もう一度プログラムモードに戻りたい場合には、Aボタンを押しながら電源ボタンを押して、モード変更画面に戻ってから、再度Aボタンを押してプログラムモードに戻ってください。

MQTTはトピックに対してメッセージを送信し、別の端末がそのトピックを受信するような動きになります。今回は別のパソコンからメッセージを送信して、M5StickCで受信できたことを確認しました。

MQTT自体は使いやすいプロトコルなのですが、ダウンロードしないと動かないのは結構面倒かもしれません。

ESP-NOW(周辺直接通信)

ESP-NOWが短距離での通信ではおすすめです。こちらはWi-Fiの電波を利用していますが、Wi-Fiアクセスポイントに接続する必要はありません。Desktop版でも非常に使いやすい通信プロトコルです。

ブロードキャスト(周辺全員に送信)

こちらは送受信で同じプログラムの例です。ボタンを押すとカウントアップして、ブロードキャストでカウントを送信します。

受信側は、高度なブロックのESP-NOWの受信ブロックを使って、2つの変数にMACアドレスと、データを受信して、それをラベルに表示するだけの処理です。

こちらはDesktop版でもCloud版でも相互の通信が可能です。無線を直接送受信しているので、非常にかんたんに通信ができます。Wi-Fiの届く範囲ですので、直接見通せる範囲ぐらいまでであれば、相互に通信が可能です。

ただし、無差別に受信するのでこのままだとちょっと使いにくいです。

相手先の指定

あまり変わっていませんが、Setupにペアの追加処理をしています。ESP-NOWはブロードキャストで周辺全員に送信する以外は、事前に相手を登録しておく必要があります。

上のサンプルでは「ff:ff:ff:ff:ff:ff」とブロードキャストのMACアドレスになっていますが、先程のブロードキャストのテストで受信したMACアドレスを入力し、送信時にペアのIDを指定すると、その相手だけに通信が送信されます。

ペアがあらかじめ決まっている場合には、この指定が好ましいと思います。

受信制限

ESP-NOWはブロードキャストで送信されると、関係ない端末の通信も受信していまいます。そのため、送信元MACアドレスで受信するかのフィルタリングを行う必要があります。

if文をたくさん並べる方法もありますが、今回はリストを利用して判定をしてみます。リストとは、複数のデータを入れておく変数です。最初に空のリストを変数に代入して、その後にリストの最後に受信を許可するMACアドレスの文字列を設定します。

上記の場合には2台を追加しました。受信時に「もし」ブロックを追加し、受信したMACアドレスがリストにあるのかを検索します。検索してなかった場合には0になりますので、0以外の場合には、受信するMACアドレスからの通信になります。

このように、少し乱暴ですが送信はブロードキャストで周辺全員に送信して、受信側でフィルタリングするのが楽かもしれません。

リストの作成は、上記のようなリストを作成で、一気に作成する方法もあります。

ESP-NOWのセキュリティについて

UIFlowのESP-NOW通信は平文で送信しています。そのため周りにいる端末が受信した内容を傍受することが可能です。そのためセキュリティ的に問題があるようなデータを送受信するのには適していませんのでご注意ください。

上記を確認したところ、内部的には暗号化の仕組みはありますが、UIFlowのブロックからは設定できないようです。コードで直接指定してから、ブロックに戻ると設定項目が消えてしまうので、現状は使えないと思ったほうがよいです。

まとめ

3種類の通信方式を試しましたが、違いがなんとなく理解できましたでしょうか?

通常の直接通信はESP-NOWと使ったほうが楽だと思います。インターネットとのデータのやり取りはMQTTか、今回は取り上げなかったHTTP通信が適しています。

センサーなどの値をインターネット上にアップしたい場合には、Ambientなどのサービスを利用することをおすすめします。

上記にて、カスタムブロックの読み込み方法から、使い方まで解説があります。

ESP32のFreeRTOS入門 その3 マルチタスク

概要

前回はタスクの作成を説明しました。今回は複数のタスクを動作させるマルチタスクについて説明したいと思います。

マルチタスクとは?

複数のタスクを同時に動作させることです。同時に動作させる方式もいろいろあり、複数のことを同時に実行できるマルチタスクは便利ですが、いろいろ気にしてつかわないとハマりやすいです。

複数のタスクを同時に実行する方法は複数あり、一つがタスクを短い時間実行しては、他のタスクに切り替えながら動かすスレッドという方法と、複数のプロセッサで個別にタスクを動かすマルチコアです。

スレッドとは?

短い時間で実行するタスクを切り替えながら、擬似的に同時に動いているようにするのがスレッドです。1つのプロセッサコアでは、その瞬間に動いているタスクは1つだけです。

FreeRTOSがタスクの切り替えを自動で行ってくれますので、切り替えについてはあまり意識しなくても大丈夫です。

マルチコアとは?

複数のプロセッサコアを搭載することで、1つのコアに対して1つのタスクを動かせるので、コアの数だけタスクを同時実行することができます。

ESP32は通常デュアルコアなので、2つのタスクまでであればスレッドを使わなくても動かすことができます。

スレッドとマルチコアの違い

こちらもFreeRTOSが管理してくれるので、あまり使い方に違いはありません。タスク作成時にCore0(PRO_CPU)とCore1(APP_CPU)を指定したと思いますが、loop()関数はCore1(APP_CPU)で動いているので、Core1(APP_CPU)に新規タスクを作成するとマルチスレッドで動いていることになります。同じようにCore0(PRO_CPU)に新規タスクを作成するとマルチコアになります。

作り方に関しては気にしなくてもいいのですが、使い方については気をつけて使い分ける必要があります。

Core0(PRO_CPU)は無線を利用する場合には、無線関係のタスクが動くことになります。それなりに重い処理になるので、さらに重いタスクを追加しようとすると、無線通信にも影響がでてしまいます。

同じようにCore1(APP_CPU)にたくさんタスクを詰め込んでも、CPUパワーが足りなくなりますので、空いているコアを選んでタスクを作成する必要があります。

また、スレッドの場合、タスクが切り替わりながら実行されるので、厳密にその瞬間に動いているタスクは1つのはずです。そのためグローバル変数などを変更しても、他のタスクから同時に変更されることなどは起こりません。

デュアルコアの場合には、その瞬間に2つのタスクが動いているので、同時にグローバル変数などを変更してしまう可能性があります。

厳密にはスレッドでも、タスクの切り替わる瞬間などにより、意図しない変更などが発生する可能性がありますので、排他制御と呼ばれる処理が必要になります。

排他制御はいろいろな種類があり、数回にわけて今後説明していきたいと思います。

タスクの切り替え時間

FreeRTOSのタスクはすべてスレッドとして、短い時間で切り替えながら動いています。時間を分割するので、タイムスライスとFreeRTOSではよんでいます。

タイムスライスで時間を分割する単位をTickとよびます。このTickは時計の針が動く音などをあらわしています。Tickの間隔なのですが、環境によりことなります。標準的な環境では10ミリ秒です。CPUの速度が遅い場合には10ミリ秒だと、ほとんど処理ができない環境であれば、もっと大きな間隔に変更することができます。

ESP32の1Tickは1ミリ秒が標準でArduino環境の場合には変更できないので、1ミリ秒間隔のタイムスライスで動いていると思ってください。

タスクの基本構造

void loopTask(void *pvParameters)
{
    setup();
    for(;;) {
        if(loopTaskWDTEnabled){
            esp_task_wdt_reset();
        }
        loop();
    }
}

また、loop()関数を見てみます。不要な部分を削ってみます。

void task(void *pvParameters)
{
    for(;;) {
    }
}

上記のような構造になっていますね。このプログラムはfor文を利用した無限ループで終了しません。タスクを作る場合には無限ループにして終了させてはいけません。終了する場合にはタスクを終了させる関数を使う必要があります。

さて、Arduinoのloop()関数はfor文の無限ループですが、while文のループを個人的に使いますんので、例文は以下の形をベースとします。

void task(void *pvParameters)
{
    while (1) {
    }
}

タスクの動作確認

バラバラ出力

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
  }
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );

  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}

void loop() {
}

最初に極端な例をだします。2つのタスク作成していて、50個文字を出力したら改行するを無限ループするタスクです。

ともにCore1のAPP_CPUで、優先度1で動かしています。どのような出力結果になるでしょうか?

1111111111111111111111111111112222222222
2222222222222222222222211111111111111111111
11111111111111222222222222222222222222222
2222221111111111111111111111111111111111122222222222222222222222222222222221
1111111111111111111111111111111112222222222
22222222222222222222222211111111111111111
111111111111111122222222222222222222222222
22222222111111111111111111111111111111111122222222222222222222222222222222222
1111111111111111111111111111111112222222
22222222222222222222222222211111111111111111
1111111111111111122222222222222222222222
2222222222111111111111111111111111111111111
1222222222222222222222222222222222211111111111111111111111111111111111222222
22222222222222222222222222211111111111111

一部抜粋ですが、上記のような実行結果になります。意外だったでしょうか?

ばらばらな文字の出力ですが、出力している途中で別タスクに処理が切り替わっています。

一括出力

void task1(void *pvParameters) {
  while (1) {
    Serial.println("111111111111111111111111111111111111111111111111111111111111111111111111111111");
  }
}
void task2(void *pvParameters) {
  while (1) {
    Serial.println("222222222222222222222222222222222222222222222222222222222222222222222222222222");
  }
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );

  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}

void loop() {
}

今度は、出力部分を1行にまとめてみました。

111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111

今度はきれいに出力されました。さすがにSerial.println()関数の途中で切り替えは発生しないみたいです。

別コア

void task1(void *pvParameters) {
  while (1) {
    Serial.println("111111111111111111111111111111111111111111111111111111111111111111111111111111");
  }
}
void task2(void *pvParameters) {
  while (1) {
    Serial.println("222222222222222222222222222222222222222222222222222222222222222222222222222222");
  }
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );

  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    PRO_CPU_NUM
  );
}

void loop() {
}

さて、次にtask2をCore0のPRO_CPUで動かしてみます。

222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222E (10301) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (10301) task_wdt:  - IDLE0 (CPU 0)
E (10301) task_wdt: Tasks currently running:
E (10301) task_wdt: CPU 0: task2
E (10301) task_wdt: CPU 1: loopTask
E (10301) task_wdt: Aborting.
abort() was called at PC 0x400d359b on core 0

Backtrace: 0x4008b560:0x3ffbe170 0x4008b78d:0x3ffbe190 0x400d359b:0x3ffbe1b0 0x400845ed:0x3ffbe1d0 0x400d1a51:0x3ffb5f50 0x400d0d09:0x3ffb5f70 0x400d0ece:0x3ffb5f90 0x400d0ef5:0x3ffb5fb0 0x400d0ba9:0x3ffb5fd0 0x40088215:0x3ffb5ff0

Rebooting...
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222

すると今度は2しか出力されません。そしてよく見るとエラーが出力されていて、リブートされていました。

エラーを見てみると「core 0」でWatchdogトリガーが発生しています。

ウォッチドッグとは?

システムがハングアップしていなかを監視する仕組みです。無限ループなどにはまっていないかを確認してくれる機能になります。

定期的に生きていますよと報告をして、報告が一定時間なくなると自動的に再起動する仕組みになります。

一定時間(ESP32は3秒)でリセットがかかるので、一般的にはウォッチドッグタイマ(WDT)と呼ばれており、生存報告のことをウォッチドッグタイマをリセットすると表現します。

ウォッチドッグの初期値

コアコア名WDT
Core 0PRO_CPUWDT有効
Core 1APP_CPUWDT無効

ESP32は2つのコアがありますが、loop()関数が動いているAPP_CPUはWDTの初期値が無効になっています。これはloop()関数などで無限ループにして、再起動させちゃう人が多いためかな?

APP_CPUはWDTが有効なので、3秒以上ウォッチドッグタイマをリセットしないと再起動が動いてしまいます。

ウォッチドッグタイマのリセット方法

delay()関数を1以上で呼び出すことでリセットが可能です。vTaskDelay()関数などを呼び出すと書いてあるサイトなどがありますが、ESP32のArduino環境ではdelay()関数のほうが適していると思います。

void delay(uint32_t ms)
{
    vTaskDelay(ms / portTICK_PERIOD_MS);
}

とはいえ、delay()関数の中身はvTaskDelay()関数です。

#define portTICK_PERIOD_MS			( ( TickType_t ) 1000 / configTICK_RATE_HZ )
#define configTICK_RATE_HZ				( CONFIG_FREERTOS_HZ )
#define CONFIG_FREERTOS_HZ 1000

delay()関数にあったportTICK_PERIOD_MSを追っていくと、(1000/1000)なので1です。1Tickが1ミリ秒なので、delay()関数の引数msのままvTaskDelay()関数を呼び出しています。

ちなみにdelay(0)で呼び出すと、vTaskDelay()関数側で処理をしないようになっているので、1以上を指定する必要があります。

この辺の情報はESP32ではなく、ESP8266の情報が混ざっている人が多く、ESP8266とはESP32が結構違うので注意してください。

正しいタスクの最小構成

void task(void *pvParameters)
{
    while (1) {
        delay(1);
    }
}

無限ループにして、ループの最後にdelay(1)を入れるのが正しいです。

ちなみにloop()関数の中身にもdelay(1)を本来は入れたほうがいいです。入れるのと入れないのでは消費電力の違いがでます。APP_CPUなのでWDTは無効ですが、delay(1)がないと、WDTが発動する条件になります。

とくに別タスクを起動して、そちらで処理を行うからいいやとloop()関数を空にしておくと無駄にCPUパワーを無限ループで使われていることになります。

ウォッチドッグタイマの設定

Core 0有効化enableCore0WDT()
Core 0無効化disableCore0WDT()
Core 1有効化enableCore1WDT()
Core 1無効化disableCore1WDT()

あまり使うことのない関数ですが、上記の関数でWDTの状態を変更することができます。

Core0のPRO_CPUで、SDカードなどのファイルデバイスにアクセスしようとした場合などに、時間がかかってWDTが発動してしまう場合などに、一時的に無効にするなどの用途で利用することができます。

基本的には初期設定から変更しないほうがこのましいので、WDTが発動してしまうような処理はなるべくCore1のAPP_CPUで実行するか、処理の途中でdelay(1)を追加するなどの工夫をしてください。

優先度の設定

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
    delay(1);
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
    delay(1);
  }
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );

  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    0,
    NULL,
    APP_CPU_NUM
  );
}

void loop() {
}

さて、タスクの優先度の実験もしたいと思います。タスクにdelay(1)を追加して、task2の優先度を1から0に落としました。

この状態で動かすと、タスク1しか実行されません!

びっくりですね。実はloop()関数も優先度1の無限ループで動いているので、タスク1がdelay(1)で処理をあけわたしても、その分loop()関数がループしてCPUをフル回転させています。そのため優先度が低いタスク2が実行されないのです。

loop()関数にdelay追加

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
    delay(1);
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
    delay(1);
  }
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );

  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    0,
    NULL,
    APP_CPU_NUM
  );
}

void loop() {
  delay(1);
}

これを実行してみると以下の出力になりました。

2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111

タスク2も少ないですが、実行されていますね。ちなみにタスクのdelay(1)だと処理時間が短すぎるので、delay(10)などにするときれいに出力されます。

11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222
11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222
11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222

最低限delay(1)は必要ですが、可能であればなるべく長めのdelay()関数を呼んであげてください。大抵の処理は100ミリ秒とか500ミリ秒間隔の実行でも構わない事が多いと思います。

ワンショットタスク

void task1(void *pvParameters) {
  for(int i = 3 ; i >= 0 ; i--){
    Serial.println(i);
    delay(1000);
  }

  // NULLだと自分自身を削除
  vTaskDelete(NULL);

  // 削除以降の処理は実行されない!
}

void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait

  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}

void loop() {
   delay(1);
}

最後にタスクの終了の仕方です。本来は作成時にタスクハンドルを保存しておき、そのハンドルに対して終了を呼ぶのが正しい動作です。

ただし、タスク内部のループで終了条件を設定して終了することもできます。vTaskDelete()関数にタスクハンドルではなく、NULLを渡した場合には自分自身のタスクを終了することになります。

ためしに、vTaskDelete()関数を消してみると、エラーがでて再起動がかかると思います。

3
2
1
0
E (4104) FreeRTOS: FreeRTOS Task "task1" should not return, Aborting now!
abort() was called at PC 0x40088233 on core 1

Backtrace: 0x4008b560:0x3ffb3fa0 0x4008b78d:0x3ffb3fc0 0x40088233:0x3ffb3fe0

Rebooting...

このようにタスク関数はたんに終了するとエラーがでますので、必ずタスクを削除してから終了してください。

資料

日本語リファレンス

関連ブログ

まとめ

マルチタスクを使う場合には優先度とどちらのコアで動かすのかを考えてから作成しましょう。delay()は必ずいれて、なるべく大きな数字にしたほうが安全です。

次回は割り込みと、排他制御を予定しています。

続編

M5StickC(ESP32)での有線通信方式の選び方

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

前回無線をやったので、有線もまとめてみました。こちらもM5StickCとタイトルに書きましたが、内容的にはすべてESP32に関することです。

ESP32の有線ユニット

無線と違い、特別なユニットはありません。GPIOを使って外部と有線接続を行います。機能によりますが、多くの方式ではどのGPIOでも使えるものが多いです。

接続形態

どのような通信相手と通信をするのかで、接続形態を選ぶ必要があります。

1対1

一番基本的な接続形態です。相手と直接有線で接続しているので比較的安定した通信が可能です。

1対多(親子関係)

1台が親になり、複数台のデバイスなどを子として接続する方式です。親から子を指定して通信をするような接続形態になります。

クライアント(全員子)

親となる端末がない形です。マスターレスなどとよばれることもあります。相手のアドレスなどを指定して通信をする接続形態です。

通信プロトコル

信号線の説明は、必要最低限な信号線の本数のみを記述してあります。基本的には信号線の他にGNDを接続することがあります。通信相手に電源が接続されていない場合には、電源も接続する必要がありますのでご注意ください。

また、有線での通信の場合には接続先との信号線の電圧が同じかを確認する必要があります。ESP32は3.3Vの電圧ですので、相手も3.3Vである必要があります。

Arudino UNOなどは5Vなので注意してください。パソコンなども5Vの場合があります。電圧が違う場合には、レベルシフタや中継用のICなどを利用して、違う電圧のまま接続しない構成を取る必要があります。

UART(1対1)

シリアル通信とよばれるものです。2本の信号線を利用して、テキストデータなどを送信します。送信用の線と、受信用の線をクロスに接続することによって、通信を行います。一方的にデータを送信する場合には、送信側の送信と、受信側の受信を接続することで1本だけの信号線でも通信が可能です。

UARTは比較的かんたんに利用することができますが、送信結果が本当に届いたのかを確認してくれません。一方的に送りつけているので、相手側が受信取りこぼしをしている可能性もあります。

比較的送信したデータが欠落したり、他のデータに変わりやすいので注意して使う必要があります。BluetoothSerialが使えるのであれば、BluetoothSerialの方が細かいことは気にせず、通信が可能です。

RS232CなどはUARTを拡張して、もうすこし信号線を使って相手が確実に受信できるかを確認する機能などが追加されています。

I2C(1対多)

センサーなどを接続するための比較的低速の通信プロトコルです。ESP32側が親となり、センサーなどに対して通信をするような用途が多いです。そのため、通信線は2本ですが、GNDと電源を含めた4本が接続されていることが多いです。

I2Cは複数のセンサーなどを並行に接続していくことが可能ですが、複数つなぎすぎると信号が劣化していきますので注意してください。

信号の劣化はおそらくオシロスコープを使って、波形をみないとわからないので、大量のセンサーを接続したり、長いケーブルを使わないようにしてください。

信号が劣化した場合には、多くの場合にはプルアップ抵抗が低くなりすぎているので、抵抗値の見直しか、I2Cリピーターなどの機材を間にいれて信号を強化してあげてください。

I2Cは親となるマスタと、スレイブで役割がわかれています。センサーなどはすべて子のスレイブとして動作します。

親側(マスタ)

一般的にESP32は親となるマスタとなります。通信するための電圧や、クロックを供給します。

子側(スレイブ)

ESP32同士などをI2Cで接続することも一応可能です。この場合、1台が親となり、それ以外は子となります。その場合、子側はスレイブとして動作させる必要があります。

ただArduinoでESP32を開発する場合には、I2Cのスレイブ側として動かすライブラリは提供されていません。(内部APIは公開されているので自作すれば動く可能性はありそうです)

ESP32同士であれば無線などで通信をするか、他のマイコンとI2C接続する場合にはESP32をマスタとするような接続にする必要があります。

SPI(1対多)

高速で通信をするためのプロトコルです。液晶画面やSDカードなどの転送に利用されています。

信号線は3本なのですが、複数の子が接続した場合に通信相手を選択するための線が必要となります。

子が1台の場合には、子の選択線をGNDに接続することで常に選択されている状態にすることができ、信号線は3本になります。

子が2台の場合には、3本の通信線の他に、2本の選択線を親から子に別々に接続する必要があるので、合計5本が必要になります。(こちらもGNDと電源が追加される可能性があります)

また、デバイスによっては通信線以外にバックライトの調整用の線や、リセット用の線などが必要な子がいまるので、更に追加されます。

親側(マスタ)

一般的にESP32は親となるマスタとなります。通信相手となる子を選択して高速通信を行います。

子側(スレイブ)

ESP32は基本的にはスレイブとして動作することは推奨されていません。ハードウエア的に制限があり、スレイブとして動かした場合に、通信したデータの最後が1バイトから7バイト受信できないことがあります。

また、ArduinoでESP32を開発する場合には、I2Cのスレイブ側として動かすライブラリは提供されていません。(内部APIは公開されているので自作すれば動く可能性はありそうです)

他のマイコンと高速データ通信を行いたい場合には、ESP32をSPIのマスタとするか、SPI以外の通信方法を検討してみてください。たとえば無線通信や、SPI接続のEthernetであれば、ESP32側はSPIのマスタとして動作が可能です。

I2S(1対1)

オーディオデータなどを高速転送するためのプロトコルです。ESP32側がマスタとなり、各種デバイスとデータ通信を行う形となります。

信号線は3本ですが、I2Sだけは利用できるGPIOが一部固定されていまるので注意してください。

外部デバイスの通信はどのGPIOでも可能ですが、直接オーディオ出力などをする場合にはDACが使えるGPIO25かGPIO26を出力に選択する必要があります。

CAN(クライアント)

自動車向けの通信プロトコルで、ノイズにつよく、比較的低速ですが安定して通信をすることが可能です。

利用する場合にはESP32以外にCAN用のトランシーバーデバイスなどが別途必要になります。また、情報が少ないのでCANを使う目的がある人以外は使わないほうがよいと思います。

Ethernet(クライアント)

ESP32は直接Ethernetの有線LANを接続することも可能です。とはいえ、こちらも大量のGPIOと専用チップなどが必要になるので、あまり一般的ではありません。

SPI接続のEthernetモジュールを使う方法などのほうが一般的だと思います。SPI以外にもUARTのシリアル通信からEthernetに変換してくれるデバイスなどもあります。

プロトコルのまとめ

プロトコル対象信号線数備考
UART1対12シリアル通信
I2C1対多2低速でお手軽
SPI1対多3以上高速通信
I2S1対13音声や映像など
CANクライアント2自動車向け
Ethernetクライアントたくさんあまり使わない

まとめ

有線は無線と違って接続したい相手がまずいる事が多いと思いますので、通信方式で迷うことは少ないと思います。

ただし、マスタとスレイブがある通信方式の場合にはESP32をマスタ側にする必要があるので注意してください。

ESP32のFreeRTOS入門 その2 タスクの作成

概要

前回は概要紹介で終わりましたが、今回はプログラムの実行から、タスクの作成まで説明したいと思います。

Arduinoプログラムの構造

Arduinoのスケッチ

void setup() {
}

void loop() {
}

上記のような何も処理しないプログラムで考えてみます。上記を実行するための裏では別のプログラムがあります。

esp32/main.cpp

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h"
#include "Arduino.h"

TaskHandle_t loopTaskHandle = NULL;

#if CONFIG_AUTOSTART_ARDUINO

bool loopTaskWDTEnabled;

void loopTask(void *pvParameters)
{
    setup();
    for(;;) {
        if(loopTaskWDTEnabled){
            esp_task_wdt_reset();
        }
        loop();
    }
}

extern "C" void app_main()
{
    loopTaskWDTEnabled = false;
    initArduino();
    xTaskCreateUniversal(loopTask, "loopTask", 8192, NULL, 1, &loopTaskHandle, CONFIG_ARDUINO_RUNNING_CORE);
}

#endif

上記がArduinoの最初に実行されるコードです。loopTask()関数と、app_main()関数があります。最初に構造だけかんたんに説明してから、後ほど詳しく解説を行います。

app_main()関数

まず、一般的なC言語ではmain()関数から実行されますが、ESP-IDFでは初期化処理が行われたあとに、app_main()関数が呼び出されます。

app_main()関数では、loopTaskWDTEnabled変数を設定しています。これはWDT(WatchDog Timer)の略で、ウォッチドッグタイマーに関連するフラグです。詳細は後ほど解説します。

次に、initArduino()関数を呼び出しています。

上記に定義がありますが、Arduino系の初期化を行っていました。OTAまわりの動作や、PSRAMなどが搭載されている場合には、こちらで初期化されます。

次にxTaskCreateUniversal()関数で、loopTaskというタスクを作成しています。この関数についてもあとで解説したいと思います。

loopTask()関数

この関数が実際の処理をする関数になります。最初にsetup()関数を呼び出しています。この関数はArduinoのスケッチにあるsetup()関数です。

その後にfor文の無限ループがあり、loopTaskWDTEnabledがtrueの場合にesp_task_wdt_reset()を呼び出しています。ここの処理は後ほど解説しますが、ウォッチドッグタイマのリセットをしています。

次にloop()関数を呼び出しています。この関数はArduinoのスケッチにあるloop()関数ですね。この関数はfor文の無限ループの中にあるので、何度も呼び出されます。

FreeRTOSの命名規則

		#define xTaskGenericCreate				MPU_xTaskGenericCreate
		#define vTaskAllocateMPURegions			MPU_vTaskAllocateMPURegions
		#define vTaskDelete						MPU_vTaskDelete
		#define vTaskDelayUntil					MPU_vTaskDelayUntil
		#define vTaskDelay						MPU_vTaskDelay
		#define uxTaskPriorityGet				MPU_uxTaskPriorityGet
		#define vTaskPrioritySet				MPU_vTaskPrioritySet
		#define eTaskGetState					MPU_eTaskGetState
		#define vTaskSuspend					MPU_vTaskSuspend
		#define vTaskResume						MPU_vTaskResume
		#define vTaskSuspendAll					MPU_vTaskSuspendAll
		#define xTaskResumeAll					MPU_xTaskResumeAll
		#define xTaskGetTickCount				MPU_xTaskGetTickCount
		#define uxTaskGetNumberOfTasks			MPU_uxTaskGetNumberOfTasks
		#define vTaskList						MPU_vTaskList
		#define vTaskGetRunTimeStats			MPU_vTaskGetRunTimeStats
		#define vTaskSetApplicationTaskTag		MPU_vTaskSetApplicationTaskTag
		#define xTaskGetApplicationTaskTag		MPU_xTaskGetApplicationTaskTag
		#define xTaskCallApplicationTaskHook	MPU_xTaskCallApplicationTaskHook
		#define uxTaskGetStackHighWaterMark		MPU_uxTaskGetStackHighWaterMark
		#define xTaskGetCurrentTaskHandle		MPU_xTaskGetCurrentTaskHandle
		#define xTaskGetSchedulerState			MPU_xTaskGetSchedulerState
		#define xTaskGetIdleTaskHandle			MPU_xTaskGetIdleTaskHandle
		#define uxTaskGetSystemState			MPU_uxTaskGetSystemState

上記は一部ですが、xとかvなどの小文字ではじまっているものは、FreeRTOSの命名規則の関数になります。

タスク作成

xTaskCreateUniversal()関数でタスクの作成を行っていました。xからはじまっているので、FreeRTOSの関数になります。(厳密にはESP32で拡張されたFreeRTOSの関数だと思います)

ESP32には3つのタスク作成関数があります。

  • xTaskCreate() – シングルコア向けタスク作成
  • xTaskCreatePinnedToCore() – コア指定タスク作成
  • xTaskCreateUniversal() – 万能ラッピングタスク作成

どう使い分けるかというと、xTaskCreateUniversal()だけ使えば大丈夫です。最初はシングルコア向けのxTaskCreate()だけしかなくデュアルコアで使えず、その後にコア指定ができて、最後に万能版ができています。

xTaskCreateUniversal()は変なコアを指定しても、環境に応じて正しいコアを指定しなおしてくれます。シングルコアのESP32-SOLO-1の場合はCore0以外を指定してもCore0として作成します。デュアルコアのESP32の場合にはCore0とCore1以外を指定した場合でもCore0で作成してくれるラッピング関数です。

BaseType_t xTaskCreateUniversal( TaskFunction_t pxTaskCode,
                        const char * const pcName,
                        const uint32_t usStackDepth,
                        void * const pvParameters,
                        UBaseType_t uxPriority,
                        TaskHandle_t * const pxCreatedTask,
                        const BaseType_t xCoreID ){
#ifndef CONFIG_FREERTOS_UNICORE
    if(xCoreID >= 0 && xCoreID < 2) {
        return xTaskCreatePinnedToCore(pxTaskCode, pcName, usStackDepth, pvParameters, uxPriority, pxCreatedTask, xCoreID);
    } else {
#endif
    return xTaskCreate(pxTaskCode, pcName, usStackDepth, pvParameters, uxPriority, pxCreatedTask);
#ifndef CONFIG_FREERTOS_UNICORE
    }
#endif
}

上記のようなコードになっています。シングルコアの場合には常にxTaskCreate()関数が呼ばれています。

xTaskCreateUniversal()関数は比較的最近できたため、他のサイトやスケッチ例などを見ると、xTaskCreatePinnedToCore()関数を利用している例が多いですが、基本的にはxTaskCreateUniversal()関数を利用してください。

xTaskCreateUniversal()関数

引数

引数型引数名備考
TaskFunction_tpxTaskCode作成するタスク関数
const char * constpcName表示用タスク名
const uint32_tusStackDepthスタックメモリ量
void * constpvParameters起動パラメータ
UBaseType_tuxPriority優先度
TaskHandle_t * constpxCreatedTaskタスクハンドル
const BaseType_txCoreID実行するコア

pxTaskCode : 作成するタスク関数

作成されて実行されるタスクの関数を指定します。ここはわかりやすいと思います。

pcName : 表示用タスク名

ここがわかりにくいのですが、タスク名はあまり意味がありません。タスク一覧を取得した場合に表示される文字列です。同じタスク名のタスクが複数あっても問題ありません。

usStackDepth : スタックメモリ量

ここのパラメータが一番むずかしいです。タスクに割り当てるスタックメモリの大きさになります。少なすぎるとエラーでプログラムが止まります。多すぎるとメモリが無駄になります。

基本的には少し多めに割り当てるのが安全だと思いますが、どれぐらい割り当てればいいのかの目安がありません。loopTaskの設定値は8192ですので、この数値を基準として、足りなくなったら数値を増やすようにしてください。

pvParameters : 起動パラメータ

作成したタスクに渡される起動パラメータです。あまり使うことはありませんのでNULLが指定されることが多いです。複数のGPIOを対象にして、複数のタスクを起動する場合などに、タスク関数自体は共通ですが、起動パラメータで対応するGPIOを指定するなどが可能です。

uxPriority : 優先度

優先度も指定が難しいパラメータになります。優先度は0が一番低く、configMAX_PRIORITIES – 1が一番高くなります。

void setup() {
    Serial.begin(115200);
    delay(50);
    Serial.println(configMAX_PRIORITIES);
}

void loop() {
}

上記で試したところ、25となりましたので、0から24までが指定できます。

/* This has impact on speed of search for highest priority */
#ifdef SMALL_TEST
#define configMAX_PRIORITIES			( 7 )
#else
#define configMAX_PRIORITIES			( 25 )
#endif

宣言部を見たところ、7と25の環境があるみたいですが、普通に使っている分にはおそらく25だと思います。

基本的には自由な値にしてもらって構いません。ArduinoのloopTaskのように標準的なタスクは1にして、それより低い優先度のタスクは0に、優先度が高いものは2でも構いません。

ただし、ESP32のライブラリの中で作成されるタスクで優先度が高いものは23で作られています。他のどんな処理よりも優先されるべきタスクは最高の優先度である24で作る必要があります。

優先度についてはマルチタスクの説明の際に、再度詳しく解説をする予定です。

pxCreatedTask : タスクハンドル

作成したタスクを管理するハンドルです。この変数を利用してタスクを停止したり、優先度を変更したりすることができます。

xCoreID : 実行するコア

コア番号定義
0PRO_CPU_NUM
1APP_CPU_NUM
シングルコア=0
デュアルコア=1
CONFIG_ARDUINO_RUNNING_CORE

こちらも詳細はマルチタスクで説明しますが、実行するコアを指定します。特徴的なのがCONFIG_ARDUINO_RUNNING_COREという定義があり、基本的にはこれか、数値の0が指定されている場合が多いです。

CONFIG_ARDUINO_RUNNING_COREは通常APP_CPU_NUMの1になっています。シングルコアの特殊なESP32の場合には0です。

xTaskCreateUniversal()は無効なコアを指定しても、いい感じに補正してくれるので、アプリと同じコアはAPP_CPU_NUM、バックグラウンドで動かすのはPRO_CPU_NUMという指定でも問題ないと思います。

無線系の処理がPRO_CPU_NUMで動いていますので、無線を利用している場合にはPRO_CPU_NUMであまりタスクを実行しないほうが好ましいです。

逆に無線を利用していない場合には、PRO_CPU_NUMのコアはあまり利用されていないので、積極的に使ったほうがいいと思います。

戻り値 – BaseType_t

状態
成功pdPASS
失敗pdPASS以外

この戻り値のBaseType_t型はわかりにくいのですが、中身をみると単なるint型ですので型名には意味がありません。ちなみにportという接頭語もFreeRTOSの命名規則になります。

#define portBASE_TYPE	int

typedef portSTACK_TYPE			StackType_t;
typedef portBASE_TYPE			BaseType_t;
typedef unsigned portBASE_TYPE	UBaseType_t;

さて成功するとpdPASSが返却され、それ以外の場合にはエラーとなっています。

#define pdFALSE			( ( BaseType_t ) 0 )
#define pdTRUE			( ( BaseType_t ) 1 )

#define pdPASS			( pdTRUE )
#define pdFAIL			( pdFALSE )
#define errQUEUE_EMPTY	( ( BaseType_t ) 0 )
#define errQUEUE_FULL	( ( BaseType_t ) 0 )

/* Error definitions. */
#define errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY	( -1 )
#define errQUEUE_BLOCKED						( -4 )
#define errQUEUE_YIELD							( -5 )

こんな感じの定義になっていました。タスク作成の戻り値を見ているスケッチはあまり存在していませんが、本当は確認したほうがいいと思います。

基本的にはパラメーターがおかしいか、スタックメモリ量が大きすぎてメモリ割り当てができなかった場合になると思います。

loopTask作成を見直してみる

xTaskCreateUniversal(
    loopTask,                    // 作成するタスク関数
    "loopTask",                  // 表示用タスク名
    8192,                        // スタックメモリ量
    NULL,                        // 起動パラメータ
    1,                           // 優先度
    &loopTaskHandle,             // タスクハンドル
    CONFIG_ARDUINO_RUNNING_CORE  // 実行するコア
    );

コメントを追加していますが、上記のパラメータで作成しています。この値を基本的な設定値とし、loopTaskより大きいのか小さいのかで、調整をするとよいと思います。

上記で重要なのは、実行するコアがCONFIG_ARDUINO_RUNNING_COREが選択されていることです。つまり、setup()関数と、loop()関数はAPP_CPUであるCore1で実行されています。

タスクは常にどちらのコアで実行されているのかを意識しながら使ってください。

資料

日本語リファレンス

まとめ

タスクの作成までですが、なんとか終わりました。タスク周りはいろいろハマりどころがあるので、複数回にわけて解説をしていきたいと思います。

続編

M5StickCでUIFlow入門 その5 加速度計とグラフィック

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

前回はif文のみで終わってしまいましたが、今回は加速度計のグラフ表示を行いたいと思います。

加速度計とは?

単位時間あたりの速度の変化をあらわします。といっても、よくわからないと思うのですが、M5StickCが動いた方向への加速と減速を計測した数値です。

どの方向に動いたかを、X軸、Y軸、Z軸であらわします。M5StickCを縦に持った場合に上下方向がY軸、左右方向がX軸、前後方向がZ軸になります。

これはあとで動かしながら試してみるとわかりやすいです。

加速度

どれだけ速度が変化したかを加速度と表現し、単位にはG(ジー)を使います。1Gは物を自然落下させた場合の加速度になります。落下する速度よりも加速が遅いと1G以下の数字で、加速が早いと1G以上の数字になります。

速度の変化ですので、速度がいくら早くても加速も減速もしていない場合には0Gになります。左右への加速度であるX軸では、右側に加速する場合にはプラスの加速度。左側に加速する場合にはマイナスの加速度になります。

注意しないといけない点として、加速度計は重力の影響をつねに受けています。常に地面に向かって重力が発生し、下向きに1Gの加速度が発生しています。

そのため、M5StickCを縦に持ったときに下向きに発生しているので、何も動かしていなくてもY軸は-1Gになります。

M5StickCを右側に倒した場合、M5StickCのX軸でいうと右側のプラス方面が地面になるので、なにも動かしていなくてもX軸は1Gになります。

つまり、動かしていなくても地面に向かって常に1Gが発生してしまうので気をつけてください。中途半端に傾けた場合には、いろいろな軸に1Gが分散して発生します。

グラフィック制御

M5StickCは横80ドット、縦160ドットの画面があります。M5StickCを縦に使った場合には、左上の座標が(x=0, y=0)になり、右上の座標が(x=79, y=0)、左下の座標が(x=0, y=159)、右下の座標が(x=79, y=159)になります。

座標は0からはじまるので注意してください。

加速度をグラフに描いてみる

ざっくりとX軸の加速度をグラフに描いてみたいと思います。左右にM5StickCを揺らした場合に、左右に揺れるようなグラフにします。

まず加速度ですが、ジャットコースターでの加速度でも3Gから5Gです。そのため、通常は-5Gから+5Gぐらいまでの範囲が測定できれば問題ありません。

M5StickCを縦に使った場合、横が80ドットですので、左右に40ドットずつと考えると、1Gあたらい8ドットで、5Gの場合40ドットとなります。

停止しているときには、X軸の加速度が0Gで、グラフが中心に来ますのでx=40になります。つまり、X軸の加速度がマイナスの場合には、x座標が40より小さくなり、X軸の加速度がプラスの場合には40より大きくなるように計算をしてグラフを描画していきます。

ちょっとわかりにくいですが、上記のプログラムになります。Y座標の保存ように変数「drawY」を定義して、最初に0をセットしています。

そのあとにずっとで、加速度を取得して8倍したあとに整数に変換してから40を足したX座標とdrawYのY座標に対して、白色で点を描画しています。

その後drawYを1増やして、グラフの描画を下にずらします。最後にこのままだと動作が早すぎるので、20ミリ秒停止をしています。

さて、動かしてみてください。動いたらM5StickCを横に動かしてみてください。

上記のように動かしたときに加速度が変化しましたでしょうか?

ただ、グラフが一番下に到達したあとに、動きが止まってしまいましたね。これを次で修正したいと思います。

グラフのリセット

もしの処理を追加しました。Y座標は159までですので、160以上になったらリセットします。リセットの処理は画面をクリアしてから、Y座標を0に戻します。

これで、一番したまでグラフが到達したら、画面がクリアして、一番上にグラフが戻ると思います。

さて、この状態で左右に思いっきり振ってみてください。ある一定以上のGには到達できないと思います。画面の端までいくと5Gですが、おそらく半分以下のところまでしかいかないと思います。どうやらUIFlowの加速度センサーは-2Gから+2Gまでの範囲しか測定できないようです。

倍率の見直し

2Gまでですので、40ドットで割ると1Gあたり20ドットです。現在8倍しているところを20倍に変更する必要があります。

また、ピクセルのところで計算をしていますが、ちょっとわかりにくいので変数に切り出してみます。

あたらしく「drawX」変数を作って、そこに代入するときに計算をしています。変数にする必要はないのですが、複雑な計算をした場合には、一度変数に代入をしておくと、画面にその数値をテストで表示することもできるので、便利です。

これで動かしてみると、思いっきり振った場合に画面端っこまでグラフが描画されてます。さて、この状態だとグラフの点がバラけて、きれいなグラフにならないと思います。

描画速度が遅いことが原因なので、20ミリ秒の停止をもっと小さい数値に変更します。

高速化

最後の停止時間だけ変更してあります。10ミリになったので、先程の倍の速度でグラフが描画されると思います。0とか5ミリ秒にしてみるともっときれいにグラフが描画されるはずですが、すぐに画面から消えてしまいます。

この段階で、M5StickCを左右に倒してみてください。そうすると動かしていないのに、グラフの線が左右のどちらかに移動したと思います。

完全に横に倒すと重力で1Gが地面の方向に追加されるのがわかると思います。

応用

一気に複雑になっていますが、タイマーを使った例です。最初に「タイマーが呼び出されたとき」ブロックを設置して、呼び出された場合にはLEDを消す処理が入っています。

「ずっと」の処理の先頭で、「もし」ブロックが追加されています。条件値としてはX軸の加速度の絶対値が1.5以上の場合です。絶対値はマイナスの値でもプラスに変換します。そのため1.5G以上の加速度の変化があると処理を行います。

処理はLEDをつけてから、タイマーを500ミリ秒でワンショットで呼び出します。これは500ミリ秒後に1度だけタイマー処理を呼び出すという意味になります。

これで、1.5G以上の衝撃などがあると、LEDが500ミリ秒点灯するプログラムになりました。できあがったプログラムは以下のアップしておきました。

タイマーを使わないで、LEDをONにしてから500ミリ秒停止して、LEDをOFFにするような処理でも構いませんが、その場合にはLEDがONの場合にグラフ描画が止まってしまいます。

まとめ

今回はX軸の加速度でしたが、他の軸に変更して試してみてください。また、1.5G以上でLEDをONにしていましたが、M5StickCを横に倒した状態で動かすと、より少ない動きでもLEDがONになります。

これは横に倒したことで、重力の1GがX軸に追加されているため、0.5Gの加速度で反応してしまうようになっています。

このように加速度は重力の影響を受けるのでちょっと使いにくいところもあります。前回との差分や、移動平均などを使って加速度の検知を調整することもできますが、今回はそこまではやりません。

ESP32のFreeRTOS入門 その1 FreeRTOSとは?

概要

ESP32を深く触っていくと、FreeRTOSにいきつきます。しかしながら情報が少ないので調べてみました。Arduino IDE環境でのFreeRTOSについてを対象とします。

FreeRTOSとは?

RTOS(Real Time Operating System)はリアルタイムOSで、組み込み系によく使わえているOSです。特徴としては、リアルタイムとついているので、厳密な時間管理ができます。リアルタイムでないOSというと、LinuxやWindowsなどのOSがありますが、重い処理をするとパソコンが固まることがあると思います。リアルタイムOSは複雑なことはできませんが、処理が途切れることなく実行できるようなOSになります。

FreeRTOSはRTOSの一種で、Freeとついているので無料で使えるものになります。FreeRTOS以外にも無料で使えるものや、有料のRTOSなどがあります。

FreeRTOSは現在Amazonが権利を保持しており、オープンソースとして公開されています。ESP32以外にも複数の環境をサポートしています。

FreeRTOSの情報

書籍

上記をみて貰えればわかるのですが、ほとんどありません。英語であれば多少でているのですが、この分野はもう日本語書籍は手に入らないのかもしれませんね。

RTOS関連は何冊かありますが、CPUの作り方とかOSの作り方系の本の方が多いですね。

特に組み込み系はノウハウが仕事に直結するので、あまり書籍がでない分野です。大学の先生とかでもESP32を使ってプロダクトを作っていても、OSそのものを研究している人はあまりいないのかもしれません。

公式ドキュメント

上記に「Mastering the FreeRTOS Real Time Kernel – a Hands On Tutorial Guide」と「FreeRTOS V10.0.0 Reference Manual」があります。

ただし英文なので、いつかは目を通したいですが、最初の一歩にはおすすめしません。

Amazonドキュメント

AmazonでもFreeRTOSの情報を提供しています。ただしAmazonのFreeRTOSは2つの意味があるので注意してください。1つ目はOSとしてのFreeRTOSでカーネルと表現されています。2つ目はAWSのサービスとしてのFreeRTOSで、単にFreeRTOS書いてある場合にはこちらになります。

Amazon FreeRTOSはIoTデバイスとして、AWS上にセンサーなどの情報をアップしたりするサービスです。「Amazon FreeRTOS ユーザーガイド」はAWSのサービスを使うためのガイドなので気をつけてください。

上記の「FreeRTOSカーネル開発者ガイド」を見る必要があります。このページがどこからリンクされているのだろう?

この資料はPDFでもダウンロードが可能ですし、日本語資料だとこれ以上のものはいまはないとは思います。

進め方

基本的にはESP32のFreeRTOS入門なので、素のFreeRTOSではなく、ESP32向けに改造されているFreeRTOSについての解説になります。

そのため、ソースコードなどは「espressif/arduino-esp32」か「espressif/esp-idf」のものを参照します。

関数などもFreeRTOSを直接呼び出す必要がない場合には、Arduino側の関数を呼び出します。

Arduino core for the ESP32の基礎知識

Arduino IDEで開発するESP32環境は、ベースとしてESP-IDF(Espressif IoT Development Framework)があります。

このESP-IDFはFreeRTOSが組み込まれており、ESP32のハードウエアへのAPIなども提供されています。Arduino coreでは、ESP-IDFをさらにラップしてArduinoと同じ関数群を実装しています。

そのため、Arduinoの提供していないようなESP32独自の機能については、ESP-IDFのAPIを直接呼び出す必要がでてきます。

ESP32のハードウエア構成

ブロックダイアグラム

よく見るですが、なかなか理解しにくい図です。機能の一覧なので、必要になったところで見ていけばよいと思います。

真ん中の「Core and memory」のところが重要で「2(or 1) x Xtensa」という記述があります。これがCPUの数で、通常は2ですが、1つしかないESP32もあります。ESP32-SOLO-1とかESP32-S2はシングルコアで、それ以外は2つあるジュアルコアになります。

シングルコアのESP32はまだ少数派ですので、一般的なデュアルコアのESP32を対象としたいきます。

System Reset

もう一つをのせておきます。ざっくりとESP32全体をSystemと呼び、CoreとRTCに機能がわかれています。

Core側に基本的な機能が実装されており、コプロセッサー的な役割でRTCがあります。RTC側に関してはULPプログラムをする時以外にはあまり意識しなくても問題ありません。

メモリマップ

こちらのも重要ですが、必要になってから学べばよいと思います。

System Structure

Core側のですが、CPUコアが2つあって、メモリや周辺機器を共有しているとのことですが、ここで見てもらいたいのはCPUコアの名前です。

番号コア名
Core0PRO_CPU
Core1APP_CPU

上記のような対応になっています。2つのコアのうち、最初のコアをPRO_CPUと呼び、次のコアをAPP_CPUと呼びます。

コアは0からカウントするので上記の表の対応になります。Arduinoでは基本的にはCoreの番号で管理していて、データシートやESP-IDFのドキュメントではコア名で記述されています。

#define PRO_CPU_NUM (0)
#define APP_CPU_NUM (1)

ソースを検索したところsoc.hにて定義されています。しかしArduinoの場合にはこの定義ではなく、直接0とか1を指定している場合が多いようです。

コアの役割

2つのコアはどのような用途につかっても構わないのですが、標準的な使い方はコアの名前の通りで、Core0のPRO_CPUはバックグラウンド系の処理、Core1のAPP_CPUはアプリのメイン処理を動かすことが多いようです。

Arduinoの場合にはsetup()やloop()関数はCore1のAPP_CPUで動いており、無線関係の処理はCore0のPRO_CPUで動いています。

まとめ

概要だけで終わってしまいましたが、次回はArduinoのmain関数まわりからタスクの説明をしたいと思います。

続編

M5StickC(ESP32)での無線通信方式の選び方

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

ESP32の通信方式をどう選べばいいのかをかんたんにまとめてみました。M5StickCとタイトルに書きましたが、内容的にはすべてESP32に関することです。

ESP32の無線ユニット

ESP32は2.4GHz帯の通信ユニットしか搭載していません。しかし2.4GHzは人が多いところなどで使うと、なかなか通信ができませんので注意してください。

Wi-Fi無線LAN(2.4G)

ESP32のWi-Fiは2.4GHz帯のみですので注意してください。5GHzのWi-Fi無線LANを使うことはできません。

Bluetooth(2.4G)

Wi-Fiの他にBluetoothも搭載しています。

接続形態

どのような通信相手と通信をするのかで、接続形態を選ぶ必要があります。

1対1

多くの場合、通信相手と事前にペアリングをしておき、接続した2台で通信を行う方式です。事前にペアリングをしておく手間がかかりますが、一度接続すれば安定して通信が可能です。ただし、ペアリング操作をする場合に、周りに人が多いと失敗することが多いです。

1対多(親子関係)

一台が親となり、他の端末が親に接続する子になる通信方法です。親のみインターネットに接続されており、子のセンサー情報などを親に集める場合などに利用されています。

クライアント(全員子)

Wi-Fiアクセスポイントなどに接続し、一般的な無線LAN的に利用する方式です。無線LANが存在する家庭内などでは一般的な通信方式となります。電波状況の悪いところの場合には、通信が不安定になりやすいです。

見通し通信

トランシーバーなどのように、電波が届く範囲に送信をします。受信側は電波が届いたときに相手先を見て、不要は通信は無視します。原始的な通信方式ですが、単純な処理のため電波状況が悪い場合でも通信ができる可能性があります。

メッシュ

複数の端末間で見通し通信を行い、受信したデータが自分宛てでない場合には、再送することで直接通信できない距離の端末間でも通信することが可能です。

無線LANが使えない屋外の環境などで使われることがありますが、それほど一般的ではありません。

通信プロトコル

代表的な通信プロトコルを紹介します。Wi-FiかBluetoothかはあまり意識する必要はありません。ただしBluetoothは見通し範囲での通信しかできません。Wi-FiはWi-Fiアクセスポイントなどを利用すれば、インターネットなどの見通し外通信も可能です。

BluetoothSerial(Bluetooth)

Bluetoothを利用して、Serialを行います。1対1でしか通信ができませんが、一番かんたんに文字列などのデータを送受信することが可能です。1対1ですので通信待ちをする子と、接続をする親の役割があります。

接続待ち側(子)

ESP32で待受をして、パソコンから接続している例です。パソコンと通信するのであれば一番かんたんな通信方式です。

パソコンなどからBluetoothの追加をすることで、通常のシリアルポートとして通信をすることができるようになります。

接続側(親)

他の接続待ちしている子に対して、接続する親側のサンプルです。ESP32同士で通信をする場合などに利用します。あまり一般的な用途ではありませんが、文字を無線で通信するのは非常にかんたんに利用できます。

接続先のMACアドレスが接続の際に必要となりますので、そのMACアドレスをどう設定するのかは用途に応じて考える必要があります。

Bluetooth Low Energy(Bluetooth)

BLEとも表記される通信方式です。Bluetoothと通常書くとこの方式です。しかしながら、なかなか難しいプロトコルで、あまりおすすめしません。

こちらも1対1ですので通信待ちをする子と、接続をする親の役割があります。

接続待ち側(子)

ESP32で待受をして、パソコンから接続している例です。ESP32側はキーボードやマウス、MIDIなどさまざまなデバイスとして待受をすることができます。

パソコンなどからBluetoothの追加をすると、ESP32で待ち受けているデバイスとして認識されます。比較的メジャーなデバイスとして待受をするのであれば情報があるので、作りやすいです。

接続側(親)

他の接続待ちしている子に対して、接続する親側のサンプルです。ESP32同士でも接続は可能ですが、他のプロトコルのほうが楽に通信できることが多いと思います。

この方式の場合には、子がどのような動作をするのかが重要で、世の中にはいろいろなデバイスがあり、結構動きが違うので検証しながら動かす必要があります。そして資料がなかなかないデバイスがあるので苦労します。

Bluetoothメッシュ(Bluetooth)

BLEでそのように動くプロトコルを自分で実装することで可能ですが、ESP32では一般的ではありません。

Wi-Fi無線LAN(Wi-Fi)

一般的なWi-Fi無線LANを利用した通信です。情報も比較的多いので使いやすいです。Wi-Fiの場合にはWi-Fiアクセスポイントに接続する必要があります。ESP32自体をWi-Fiアクセスポイントにすることも可能です。

Wi-Fiアクセスポイントモード(APモード)

softAPという機能をつかって、Wi-Fiアクセスポイントとの機能を提供できます。softAPを提供している場合には、Webサーバー機能も提供している場合が多いですが、機能的には別のものですので、個別に必要な機能のみを提供できます。

Wi-Fiステーションモード(STAモード)

Wi-Fiアクセスポイントに接続するステーションモードです。一番基本的なモードです。

Wi-Fi混合モード(AP STAモード)

Wi-FiアクセスポイントモードとWi-Fiステーションモードは同時に動かすことができます。あまりよい例はなかったので、wifi_ap_staで検索してみてください。

接続するルーターなどのWi-Fiアクセスポイントの情報を、ESP32のソフトアクセスポイント経由で設定するなどの用途が多いです。

Wi-Fiアクセスポイントの設定方法は上記にまとまっているので、こちらも参考にしてください。

ESP-NOW(Wi-Fi)

ESP-NOWはESP32やESP8266などで利用できる、独自プロトコルの通信方式です。特徴としてはBluetoothのように事前にペアリングする必要がなく、Wi-Fi無線LANのようにアクセスポイントも必要ありません。

相手のMACアドレスを指定して、直接データを送信します。見通し通信で、近くにいる範囲内で通信をするのは非常にかんたんに利用が可能です。

ただし、小さいデータ向けの通信方式で、データが届くかの保証もないので、Wi-Fi無線LANなどのほうが高速通信には向いています。

親子関係は特になく、相手のMACアドレスを指定して送信するか、まわり全員に送信するかのみになります。自分宛ての通信はすべて受信しますので、不要なデータも受信してしまうので送信元を見てから処理をする必要があります。

ESP-Mesh(Wi-Fi)

こちらもESP系のみの独自プロトコルです。複数台のESPでメッシュネットワークをかんたんに構築することができますが、あまり普及していません。通常はESP-NOWかWi-Fi無線LANを利用したほうがよいと思います。

プロトコルのまとめ

プロトコル役割難易度備考
BluetoothSerialパソコンとの接続はこれ
BluetoothSerialあまり使わない
BLEやりたいことありき
BLE相手次第では地獄
Wi-Fi無線LANAP常用はおすすめしません
Wi-Fi無線LANSTA一般的な方法
ESP-NOWかなりお手軽

用途別おすすめ

パソコンとかんたんに通信したい

BluetoothSerialでESP32側が待受です。非常にかんたんに接続することができます。送受信はテキストになるので、数字などもprintfなどで送信して、受信時にもパースする必要があります。

ESP32同士でかんたんに通信したい

ESP-NOWをおすすめします。小さいデータしか送信できませんが、かんたんに送受信ができます。

インターネット接続したい

Wi-Fiステーションモードで、ちゃんとしたWi-Fiルーターのアクセスポイントに接続してください。

Wi-Fiアクセスポイントがないところで通信したい

ESP-NOWがおすすめです。Wi-Fi無線LANのAPモードは、あまり通信が安定しない気がしますので、ちょっとした設定ぐらいであれば問題がありませんが、安定しての通信をしたいのであれば安くても専用のWi-Fiルーターなどを準備したほうがおすすめです。

注意点

電波状況の悪い場所の場合

人が多い場所などは電波状況が悪くなりやすくなります。50人以上がいるような場所だとかなり接続に時間がかかったり、失敗するようになりやすいです。

BLEなどの新規ペアリングも周りに人数が多いと、ずらりと表示されるのでペアリング先を見つけるまで大変だったり、なかなかペアリングしません。

展示会や人が集まるセミナーなどで利用する場合には、事前にペアリングまで済ませておくと、比較的接続がしやすくなります。

Wi-Fi接続も、アクセスポイントへの接続が失敗しやすくなりますので注意してください。

Wi-Fi無線LANもBluetoothSerialもBLEも、電波状況が悪いと使えないぐらいのイメージでいたほうがいいです。ESP-NOWは比較的電波状況が悪くても近距離だと通信ができるイメージです。

根本的にはWi-FiやBluetoothで使っている2.4GHzの周波数は混雑してしまうので、920MHzなどの他の周波数帯の無線ユニットやLTEなどを利用したほうが安全です。

電子レンジに注意

電子レンジが利用しているのが2.4GHzです。Wi-FiやBluetoothで使っている周波数帯と同じなのですが、これはそもそも電子レンジが2.4GHzに対してノイズを出しているので、他の用途に使えない事情がありました。

その後、全世界であまり使われていない周波数帯域としてWi-Fiなどでも使うようになりました。そのため電子レンジが近くで使われると、その影響で通信ができなくなります。

電子レンジが近くにある環境で通信する場合には、利用できない時間帯があることを前提に構築したほうが安全です。

まとめ

ESP32の無線はパソコンやスマホなどと比べると弱いので注意してください。特に人が多いところだとなかなか使えないので、展示会や発表会などたくさんの人に見てほしい環境では動かなくなるのでハマりやすいです。

LTE M1/NB1がもう少し安く提供されるようになればモジュールも安いので使いやすいのですが、いまはちょっと通信すると通信料が普通のLTEより高くなるので使いにくいです。

続編

M5StickCのdefine宣言を調べる

現時点の情報です。最新情報はM5StickC非公式日本語リファレンスを確認してみてください。

概要

M5StickCをArduino IDEで開発する場合に、最初から定義されているdefineを調べてみました。

defineの階層

  • M5StickCライブラリ
  • M5StickCボード
  • ESP32プラットフォーム

Arduino IDEでは、上記の3層にわかれています。一番上のライブラリはM5StickC.hのファイルで、自分で読み込む必要があるライブラリです。

真ん中のボードは、Ardiono IDEで選択するボードで「M5Stick-C」という表記になっていると思います。ボードごとにコンパイルや転送オプションを変更しています。

一番下にあるプラットフォームはESP32で、基本的な関数はすべてここで定義されています。

全部出してみる

defineの宣言を知るためには、コンパイラのオプションでプリプロセッサの結果を出力するのが一番正確です。

調べたいスケッチを環境設定の「より詳細な情報を表示する」でコンパイルを選択してから、コンパイルを実行するとコンソールにコンパイル時のコマンドラインが表示されると思います。

スケッチをコンパイルしています...

上記の次の行がスケッチのコンパイルですので、コピーしてきて引数を書き換えます。

  • 「-c」を「-dM -E -x c++」に書き換え
  • 最後の-o以降を削る

上記の変更をして実行してみるとプロンプトにdefineの一覧が出力されます。ただしWindows環境で試すと、コマンドラインの文字数制限にひっかかるのでそのままだと実行できません。

バッチファイルにコマンドを書いて実行するのが、かんたんだと思います。

ただし、この結果出力を見たところ1万行以上出力されたので、とてもじゃないですがぜんぶ見ることができません。

ざっとみたところ、気になったものを紹介します。

#define B0 0
#define B00 0
#define B000 0
#define B0000 0
#define B00000 0
#define B000000 0
#define B0000000 0
#define B00000000 0
#define B00000001 1
#define B0000001 1

2進数でこんな感じのがずらっと255まで並んでいました。なにに使うんだろう。。。

あとは関数系のマクロが大量に入っていました。

#define GPIO_PIN0_CONFIG 0x00000003
#define GPIO_PIN0_CONFIG_M ((GPIO_PIN0_CONFIG_V)<<(GPIO_PIN0_CONFIG_S))
#define GPIO_PIN0_CONFIG_S 11
#define GPIO_PIN0_CONFIG_V 0x3
#define GPIO_PIN0_INT_ENA 0x0000001F
#define GPIO_PIN0_INT_ENA_M ((GPIO_PIN0_INT_ENA_V)<<(GPIO_PIN0_INT_ENA_S))
#define GPIO_PIN0_INT_ENA_S 13
#define GPIO_PIN0_INT_ENA_V 0x1F
#define GPIO_PIN0_INT_TYPE 0x00000007
#define GPIO_PIN0_INT_TYPE_M ((GPIO_PIN0_INT_TYPE_V)<<(GPIO_PIN0_INT_TYPE_S))
#define GPIO_PIN0_INT_TYPE_S 7
#define GPIO_PIN0_INT_TYPE_V 0x7
#define GPIO_PIN0_PAD_DRIVER (BIT(2))
#define GPIO_PIN0_PAD_DRIVER_M (BIT(2))
#define GPIO_PIN0_PAD_DRIVER_S 2
#define GPIO_PIN0_PAD_DRIVER_V 0x1
#define GPIO_PIN0_REG (DR_REG_GPIO_BASE + 0x0088)

こんなのが0から99まで入っていて、似たようなのも大量にありました。

参考ページ

ただし、上記はgccでコンパイルしていますが、esp32はg++なのでコマンドが若干違います。

気になった単語抜き出し

M5

#define ARDUINO_BOARD "M5Stick_C"
#define ARDUINO_M5Stick_C 1
#define ARDUINO_VARIANT "m5stick_c"
#define M5_BUTTON_HOME 37
#define M5_BUTTON_RST 39
#define M5_IR 9
#define M5_LED 10
#define _M5DISPLAY_H_ 
#define _M5STICKC_H_ 
#define m5 M5

ボードとライブラリで宣言されたdefineだと思います。どこでなにを宣言しているのかは後ほど調べたいと思います。

ESP32

#define ARDUINO_ARCH_ESP32 1
#define ESP32 1
#define ESP_FAIL -1
#define ESP_OK 0
#define ESP_PLATFORM 1

たくさんあったので、抜粋です。

ESP32のESP-IDFライブラリの実行結果は0だと正常終了で、それ以外だとエラーみたいで、これ以外にもエラーコードがたくさん定義されていました。

宣言場所を探す

libraries\M5StickC\src\utility\Config.h

#define M5_IR      9
#define M5_LED     10
#define M5_BUTTON_HOME 37
#define M5_BUTTON_RST  39

#define BUTTON_A_PIN 37
#define BUTTON_B_PIN 39

// UART
#define USE_SERIAL Serial

上記がM5StickCライブラリの宣言です。BUTTON_A_PINってのも宣言されているんですね。

packages\esp32\hardware\esp32\1.0.4\boards.txt

m5stick-c.build.variant=m5stick_c
m5stick-c.build.board=M5Stick_C

上記の設定値が自動的にdefine宣言されるようです。

#define ARDUINO_BOARD "M5Stick_C"
#define ARDUINO_M5Stick_C 1
#define ARDUINO_VARIANT "m5stick_c"

これに対応しているはずですが、ARDUINO_M5Stick_Cはm5stick-c.build.boardの値で自動宣言されるようです。

packages\esp32\hardware\esp32\1.0.4\variants\m5stick_c\pins_arduino.h

#define EXTERNAL_NUM_INTERRUPTS 16
#define NUM_DIGITAL_PINS        40
#define NUM_ANALOG_INPUTS       16

#define analogInputToDigitalPin(p)  (((p)<20)?(esp32_adc2gpio[(p)]):-1)
#define digitalPinToInterrupt(p)    (((p)<40)?(p):-1)
#define digitalPinHasPWM(p)         (p < 34)

static const uint8_t TX = 1;
static const uint8_t RX = 3;

static const uint8_t SDA = 32;
static const uint8_t SCL = 33;

static const uint8_t SS    = 5;
static const uint8_t MOSI  = 15;
static const uint8_t MISO  = 36;
static const uint8_t SCK   = 13;

static const uint8_t G9 = 9;
static const uint8_t G10 = 10;
static const uint8_t G37 = 37;
static const uint8_t G39 = 39;
static const uint8_t G32 = 32;
static const uint8_t G33 = 33;
static const uint8_t G26 = 26;
static const uint8_t G36 = 36;
static const uint8_t G0 = 0;

static const uint8_t DAC1 = 25;
static const uint8_t DAC2 = 26;

static const uint8_t ADC1 = 35;
static const uint8_t ADC2 = 36;

あとM5StickCに関係あるのが、上記ファイルです。SDAとSCLのデフォルトなどはこの値を取得していると思います。

基本的にはここの値は使わないで、自分で指定するほうが安全だと思います。

環境判定用define

ESP32プラットフォーム

#ifdef ARDUINO_ARCH_ESP32
#include "???.h"
#endif

もしくは

#if defined(ARDUINO_ARCH_ESP32)
#include "???.h"
#endif

ARDUINO_ARCH_ESP32が宣言されているかで確認するのが一般的みたいです。AdafruitのライブラリだとESP32で確認していますが、公式ライブラリはARDUINO_ARCH_ESP32を使っていました。

M5StickCボード

#ifdef ARDUINO_M5Stick_C
#include "???.h"
#endif

もしくは

#if defined(ARDUINO_M5Stick_C)
#include "???.h"
#endif

ARDUINO_M5Stick_Cが宣言されているかで確認できますが、あまりボードの判定はしていないみたいです。

M5StickCライブラリ

#ifdef _M5STICKC_H_
#include "???.h"
#endif

もしくは

#if defined(_M5STICKC_H_)
#include "???.h"
#endif

_M5STICKC_H_の宣言で確認できますが、こちらもあまり使わないかな?

まとめ

実際のところESP32かの確認は必要ですが、あまりボードの違いは少ないので判定している例は少なかったです。

しかしながら1万以上もdefineがされていたとは。。。

2進数のはArduino本家のbinary.hをそのままESP32に持ってきているから、入っているみたいです。