OGIMOノート

6歳の娘と、4歳の息子(脳性麻痺)を持った父親エンジニアの備忘録。自作の電子工作おもちゃ/リハビリ器具の製作記録、勉強記録を残していきます

LED(NeoPixel)を使いこなし、リハビリ支援用光るジョイスティックを作ってみた

f:id:motokiinfinity8:20180901142652j:plain

今回の学び技術: NeoPixel LED制御、ESP32のマルチタスク処理 (別タスクでLED制御)

はじめに(作成動機)

息子向けの足の代わりになる様なモビリティの開発を構想しているのだが、まず一番大事なのは操作系!まず現状の電動車いすの標準操作であるジョイスティック操作」、これを使えるかどうかをまずは見極めたい。
息子はまだ意志ある言葉は話せないのだが、手は器用に動かせるので、、、
もしかしたらジョイスティックでの操作が詳細な行動意志(「前に行きたい」「後ろに戻りたい」)を表すツールになりえるかもしれない!

ただし、息子くんはまだ自分の搭乗している乗り物が勝手に動く事に恐怖心を覚えてしまう。
乗り物に乗る行動が、本当に生理的に嫌いな行動なのか、それとも一度楽しさを覚えればそこから新しい世界(経験)が広がるのか、現時点では不透明だが、
親としては後者の可能性を信じて広げる手助けをしていきたい!
なので、本人の可能性を広げるため、息子に楽しんでもらいながら(←ここ重要)
一歩ずつモビリティ操作の練習を進めていきたいと思う。

なので、以下の作戦でスティック操作 = 意志を伝達する練習をしていく!


◆Step1 : ジョイスティックを動かすと…何かが起こる!(物は動かない)
◆Step2 : ジョイスティックを動かすとロボットが動く!(物が動く)
◆Step3: ジョイスティックを動かすと自分の乗った乗り物が動く (自分が動く)

そこで、Step1~Step3まで使い続ける事のできる『息子専用ジョイスティック』を作ってみる事にした。

要求仕様

 ・スティックは大きく簡単な力で動く事
 ・防水性が高い事 (息子は気に入ると涎ナメナメするため)
 ・将来的にロボット/モビリティを操作できる事
    → 防水性を考えるとロボットとは無線通信
 ・ジョイスティック単体として、動かすと楽しい事が起こる事


最後の「楽しい事」ってなんだ!?
という事で、悩んでいましたが、、、

「そうだ、子供の大好きな光り物を使ってウェイウェイ感を出そう!」

という事で、フルカラーLEDをライン上に光らせる仕様を追加。

いやぁ、ちょうど夏休みの旅行に行ったハウステンボスのライトアップにて息子本人はかなり興奮!
体をフリフリしながらテンション上がっており、
「光と音のコラボ、最強!」
という体験をしてきたので、その体験をリモコンにも取り入れようかと。

完成品

完成したジョイスティックの写真は以下。

f:id:motokiinfinity8:20180901142652j:plain

完成品の動画と、息子の反応動画は以下。

遊んでくれてるー!!  作戦成功

方向ごとに色を分けていて、上が黄色、下が赤、左が緑、右が青 として、方向と色のことばも一緒に覚えてもらう作戦です。
また、上 or 下ならラインLEDの中央に、右ならラインLEDの右側に、左ならラインLEDの左側に LEDがウェーブして動く様にしてみました。
(最初はウェーブなしの静止状態だったのですが、息子の反応がイマイチだったので、ウェーブで動かしました。『光る+動く』が重要な様です)

そして、今回のあそびごころは「フィーバーモード」! スティックを中心以外の場所に5秒連続で倒しておくと、レインボーに光る様にする仕様です。
息子はこれにかなり食いついていて、一度フィーバーモードに入れるコツが分かったらその後は繰り返して遊んでくれてました!


ハードウェア構成

ハードシステム図は以下。

f:id:motokiinfinity8:20180901151750j:plain

将来的には、無線経由でHostロボットに操作命令を送るため、ESP32モジュール一択。
今回 M5stackを使わなかったのは防水性優先にしたかったから。
(現時点では開発途中なので電源はUSB、将来的には無接点充電を頑張る!)

■ESP32 ESP-32S NodeMCU開発ボード
  : ESP32搭載ボードなら何でもよいと思います
  Amazon CAPTCHA

ジョイスティック
: ゲーセン用。まだ細かい手の操作が難しい息子が操作しやすい様、大きめのスティックを選びました。
   ジョイスティック: センサ一般 秋月電子通商-電子部品・ネット通販

■LEDテープ(NeoPixel)
: 業界おなじみシリアル信号でフルカラーLED制御可能なLEDテープ。今回は9個分を追加います。
   本当は5V駆動がよいのだが、3.3Vでも動いたので3.3V接続とする (レベル変換回路簡略のため)
フルカラーシリアルLEDテープ(17cm) - スイッチサイエンス

■筐体
; 息子の前に置ける様に大きい箱を購入。

■LEDテープ張り付け台
  : 3Dプリンタで自作。正面から光って見える様に斜め向きにテープ張り付けできる様に設置。
   f:id:motokiinfinity8:20180901144526p:plain

ソフトウェア開発

開発する上でのポイントは以下の三点
 ① サンプルコードをベースに LEDを専用で光らせる
 ② 独自点灯パタンへの置き換え
 ③ LED制御を別タスク処理 (ジョイスティックの変化に対する応答性アップ)

①サンプルコードをベースに LEDを専用で光らせる

ライブラリはおなじみのAdafruit_NeoPixel。
github.com

#include <Adafruit_NeoPixel.h>

// 定義 (合計9個のLEDを GPIO13から制御)
Adafruit_NeoPixel strip = Adafruit_NeoPixel(9,  13, NEO_GRB + NEO_KHZ800);

void setup()
{
  strip.begin();
 strip.setPixelColor(2, strip.Color(255, 0, 0)); //先頭から3個目(id=2)を赤色表示
 strip.setPixelColor(3, strip.Color(0, 255, 0)); //先頭から4個目(id=3)を緑色表示  
 strip.setPixelColor(4, strip.Color(0, 0, 255)); //先頭から5個目(id=4)を青色表示    
  strip.show(); //設定反映
}

上記だけのイメージで好きな位置のLEDを自由に光らせらるのでめっちゃ便利!!

失敗談①:最初Arduino Unoでは動いたがESP32では動かず、結局 Adafruit_NeoPixelのライブラリが古かったらしい (2年前に入れたきり更新してなかった)
 ⇒ ライブラリバージョンは要注意!

② 独自点灯パタンへの置き換え

これだけでもLEDは光るが、せっかくならLEDを色々と動かしたいし、カラフルにしたい!
自分で作ってもいいが、効率化のため既に作成されているサンプルを使っていくとよい。
私は以下のサイトを参考にした。

www.tweaking4all.com

私はRunning Lights効果をベースに改造した。
右側に倒した場合は右方向へ、左側へ倒した場合は左方向へ動かした。
なお、前の場合はセンター方向に光が集まる様に(前に進んでいる様にしたかったので)上記を参考に自作した。

また、フィーバーモードのLED点灯効果は、rainbowCycle効果をベースに改造した。

失敗談②:上記のRunning Lightsのソース内部では、三角関数を使いSin角をずらす事で波みたいな光量差を計算している。しかし、なぜかこれを使うと先頭LED(id=0)にノイズが乗ってしまう。原因は不明。
 ⇒ sin関数計算は使わず固定値(テーブルを持つ事)で対応した

③ LED制御を別タスクで回す

②で面白いLED効果が出来たが、Main関数でloopでLED切り替え操作を実施しているため、LED操作が終わるまでジョイスティックの状態を読みにいけず、ジョイスティックを別方向に動かしてから、LED効果が変更なるまで時間差が発生する。これだけなら別にLED更新周期とジョイスティック確認周期を作りこめばよいが、将来的にスピーカ制御やロボットとの通信やり取りを考えると、面倒くさい。そこで LED制御はMain関数から分離して別タスクで回す事にする。

なんとESP32は、無線対応マイコンでありながら、デュアルコアであり、FreeRTOS というリアルタイム OS で Arduino core プログラムが組まれているため、マルチタスク制御ができるとの事!なんてすごいんだ!!

下記サイトを参考に、LED処理を別タスクで処理する様に変更してみた!

www.mgo-tec.com


setup時点で、xTaskCreatePinnedToCore関数でタスク生成をして、Main関数ではジョイコン読み出し、LEDタスクではLED制御を行う。タスク間通信手段として一旦グローバル変数を使った。(もっと良いやり方あれば教えてください!)
LEDタスク側処理としては、該当グローバル変数の変更フラグをReadしたらLED処理を停止→ジョイコン値に従って次のLED処理に即座に切り替えるイメージですね。

失敗談③:Task内はwhile(1)で回さないとプログラムダンプで落ちるので注意!

作成したコードの一部は以下。

#include <Adafruit_NeoPixel.h>

#define UP_STICK_PIN   33
#define LEFT_STICK_PIN   35 
#define RIGHT_STICK_PIN   32 
#define DOWN_STICK_PIN   34 
#define PIXEL_PIN    13

//GLOBAL変数
int stick_pos = 0;    // [0: センター、4: 上、-4:下 ] + [+1:右、-1:左]を補正
int stick_pos_now = 0;  //初期値
bool _stick_pos_is_changed = false;
bool _fever_mode = false;
uint8_t red, green, blue;
int start_led, end_led;
uint32_t start_time = 0;



void setup()
{
  Serial.begin(115200);

  pinMode(PIXEL_PIN, OUTPUT);
  pinMode(UP_STICK_PIN, INPUT_PULLUP);
  pinMode(LEFT_STICK_PIN, INPUT_PULLUP);
  pinMode(RIGHT_STICK_PIN, INPUT_PULLUP);
  pinMode(DOWN_STICK_PIN, INPUT_PULLUP);
  strip.begin();
  strip.setBrightness(64);
  delay(1);  //安定待ち
  strip.show(); // Initialize all pixels to 'off'

  rainbow(10, 0, PIXEL_COUNT-1) 

  TaskHandle_t th; //マルチタスクハンドル定義
  xTaskCreatePinnedToCore(DisplayLED, "DisplayLED", 4096, NULL, 5, &th, 0); //マルチタスク起動
}




/////////////////////////////
// LED表示タスク処理
/////////////////////////////
void DisplayLED(void *pvParameters) {
    while(1){
     // もし5秒以上スティックが倒れていたら、フィーバーモード(レインボー)に遷移  
      if(Time_Mesure(g_start_time) > 5000){
        rainbowCycle(2, 0, PIXEL_COUNT-1);
      }else{
        // 5秒以下の場合、スティックの倒れ方に応じてモード変更
        switch(g_stick_pos){
          case  -5: //右後ろ
            RunningLights(255, 0,  0, 80,  2, 8,  0); 
            break;
          case -4:  //後ろ
            RunningLightsToCenter(255, 0,  0, 80,  0, 8,  0); 
            break;
          case  -3: //左後ろ
            RunningLights(255, 0,  0, 80,  0, 6,  1); 
            break;
          case  -1: //右
            RunningLights(0, 255, 255, 80,  2, 8,  0); 
            break;
          case  1:  //左
            RunningLights(0, 255, 0, 80,  0, 6,  1); 
            break;
          case  3:  //右前
            RunningLights(255, 255,  0, 80,  2, 8,  0); 
            break;
          case  4:  //前
            RunningLightsToCenter(255, 255,  0, 80,  0, 8,  1); 
            break;
          case  5:  //左前
            RunningLights(255, 255,  0, 80, 0, 6, 1);
            break;
          case  0:  //なし
           for(int i=0; i<strip.numPixels(); i++) {
              strip.setPixelColor(i, strip.Color(0, 0, 0));
              delay(10);  //安定待ち
           }
           break;
        }
         strip.show();
        }
        g_stick_pos_is_changed = false;
    }
}


/////////////////////////////
// Main処理
/////////////////////////////
void loop(){
  // スティック検出
  if(digitalRead(UP_STICK_PIN)==1){
    g_stick_pos = 4;
    wave_dir = 1;
  }else if(digitalRead(DOWN_STICK_PIN)==1){
    g_stick_pos = -4;
    wave_dir = 0;
  }else{
    g_stick_pos = 0;
    wave_dir = 0;
  }

  if(digitalRead(LEFT_STICK_PIN)==1){
    g_stick_pos += 1;
  }else if(digitalRead(RIGHT_STICK_PIN)==1){
    g_stick_pos -= 1;
  }

  //スティックが倒されている間の時間測定
  if(g_stick_pos == 0)  g_start_time = millis();  //時間リセット
  Serial.println(Time_Mesure(g_start_time));
  Serial.print("g_stick_pos:");
  Serial.println(g_stick_pos);

  if(g_stick_pos != g_stick_pos_now){
     g_stick_pos_is_changed = true;
  }
  g_stick_pos_now = g_stick_pos;

}

//////////////////////////////////
// サブ関数
/////////////////////////////////
void RunningLights(uint8_t red, uint8_t green, uint8_t blue, int WaveDelay, int start_led, int end_led) {
//コード省略
}

void RunningLightsToCenter(uint8_t red, uint8_t green, uint8_t blue, int WaveDelay, int start_led, int end_led) {
  int Position=100;
  
  set_led_out_of_range(start_led, end_led);
  
  for(int j=0; j<=(end_led-start_led)*2; j++)
  {
      if(wave_dir == 1)    Position++;
      else  Position--;
             
      for(int i=start_led; i<=(end_led-start_led); i++) {
        // sine wave, 3 offset waves make a rainbow!
        //float level = sin(i+Position) * 127 + 128;
        //setPixel(i,level,0,0);
        //double level = (sin((float)(i+Position)) * 127 + 128);
        double level;
        // sinを使うと先頭LEDに緑ノイズが発生する対策 (sinを使わない)
        // 周期 3.5LED分
        switch((i+Position)%7){
          case 0: level=0.5; break;
          case 1: level=0.99; break;
          case 2: level=0.30; break;
          case 3: level=0.10; break;
          case 4: level=0.87; break;
          case 5: level=0.75; break;
          case 6: level=0.05; break;        
        }
        // 2乗にする事で明暗差をつける対応
        uint8_t tmp_r = pow(level,2)*red;
        uint8_t tmp_g = pow(level,2)*green;
        uint8_t tmp_b = pow(level,2)*blue;                
        strip.setPixelColor(i,strip.Color(tmp_r, tmp_g, tmp_b));
        strip.setPixelColor(end_led-(i-start_led), strip.Color(tmp_r, tmp_g, tmp_b));
        delay(2);
        //strip.setPixelColor(i,
        //  strip.Color(((sin(i+Position) * 127 + 128)/255)*red,
        //              ((sin(i+Position) * 127 + 128)/255)*green,
        //              ((sin(i+Position) * 127 + 128)/255)*blue));
      }
      strip.setPixelColor((end_led-start_led)/2,strip.Color(red, green, blue));
      strip.show();

      if(_stick_pos_is_changed == true) return;
      delay(WaveDelay);
  }
}


void rainbowCycle(int SpeedDelay,  int start_led, int end_led) {
  uint16_t i, j;
  
  set_led_out_of_range(start_led, end_led);

  for(j=0; j<256*5; j++) { // 5 cycles of all colors on wheel
    for (int i=start_led; i<= end_led; i++) {
      strip.setPixelColor(i, Wheel(((i * 256 / (end_led-start_led)) + j) & 255));
    }
    strip.show();
    if(_stick_pos_is_changed == true) return;
    delay(SpeedDelay);
  }
}

uint32_t Wheel(byte WheelPos) {
  WheelPos = 255 - WheelPos;
  if(WheelPos < 85) {
    return strip.Color(255 - WheelPos * 3, 0, WheelPos * 3);
  }
  if(WheelPos < 170) {
    WheelPos -= 85;
    return strip.Color(0, WheelPos * 3, 255 - WheelPos * 3);
  }
  WheelPos -= 170;
  return strip.Color(WheelPos * 3, 255 - WheelPos * 3, 0);
}

void set_led_out_of_range(int start_led, int end_led){
  int i;
 
  for(i=0; i<start_led ;i++){
     strip.setPixelColor(i, strip.Color(0, 0, 0));
  }
  if(end_led < strip.numPixels()){
    for(i=(end_led+1) ; i<strip.numPixels();i++){
      strip.setPixelColor(i, strip.Color(0, 0, 0));
    }
  }
  delay(5);  //安定待ち
  strip.show();
}

uint32_t Time_Mesure( uint32_t st_time ){
  return millis() - st_time;
}


全体コードはGitにアップしました(9/23追記)
github.com

汚いコードですが、興味ある方はご参考までにどうぞ!


まとめ

こんな感じで、日常に役に立つ開発ネタを探しつつ、それに加えて 毎回必ず一つは新しいモジュールや技術手法を試す事を目標にしています!
今回は ESP32のタスク処理の実施方法を学べて良かった! LED制御と合わせて、子供むけリハビリ器具には必須な技術なので、どんどん使っていこうとおもう!
次は、音と組み合わせるか、いよいよロボット操作と組みあわえるか、だな♪
頑張ろう!!