LoginSignup
13
5

More than 1 year has passed since last update.

IoTプラレールを作ってみました(その6:プラレールとフィットネスバイクを電波でつなごう)

Posted at

はじめに

こんにちは。(株) 日立製作所の Lumada Data Science Lab. の松本茂紀です。

IoTプラレールを作ってみましたシリーズの第6弾です。最終回です。

今回は「プラレールとフィットネスバイクを電波でつなごう」です。

今までの記事は以下から参照できますのでよろしければどうぞ。

IoTプラレールを作ってみました(その1:IoT電池の作成 前編)

IoTプラレールを作ってみました(その2:IoT電池の作成 後編)

IoTプラレールを作ってみました(その3:IoTサイクルメータを作ろう 前編)

IoTプラレールを作ってみました(その4:IoTサイクルメータを作ろう 後編)

IoTプラレールを作ってみました(その5:プラレールとフィットネスバイクを電波でつなごう 前編)

プログラムを仕上げていこう(IoT電池側)

まずは、プラレールに搭載するマイコンのプログラムを書き換えていきます。Arduino IDEのサンプルの中に、BLE_serverがあるか確認してください。なければ、ライブラリ管理から、「ble ESP32」を検索すると、「ESP32 BLE for Arduino」が見つかるので、これをインストールしBLEライブラリの準備を行います。

BLEの接続部分の実装については、BLE_clientのサンプルコードを参考にしました。自宅の範囲で利用する目的なので、ひとまずUUIDはサンプルのものを使いました。UUIDはオンラインで作成できるサービスや、Linuxならコマンドラインから作成することができるので、可能ならオリジナルでUUIDを作成すると良さそうです。

#include "BLEDevice.h"

//--- BLE関連の設定 ---//
// UUIDの設定(可能ならオリジナルのUUIDを取得)
static BLEUUID serviceUUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b");
static BLEUUID    charUUID("beb5483e-36e1-4688-b7f5-ea07361b26a8");

// 接続に関する設定
static boolean doConnect = false;
static boolean connected = false;
static boolean doScan = false;
static BLERemoteCharacteristic* pRemoteCharacteristic;
static BLEAdvertisedDevice* myDevice;
static int notifyPWM = 0; //ペリフェラルから受信するPWM値

// 接続状態に関するコールバック関数
class MyClientCallback : public BLEClientCallbacks {
  void onConnect(BLEClient* pclient) {
    Serial.println("onConnect");
  }

  void onDisconnect(BLEClient* pclient) {
    connected = false;
    Serial.println("onDisconnect");
  }
};

// ペリフェラルをスキャンしてserviceUUIDが一致するデバイス(myDevice)を取得
class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() && advertisedDevice.isAdvertisingService(serviceUUID)) {
      BLEDevice::getScan()->stop();
      myDevice = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
      doScan = true;
    }
  }
};

// ペリフェラルからデータ受信するためのコールバック関数
static void notifyCallback(
  BLERemoteCharacteristic* pBLERemoteCharacteristic,
  uint8_t* pData,
  size_t length,
  bool isNotify) {
    notifyPWM = atoi((const char *)pData);
    if (notifyPWM >255){
      notifyPWM = 255;
    }
}

// BLE接続を試行する関数
bool connectToServer() {
    BLEClient*  pClient  = BLEDevice::createClient();
    pClient->setClientCallbacks(new MyClientCallback());

    // 見つけたペリフェラル(myDevice)に接続
    pClient->connect(myDevice);
    pClient->setMTU(517);

    // serviceUUIDと一致するペリフェラルのserviceを参照
    BLERemoteService* pRemoteService = pClient->getService(serviceUUID);
    if (pRemoteService == nullptr) {
      pClient->disconnect();
      return false;
    }

    // charUUIDと一致するペリフェラルのcharacteristicを参照
    pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);
    if (pRemoteCharacteristic == nullptr) {
      pClient->disconnect();
      return false;
    }

    // characteristicから値を読み出す
    if(pRemoteCharacteristic->canRead()) {
      std::string value = pRemoteCharacteristic->readValue();
    }
    if(pRemoteCharacteristic->canNotify()){
      pRemoteCharacteristic->registerForNotify(notifyCallback);
    }
    connected = true;
    return true;
}

//--- モーター関連の設定 ---//
// モーターパラメータの設定
const int IN1 = 25;
const int IN2 = 26;
const int CHANNEL0 = 0;
const int CHANNEL1 = 1;
// PWM設定
const int PWM_BIT = 8;
const int PWM_FREQ = 700;
const int PWM_MAX = 255;

// 前進
void forward(uint32_t pwm) {
  if (pwm > PWM_MAX) {
    pwm = PWM_MAX;
  }
  ledcWrite(CHANNEL0, 0);
  ledcWrite(CHANNEL1, pwm);
}

// ブレーキ
void brake() {
  ledcWrite(CHANNEL0, PWM_MAX);
  ledcWrite(CHANNEL1, PWM_MAX);
}

//--- マイコンのメインの関数(setup/loop) ---//
void setup() {
  // コンソール設定(デバッグ用)
  Serial.begin(115200);

  //--- BLEの設定 ---//
  BLEDevice::init("");
  BLEScan* pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setInterval(1349);
  pBLEScan->setWindow(449);
  pBLEScan->setActiveScan(true);
  pBLEScan->start(5, false);

  //--- モーターの設定 ---//
  // ピンをアウトプットに設定
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  // チャネルのPWMを設定
  ledcSetup(CHANNEL0, PWM_FREQ, PWM_BIT);
  ledcSetup(CHANNEL1, PWM_FREQ, PWM_BIT);
  // ピンのチャンネルを設定
  ledcAttachPin(IN1, CHANNEL0);
  ledcAttachPin(IN2, CHANNEL1);
}

void loop() {
  // 未接続の場合は接続を試行
  if (doConnect) {
    if (connectToServer()) {
      Serial.println("ペリフェラルと接続");
    } else {
      Serial.println("接続に失敗");
    }
    doConnect = false;
  }

  // 接続していたらPWM値を取得しモーターを駆動
  if (connected) {
    Serial.println(notifyPWM );
    if (notifyPWM == 0){
      brake();
    }else{
      forward(notifyPWM);
    }
  }else if(doScan){
    notifyPWM=0;
    brake();
    BLEDevice::getScan()->start(0);
  }

  // 更新周期を1秒に設定
  delay(1000);
}

プログラムの中身について簡単に説明していきます、まず大きく3つのパートに別れています。

  • BLE通信に関するパート
  • モーター制御に関するパート
  • マイコンのメイン関数のパート

基本的な部分はIoTプラレールを作ってみました(その2:IoT電池の作成 後編)で紹介した内容になります。メインの処理を担う関数であるsetupとloopを見てもらうとわかるように、モータ制御用のプログラムに加えBLE通信の処理が追加された形になっています。

まず、起動時に呼び出されるsetup関数は、モーターのPWM設定の他にBLEのスキャンを開始する処理が入っています。
内部で呼び出されているMyAdvertisedDeviceCallbacksクラスによって、指定したserviceUUIDを発信しているペリフェラルのデバイス情報を取得しています。

次に、終了するまで繰り返し呼びれるloop関数は、ペリフェラルと接続できていなければconnectToServer関数を呼び出し接続を試みます。接続が確立したら、BLEで受信したデータを使ってモーターを駆動する処理を行っています。サイクルメータ側の実装を見てもわかりますが、characteristicはバイト文字列で送信されるので、受け取ったデータ(pData)を整数値(notifyPWM)にキャストしています。なお、更新周期は1秒に設定しています。

もし通信の途中に何かしらの影響で接続が切れた場合は、PWM値を更新しないことにしています。この仕様は、もし障害物の間を縫うようなとても長いコースを作った場合、障害物に隠れた時点で止まってしまわないような対策としています。

プログラムを仕上げていこう(IoTサイクルメータ側)

次に、Raspberry Pi Zeroを仕上げていきます。プログラムを作成する前に、いくつかライブラリの準備をしておきます。必要なライブラリのインストールと設定を行います。

$ sudo apt install libbluetooth3-dev libglib2.0 libboost-python-dev libboost-thread-dev 
$ sudo apt install python3-pip
$ cd /usr/lib/arm-linux-gnueabihf/
$ sudo ln libboost_python-py35.so libboost_python-py34.so
$ sudo pip3 install gattlib bluepy 
$ sudo systemctl daemon-reload
$ sudo service bluetooth restart

前回作成したcycle_puls.pyのプログラムに、消費カロリー計算とBLE通信を追加したプログラムに変更していきます。全体のプログラムは以下のようになります。

#!/usr/bin/python3
# coding:utf-8
import os
import time
import pickle
import numpy as np
import RPi.GPIO as GPIO
import Adafruit_SSD1306
from datetime import datetime, timedelta, timezone
from PIL import Image, ImageDraw, ImageFont
from pybleno import *
from max30102 import MAX30102

# BLE通信の設定
class EchoCharacteristic(Characteristic):

    def __init__(self, uuid):
        Characteristic.__init__(self, {
            'uuid': uuid,
            'properties': ['read', 'write', 'notify'],
            'value': None
        })
        self._value = array.array('B', [0] * 0)
        self.updateValueCallback = None

    def onSubscribe(self, maxValueSize, updateValueCallback):
        self.updateValueCallback = updateValueCallback

    def onUnsubscribe(self):
        self.updateValueCallback = None

# サイクルメータの基本機能
class CallBack:

    def __init__(self,gender, weight, age):
        # 消費カロリー計算用の情報入力
        self.gender = gender
        self.weight = weight
        self.age = age

        #GPIO設定
        GPIO.setmode(GPIO.BCM)

        #14番pinをスタート・リセット
        self.start_pin = 4
        GPIO.setup(self.start_pin,GPIO.IN,pull_up_down=GPIO.PUD_UP)

        # 18番pinをサイクルパルス入力、プルアップに設定
        self.cycle_pin = 18
        GPIO.setup(self.cycle_pin, GPIO.IN, GPIO.PUD_UP)

        # 回転数の平均を取る周期
        self.cycle_ave_interval = 2

        # タイムゾーン
        self.jst = timezone(timedelta(hours=+9),'JST')
        self.utc = timezone.utc

        # 液晶ディスプレイの初期設定
        RST = None
        I2C_ADDR = 0x3c
        font_size = 14
        font_path = "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf"
        self.disp = Adafruit_SSD1306.SSD1306_128_64( rst=RST, i2c_address=I2C_ADDR )
        self.jpfont = ImageFont.truetype(font_path, font_size, encoding='unic')
        self.image = Image.new( '1', ( self.disp.width, self.disp.height ) )
        self.draw = ImageDraw.Draw( self.image )

        self.reset_display()
        self.reset_count()

        # 割り込みイベント設定
        GPIO.add_event_detect(self.cycle_pin, GPIO.FALLING, bouncetime=300)
        # コールバック関数登録
        GPIO.add_event_callback(self.cycle_pin, self.cycle_count)

    # 液晶ディスプレイをリセット
    def reset_display(self):
        self.disp.begin()
        self.disp.clear()
        self.disp.display()

    # メータの値をリセット
    def reset_count(self):
        self.kcal = 0
        self.bpm = 0
        self.bpm_interval = []
        self.rpm = []
        self.rpm_interval = np.full(self.cycle_ave_interval,np.nan)
        self.s_time = time.time()
        self.b_time = self.s_time
        self.interval_count = 0
        self.event = []
        self.ee_record = []

    # 回転数を更新
    def cycle_count(self, channel):
        self.rpm.append(60.0/(time.time() - self.b_time))
        self.b_time = time.time()
        self.event.append(time.time())

    # 自己相関関数の計算
    def calc_acf(self,data,nlag=50):
        y = np.array(data) - np.mean(data)
        nlag = min(len(y),nlag)
        acf = np.array([np.sum(y[lag:]*y[:len(y)-lag]) for lag in range(nlag)])/np.sum(y**2)
        return acf

    # ピーク位置の検出
    def find_peak(self,data,order=5):
        y = np.array(data)
        peak = np.full(len(y),True)
        for o in range(1,order+1):
            diff = y[o:]-y[:len(y)-o]>0
            margin = np.full(o,False)
            peak = np.logical_and(peak, np.logical_and(np.append(margin,diff),np.append(~diff,margin)))
        return np.where(peak)[0]

    # 消費カロリー計算
    def calc_kcal(self,bpm):
        ee = self.gender*(-55.0969 + 0.6309*bpm + 0.1988*self.weight + 0.2017*self.age)\
            +(1-self.gender)*(-20.4022 + 0.4472*bpm - 0.1263*self.weight + 0.074*self.age)
        return ee/4.18

    # 液晶ディスプレイの表示を更新
    def update_disp(self):
        # 現在のケイデンスを算出
        interval_num = int((time.time() - self.s_time)%self.cycle_ave_interval)
        if self.interval_count != interval_num:
            self.interval_count = interval_num
            self.rpm_interval[interval_num] = sum(self.rpm)/len(self.rpm) if self.rpm else np.nan
            self.rpm = []
            if interval_num == 0:
                # 心拍数を算出
                acf = self.calc_acf(self.bpm_interval,nlag=40)
                peak = self.find_peak(acf, order=5)
                self.bpm = 0
                if len(peak):
                    self.bpm = 25/peak[0]*60
                self.kcal += self.calc_kcal(self.bpm)*self.cycle_ave_interval/60.0
                self.ee_record.append(self.kcal)
                self.bpm_interval = []
        ftime = datetime.fromtimestamp(time.time() - self.s_time ,self.utc)
        mean_rpm = 0 if np.isnan(self.rpm_interval).sum()==len(self.rpm_interval) else np.nanmean(self.rpm_interval)
        # 数字部分だけ表示を更新
        self.draw.rectangle((50,0,self.disp.width,self.disp.height), outline=0, fill=0)
        self.draw.text( ( 50, 5  ), ftime.strftime('%H:%M:%S'), font=self.jpfont, fill=255 )
        self.draw.text( ( 50, 20 ), f"{mean_rpm:0.0f}", font=self.jpfont, fill=255 )
        self.draw.text( ( 50, 35 ), f"{int(self.bpm)}", font=self.jpfont, fill=255 )
        self.draw.text( ( 50, 50 ), f"{int(self.kcal)}", font=self.jpfont, fill=255 )
        self.disp.image(self.image)
        self.disp.display()
        return mean_rpm

    # 液晶ディスプレイに項目名を表示
    def set_disp(self):
        self.draw.rectangle((0,0,self.disp.width,self.disp.height), outline=0, fill=0)
        self.draw.text( ( 0, 5  ), "TIME:", font=self.jpfont, fill=255 )
        self.draw.text( ( 0, 20 ), " RPM:", font=self.jpfont, fill=255 )
        self.draw.text( ( 0, 35 ), " BPM:", font=self.jpfont, fill=255 )
        self.draw.text( ( 0, 50 ), " kcal:", font=self.jpfont, fill=255 )
        self.disp.image(self.image)
        self.disp.display()

    # 液晶ディスプレイにメッセージを表示
    def disp_mesg(self,mesg):
        self.draw.rectangle((0,0,self.disp.width,self.disp.height), outline=0, fill=0)
        self.draw.text( ( 0, 5 ), mesg, font=self.jpfont, fill=255 )
        self.disp.image(self.image)
        self.disp.display()

    # 履歴をpickleで保存
    def dump_events(self):
        if not self.event:
            return
        with open(f"/home/pi/records/{datetime.isoformat(datetime.fromtimestamp(self.event[0]))}.pkl","wb") as f:
            pickle.dump(self.event,f)
        with open(f"/home/pi/records/ee_{datetime.isoformat(datetime.fromtimestamp(self.event[0]))}.pkl","wb") as f:
            pickle.dump(self.ee_record,f)

    def onStateChange(self,state):
        print('on -> stateChange: ' + state)
        if (state == 'poweredOn'):
            self.bleno.startAdvertising('RaspberryPiZeroW', ['4fafc201-1fb5-459e-8fcc-c5c9c331914b'])
        else:
            self.bleno.stopAdvertising()


    def onAdvertisingStart(self,error):
        print('on -> advertisingStart: ' + ('error ' + error if error else 'success'))
        if not error:
            self.bleno.setServices([
                BlenoPrimaryService({
                    'uuid': '4fafc201-1fb5-459e-8fcc-c5c9c331914b',
                    'characteristics': [
                        self.characteristic
                    ]
                })
            ])

    # コールバック関数
    def callback_start(self):
        try:
            self.disp_mesg("Ready?")
            start_flag = 1
            while(True):
                push = GPIO.wait_for_edge(self.start_pin, GPIO.FALLING,timeout=5000)

                # BLE notifyの開始
                self.bleno = Bleno()
                self.bleno.on('stateChange', self.onStateChange)
                self.bleno.on('advertisingStart', self.onAdvertisingStart)
                self.characteristic = EchoCharacteristic('beb5483e-36e1-4688-b7f5-ea07361b26a8')
                self.bleno.start()

                # スイッチ操作の処理
                if start_flag:
                    self.set_disp()
                    self.reset_count()
                elif start_flag==0 and push:
                    self.ppg.shutdown()
                    self.disp_mesg("Bye")
                    self.dump_events()
                    os.system("sudo shutdown -h now")
                    break # システムシャットダウン
                else:
                    self.set_disp()
                    start_flag = 1  # 続けてボタンを操作しなければ継続

                # パルスオキシメータの初期化
                self.ppg = MAX30102()

                while(start_flag):
                    time.sleep(0.1)
                    self.bpm_interval+=self.ppg.read_fifo() # パルスオキシメータ読み出し
                    mean_rpm = self.update_disp() # 0.1秒ごとに表示アップデート
                    on_start_button = GPIO.input(self.start_pin) == 0
                    on_cycle_button = GPIO.input(self.cycle_pin) == 0
                    if on_start_button and not on_cycle_button:
                        start_flag = 0 # 赤スイッチ押されたらループから抜ける
                    elif on_start_button and on_cycle_button:
                        self.reset_count() # 赤と黒のスイッチ同時おしで数値リセット

                    # 回転速度の値をnotifyで送信
                    if self.characteristic.updateValueCallback:
                        notificationBytes = str(mean_rpm).encode()
                        self.characteristic.updateValueCallback(notificationBytes)

                self.disp_mesg("Finish?")
                time.sleep(1)

        except KeyboardInterrupt:
            self.ppg.shutdown()
            GPIO.cleanup()

if __name__ == '__main__':
    gender = 1 # 1(男性) or 0(女性)
    weight = 60 # 体重 kg
    age = 30 # 年齢
    cb = CallBack(gender,weight,age)
    cb.callback_start() # 割り込みイベント待ち

プログラムの中身について簡単に説明します。

ベースのプログラムはIoTプラレールを作ってみました(その4:IoTサイクルメータを作ろう 後編)で作成したものになります。そこに、IoTプラレールを作ってみました(その5:プラレールとフィットネスバイクを電波でつなごう 前編)で作成したパルスオキシメータによる心拍数計算のプログラムをライブラリとしてインポートしているのと、心拍数から消費カロリーを計算する関数が追加された形になっています。

新たに追加されたのは、BLE通信の部分になります。基本的な処理は、pyblenoライブラリを利用しておりますが、ペリフェラルのcharacteristicの設定はCharacteristicクラスを継承して作成しており、初期化の際にプロパティとしてnotifyを指定しています。また、serviceUUIDやcharacteristicUUIDは、先程セントラル側で設定したものと同じものを記載します。これらが一致さえしていれば、正しく通信できるようになります。

最後のgender, weight, ageのところは、仮の情報を入れたので自分に合った設定にしてください。なお、実行するときの注意ですが、BLEモジュールを扱う権限の関係で、上記のプログラムは管理者権限での実行が必要です。ターミナルから実行するときはsudoをつけて実行してください。自動起動する場合は管理者で実行されるので特に変更は有りません。

プラレールとフィットネスバイクをつなごう

以上で準備が整いました。後はプラレールのコースを作り、IoT電池を入れたプラレールをセットすれば、プラレール側の準備はOKです。また、フィットネスバイクもモノラルジャックを接続し、電源をいれれば準備はOKです。ペダルを漕ぎ始めると心拍数も上がり、それっぽい値が表示されているようです。

4-05.png

実際に走行した動画がこちらです。大きなアップダウンのある立体的なコースです。特に、動画手前側には3段階登る場所がありますが、かなりペダルを漕ぐ速さを上げ、モーターの出力を上げていかないと登れない恐ろしいコースです(笑)

フィットネスバイクなのにヒルクライムを体験している気分になります。コースは娘の気まぐれなので毎回違って楽しめたり、娘が喜ぶ立体的なコースを沢山作れば負荷の高いヒルクライムを体感できるので運動強度も選べます。何より、ペダルを漕ぐとプラレールが動くのは想像以上に楽しく、終いには娘がペダルを漕ぎたいと言い出す程でした。ただ、足が届かずプラレールが動き出すだけの回転速度になりませんでしたが(笑)。親子で一緒に楽しめるIoTデバイスになりました。

4-06.gif

なお、実際に走らせてわかったのが、平坦なコースでは問題なく走りますが、上り坂のあるコースでは、中間車両が重く先頭車両が引っ張られることで、車輪が空転してしまうということです。今回は、ニセ電池のところに釘やネジ入れることで、先頭車両を少し重くし空転を防ぎました。

また、遊び始める前は、ペダルを漕いでからプラレールの動きに反映されるまで2秒のタイムラグが出てしまう点が、リアルタイム感を損なう悪者という認識でした。実際に遊んでるうちに、徐々に減速するような仕掛けを入れれば、制動距離を擬似的に再現できて、駅に停車させるゲームみたいで面白そうだな!と、悪者だったタイムラグが面白いゲーム要素に発想転換できるという気付きもありました。

おわりに

身の回りを見渡すと、色んなものがWiFiやBluetoothなど電波で繋がっていて、その仕組を詳しく知らずとも何気なく使っている技術だなと改めて感じます。今回BLEを使おうと思うまでは、Bluetoothのバージョンで何が違うのかもほとんど意識せず、番号が大きければ新しいから良いのだろう、という認識でしかありませんでした。

いざ自分でデバイスを作ってみると、必ずしも新しい規格いいわけではなく、メリット・デメリットがあって、デバイスを使う制約条件に合わせて適切に選択していく必要があることがわかってきました。また、最新の通信規格が何を目的としたもので、どんな面白いことができそうか、などに目を向けるようになりました。

色んなモノをセンシングし無線経由で収集したデータを分析するシーンがますます増えてくると思いますが、データの質や量を左右するのはセンサだけではなく通信まわりの制約も関係してきます。通信の制約は、デバイスの使用条件によって決まってくるので、データ分析の効果を上げるため、どこまで通信方式で対応できるかを知っておくことは重要な視点かもしれません。

最後になりますが、「IoTプラレールを作ってみました」シリーズということで、6回に分けて紹介してきましたがいかがでしたでしょうか?
正直「フィットネスバイク漕いでプラレール動かす」というのは、くだらないけど面白そうだなと思うような内容だと思いますが、日頃からこういったアイディアを探し、思い立ったら作ってみるという経験は、色々と得るものが大きいなと実感しています。

今後も、余暇の時間をつかって、くだらなくて面白いIoTデバイスを作っていこうと思います。

商標

Arduino IDEは、Arduinoのファームウェア開発に使用する統合開発環境です。

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5