電波が届きにくい環境で、2mぐらいの間隔で4〜5ヶ所、合計10mぐらいの距離にあるセンサをアクセスしたい案件がありました。センサはシリアルインタフェースでアクセスしますが、マイコン(M5StickC)からセンサまでのケーブルを2mぐらいにするとアクセスが不安定になってしまいました。

そこで、多少オーバースペックではありますが、RS485でネットワークを作り、マスタにはM5StickCを、スレーブにはM5ATOM Liteを使って、M5ATOM Liteでセンサを制御し、マスタからのリクエストでセンサ値を返すようにしました。RS485についてはこちらをご覧ください。

Modbus

RS485の上位のやりとりとしては、データパケットの構造を決め、端末のアドレスややり取り(コマンド)の体系を決める必要があります。今回必要なのは、マスタとなるマイコンから4台のスレーブのセンサ値を取得するという単純なものですが、ゼロから作るのは無駄になりそうなので、Modbusを使いました。Modbusについては「Modbus プロトコル概説書」が分かりやすかったです。

ハードウェア

M5StackシリーズにはRS485でM5Stackなどを相互につなぐパーツがあります。この中から以下のものを使い、最初の写真のような実験用のネットワークを作りました。

  • M5StickC RS485 Hat(AOZ1282CI搭載)
  • ATOMIC RS485キット
  • ATOM Tail485
  • RS485T
  • ACアダプター 12V/1.5A
  • ブレッドボードで使えるDCジャック(2.1mm)

T字型のRS485通信用コネクタには終端抵抗が内蔵されています。終端抵抗は両端のコネクタ以外には必要がないので、両端以外のコネクタの抵抗は取り外しました。6角レンチでコネクタのカバーを外し、写真上の赤丸部分にある抵抗を外します。左下が抵抗を取り外す前の写真、右下が取り外した後の写真です。右下の写真には取り外した抵抗が写っていますが、不要ですので捨てます。また、カバーをすると終端抵抗のあるものとないものの区別がつかなくなるので、印をつけておくとよいでしょう。

RS485の信号線はノイズに強くするためにツイストペア線を使います。手元に適当なツイストペア線がなかったので、「ツイストペア線が必要だ」ということを示すために普通の線をよじってツイストペアにしましたが、本番環境ではシールド付きのツイストペア線を使う予定です。

RS485コネクタとM5StickCやM5ATOMとはRS485 HatやATOMIC RS485キットでつなぎます。これらのRS485対応のHatやキットは、信号の送受信をおこなうICの他にRS485の12Vを5Vに変換するDC/DC変換器を内蔵していて、M5StickCやM5ATOMに給電できるので、電源の心配がなくなるのもありがたいです。

Modbusライブラリ

ArduinoのModbusライブラリはいくつか提供されていますが、マスタ機能だけのものやスレーブ機能だけのものもあり、マスタとスレーブ両方を提供するものとしては公式サイトにある「ArduinoModbus」と「modbus-esp8266」の2つぐらいです。M5StackのRS485はSerial2を使ってアクセスしますが、「ArduinoModbus」はSerial構造体を渡すことができないので、「modbus-esp8266」を使うことにしました。esp8266という名前ですが、ESP8266とESP32の両方に対応しています。

Arduino IDEの「ツール」メニューから「ライブラリを管理…」を選んでライブラリマネージャを立ち上げ、「modbus」で検索して「modbus-esp8266」をインストールします。

modbus-esp8266のAPIの説明は次のページにありますが、プログラムサンプルが貧弱なので、プログラムの流れを少し詳しく説明します。

* https://github.com/emelianov/modbus-esp8266/blob/master/API.md

Modbusにはコイル、入力ステータス、入力レジスタ、保持レジスタという4種類のデータが定義されています。コイルと入力ステータスはスレーブデバイスの先にある機器を制御するのに使われ、入力レジスタと保持レジスタは16ビット長のアナログ入出力に使われます。それぞれのレジスタには1から9999までのアドレスが割り振られています。

スレーブデバイスにセンサをつけ、マスタからセンサ値を取得するアプリケーションでは、入力レジスタ(Ireg)を使うのがよさそうです。

まず、16ビットの一つのレジスタ値をやり取りするプログラムを見てみます。

マスタ側プログラム

マスタ側プログラムは次のようになります。

void setup() {
    Serial2.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN); // Serial2を初期設定
    mb.begin(&Serial2); // Serial2のアドレスを渡してmodbusを初期設定する
    mb.master(); // 自分自身をマスタに設定
}

setup関数でシリアルを初期設定し、そのアドレスを渡してmb.begin関数でmodbusを初期設定します。次にmb.master関数で自分自身をマスタに設定します。

APIの説明にはスレーブのIレジスタの値はmb.readIreg関数で取得できると書かれています。実際にやってみると、通信が正常におこなわれた場合はIレジスタの値が取得できますが、タイムアウトなどでエラー終了しても、それを示す値が得られません。通信処理が終了したときに呼ばれるコールバック関数を設定すると通信の終了ステータスが取得できるので、それを使って終了ステータスを確認するようにしました。

Modbus::ResultCode result;

// 通信処理が終了したときに呼ばれるコールバック関数
bool cb(Modbus::ResultCode event, uint16_t transactionId, void* data) {
    result = event; // リクエストの終了ステータスをresultにセットする
    return true;
}

uint16_t value;

void loop() {
    while (mb.slave()) ; // スレーブがリクエストを処理中なら待つ
    mb.readIreg(slaveId, offset, value, numregs, cb); // コールバック関数を設定し、スレーブのIregを読む
    while (mb.slave()) { // スレーブのリクエスト完了を待つ
        mb.task();
        delay(100); 
    }
    // resultに終了ステータスが、valueにIregの値が入る
}

終了ステータスは次のページに書かれています。正常終了は0x00、存在しないスレーブIDを指定したり、スレーブ端末が動いていないときなどにはタイムアウト(0xE4)が返されます。

* https://github.com/emelianov/modbus-esp8266/blob/master/src/Modbus.h

プログラム全体は次のようになります。

スレーブ側プログラム

スレーブ側プログラムは、マスタからのデータ取得リクエストとは無関係にセンサをアクセスするなどしてデータを更新する非同期な方法と、マスタからリクエストがあった時点でデータを更新する同期的な方法があります。

非同期的なスレーブ側プログラム

非同期的なスレーブ側プログラムは次のようになります。

void setup() {
    Serial2.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN); // Serial2を初期設定
    mb.begin(&Serial2); // Serial2のアドレスを渡してmodbusを初期設定する

    mb.slave(SLAVE_ID); // 自分自身のスレーブIDを設定する
    mb.addIreg(offset); // Iregを使うことを指定する
}

setup関数でシリアルを初期設定し、そのアドレスを渡してmb.begin関数でmodbusを初期設定します。次にmb.slave関数で自分自身のスレーブIDを設定します。スレーブIDは0がブロードキャストになるので、0以外の値を指定します。

Modbusには4種類のデータが定義されています。外部からのアナログ入力を返すにはIレジスタ(Input register)を使うので、mb.addIreg関数で使うレジスタを指定しておきます。

 

unsigned long lasttime = 0;

void loop() {
    if ((millis() - lasttime) > 1 * 1000) {
        lasttime = millis();
        mb.Ireg(REGN, ++value);
    }
    mb.task();
}

loop関数では、周期的にmb.task関数を呼んでModbusの通信処理をおこなっています。millis関数で経過時間を見ながら、1秒ごとにmb.Ireg関数でIレジスタの値を更新しています。センサから値を取得する場合はmb.Iregの前にセンサをアクセスして値を取得し、Iレジスタにセットします。

実際のスレーブ側のプログラムは次のようになります。上で説明したModbusのやり取りに加えて、動作確認のために、1秒ごとにM5ATOM LiteのLEDの色を変えています。

 

これはRS485キットを使ったときのプログラムです。Tail485を使うときは送受信のピン番号が次のように変わります。

#define RX_PIN 32 // Tail485のRXピン
#define TX_PIN 26 // Tail485のTXピン

 

同期的なスレーブ側プログラム

マスタからレジスタの読み出し要求があったときに、データを更新するには、そのレジスタに読み出し要求があったときに呼ばれるコールバック関数をmb.onGetIreg関数で設定します。

uint16_t value = 0;

uint16_t cb(TRegister* reg, uint16_t val) { // コールバック関数
    toggleLED();
    return ++value; // 戻り値がレジスタ値にセットされる
}

void setup() {
    Serial2.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN); // Serial2を初期設定
    mb.begin(&Serial2); // Serial2のアドレスを渡してmodbusを初期設定する

    mb.slave(SLAVE_ID); // 自分自身のスレーブIDを設定する
    mb.addIreg(REGN); // Iregを使うことを指定する
    mb.onGetIreg(REGN, cb); // Iregの読み出し要求があったときのコールバック関数を設定する
}

void loop() {
    mb.task();
}

コールバック関数cbの戻り値がレジスタ値にセットされます。mb.Ireg関数を呼ぶ必要はないようです。

ドキュメントにコールバック関数の仕様が書かれていないので、コールバック関数内で中断ができるか不明です。中断できないと仮定して、中断をともなうセンサアクセスはloop関数内でおこない、センサ値をグローバル変数にセットしておいて、コールバック関数ではその値をリターンするのが無難だと思います。

loop関数はmb.task関数でModbusの通信処理を起動するだけになります。センサ値を取得するならloop関数内でおこないます。

連続する複数レジスタ値のやりとり

Modbusでは連続する複数のレジスタ値をやり取りすることができます。1つのレジスタは16ビット長なので、2つのレジスタで32ビット長のデータをやり取りしたり、温度、湿度、気圧のように複数のデータをやり取りすることが可能です。

マスタ側プログラム

マスタ側プログラムでは、次のように複数レジスタの値を受け取る配列value[3]を用意し、mb.readIreg関数の4番目のパラメータに取得するレジスタ数を指定します。

uint16_t value[3];

void loop() {
    while (mb.slave()) ;
    mb.readIreg(SLAVE_ID, REGN, value, 3, cb);
    while (mb.slave()) {
        mb.task();
        delay(500); 
    }
}

コールバック関数cbは指定した複数レジスタの受信が終わると1回呼ばれます。

スレーブ側プログラム(非同期)

マスタからのデータ取得リクエストとは非同期にデータを更新するスレーブ側プログラムを見てみましょう。setup関数内のmb.addIreg関数でIregを使うことを指定する際、レジスタ数を指定します。

    mb.addIreg(REGN, 0, 3); // Iregを使うことを指定する

loop関数内では、使用するそれぞれのIレジスタの値をIreg関数でセットします。

    mb.Ireg(REGN, ++value);
    mb.Ireg(REGN+1, ++value);
    mb.Ireg(REGN+2, ++value);

スレーブ側プログラム(同期)

同期的なプログラムでは、setup関数内のmb.addIreg関数でIregのレジスタ数を指定するのに加え、mb.onGetIreg関数でコールバック関数を設定する際にもレジスタ数を指定します。

    mb.addIreg(REGN, 0, 3); // Iregを使うことを指定する
    mb.onGetIreg(REGN, cb, 3); // Iregの読み出し要求があったときのコールバック関数を設定する

コールバック関数は複数のレジスタ読み出しに対して複数回(例では3回)呼び出され、その際、引数で渡されたregポインタの先のreg->address.addressにアクセルされているアドレスが入っています。アドレスに対応して値をリターンすると、その値がアドレスに対応したレジスタ値としてマスタ側に返されます。

uint16_t cb(TRegister* reg, uint16_t val) {
    if (reg->address.address == REGN) {
        toggleLED();
        return value++;
    } else if (reg->address.address == REGN + 1) {
        return value++;
    } else if (reg->address.address == REGN + 2) {
        return value++;
    }
        return 0;
}

ハマったところ

スレーブのプログラムを間違えて、マスタからのリクエストに応答できない場合、マスタのリクエストはタイムアウトになります。今回の実験ではスレーブを2台、1台はRS485キット、もう1台はTail485を使いました。2台のスレーブプログラムはスレーブIDとRS485の送受信のピン番号以外は共通ですが、Tail485の送受信のピン番号を逆に指定していて、Tail485のスレーブが応答しませんでした。プログラムが共通で1台のプログラムが動いたので、もう1台も動くものと思い込み、間違いに気がつくまで相当悩みました。

もう1つ。Tail485やM5Stack用RS485ユニットには送受信をおこなうIC(SP485)が入っています。M5ATOMやM5StackできちんとTail485やRS485ユニットを初期設定しなかったり、写真下のようにM5ATOMをつながずTail485だけをRS485ネットワークにつなぐと、ネットワーク全体の通信がタイムアウトします。ネットワークから外すときは写真上のようにTail485も外すようにする必要があります。