ESP32用ヘルパーライブラリ その6 PWMとサーボ

概要

前回はシリアルRGB LEDでした。今回はRGB LEDではない普通のLEDなどの明るさを調整するPWMと、その応用であるサーボです。

PWMとは?

ESP32の場合、アナログ出力はDACという機能を利用して256段階で出力可能です。ただし、GPIO25とGPIO26の2ピンからしか利用をすることができません。

そこで簡易的に出力を調整するPWMと呼ばれる機能もあります。これはONとOFFを繰り返すことで擬似的に出力を調整しています。

上記などを参考にしてください。

PWM(EspEasyPWM)

使い方

#include "EspEasyPWM.h"

EspEasyPWM pwn(LEDC_CHANNEL_0, GPIO_NUM_2);

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("PWM test");
}

void loop() {
  // set 0%-100%
  pwn.setPWM(100);
  delay(1000);

  pwn.setPWM(0);
  delay(1000);

  pwn.setPWM(50);
  delay(1000);

  for (int j = 0; j < 5; j++) {
    for (int i = 0; i < 100; i++) {
      pwn.setPWM(i);
      delay(5);
    }
  }

  pwn.setPWM(0);
  delay(1000);
}

シンプルな使い方になります。

EspEasyPWM pwn(LEDC_CHANNEL_0, GPIO_NUM_2);

上記でGPIOとPWMチャンネルを指定します。PWMチャンネルはESP32だと0から7までの8チャンネルですが、ESP32-C3などのCシリーズは0から5までの6チャンネルとなっています。

ただし、使えないチャンネルを指定するとコンパイルエラーになるはずです。また、複数のPWMを利用する場合にはチャンネルを重複しないようにしてください。

  // set 0%-100%
  pwn.setPWM(100);

0から100%で出力を指定します。LEDによっては負論理で0%にしたときに点灯する接続もあるはずです。

実装

class EspEasyPWM {
public:
  ledc_channel_t _ledc;
  gpio_num_t _gpio;
  uint16_t _frequency;
  uint8_t _bit;

  EspEasyPWM(ledc_channel_t ledc, gpio_num_t gpio, uint16_t frequency = 12000, uint8_t bit = 8) {
    _ledc = ledc;
    _gpio = gpio;
    _frequency = frequency;
    _bit = bit;

    ledcSetup(_ledc, _frequency, _bit);
    ledcAttachPin(_gpio, _ledc);
  };

  void setPWM(uint8_t percent) {
    ledcWrite(_ledc, (1 << _bit) * percent / 100);
  }
};

非常にシンプルです。デフォルトでは12000Hzで制御しています。ledcSetup()で周波数とビット数、チャンネル数を設定して、ledcAttachPin()でGPIOを設定しています。

ledcWrite()でパーセントでの指定をPWMの設定値に変換しています。これだけの処理であれば通常はラッパー処理もいらないと思います。

シンプルサーボ(EspEasyServo)

使い方

#include "EspEasyServo.h"

EspEasyServo servo(LEDC_CHANNEL_0, GPIO_NUM_2);

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("Servo test");
}

void loop() {
  // set 0(min)-90(center)-180(max)
  servo.setServo(90);
  delay(2000);

  servo.setServo(0);
  delay(2000);

  servo.setServo(180);
  delay(2000);

  for (int i = 0; i <= 180; i++) {
    servo.setServo(i);
    delay(30);
  }
}

サーボモーターはPWMで制御するので、非常に似た使い方になります。

EspEasyServo servo(LEDC_CHANNEL_0, GPIO_NUM_2);

サーボの定義はPWMとほぼ同じです。

// set 0(min)-90(center)-180(max)
servo.setServo(90);

サーボモーターは180度で角度を指定できるものと、360度で回る方向と速度を指定できるものがあります。どちらにしても、0から180度の角度で動作を指定します。

実装

class EspEasyServo {
public:
  ledc_channel_t _ledc;
  gpio_num_t _gpio;
  uint16_t _frequency;
  uint8_t _bit;
  uint16_t _min;
  uint16_t _max;

  EspEasyServo(ledc_channel_t ledc, gpio_num_t gpio) {
    _ledc = ledc;
    _gpio = gpio;
    _frequency = 50;
    _bit = 10;
    _min = ((0.5 / 20) * (1 << _bit)) + 0.5;  // (0.5ms/20ms)*1024 + 0.5(round)
    _max = ((2.4 / 20) * (1 << _bit)) + 0.5;  // (2.4ms/20ms)*1024 + 0.5(round)

    ledcSetup(_ledc, _frequency, _bit);
    ledcAttachPin(_gpio, _ledc);
  };

  void setServo(uint8_t degree) {
    if (180 < degree) {
      degree = 90;
    }
    ledcWrite(_ledc, _min + ((_max - _min) * degree / 180));
  }
};

上記の実装になります。

  EspEasyServo(ledc_channel_t ledc, gpio_num_t gpio) {
    _ledc = ledc;
    _gpio = gpio;
    _frequency = 50;
    _bit = 10;
    _min = ((0.5 / 20) * (1 << _bit)) + 0.5;  // (0.5ms/20ms)*1024 + 0.5(round)
    _max = ((2.4 / 20) * (1 << _bit)) + 0.5;  // (2.4ms/20ms)*1024 + 0.5(round)

    ledcSetup(_ledc, _frequency, _bit);
    ledcAttachPin(_gpio, _ledc);
  };

初期化時に違うところはサーボの周波数は50Hz固定です。ビットも10ビットに固定しています。_minと_maxを計算していますが、これが肝になるところでサーボは出力の幅によって設定値を通信しています。この_minから_maxまでの幅で指定することで任意の位置に移動させることが可能です。

  void setServo(uint8_t degree) {
    if (180 < degree) {
      degree = 90;
    }
    ledcWrite(_ledc, _min + ((_max - _min) * degree / 180));
  }

実際の角度指定の処理です。範囲外は中間である90度にとりあえず修正しています。また、出力の際には_minから_maxの間を180度で割ったものを利用して角度を指定しています。処理は非常に単純です。

速度指定付きサーボ(EspEasyServoEx)

使い方

#include "EspEasyServoEx.h"

EspEasyServoEx servo(LEDC_CHANNEL_0, GPIO_NUM_2);

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("Servo test");
}

void loop() {
  // set 0(min)-90(center)-180(max), speed 1(min)-100(max)
  servo.setTarget(0, 100);
  Serial.println("p=0, spped=100");
  while (servo.getPosition() != 0) {
    Serial.printf("target=%d, position=%d\n", servo.getTarget(), servo.getPosition());
    delay(100);
  }
  delay(1000);

  servo.setSpeed(50);
  servo.setTarget(180);
  Serial.println("p=180, spped=50");
  while (servo.getPosition() != 180) {
    Serial.printf("target=%d, position=%d\n", servo.getTarget(), servo.getPosition());
    delay(100);
  }
  delay(1000);

  servo.setTarget(0, 100);
  Serial.println("p=0, spped=100");
  while (servo.getPosition() != 0) {
    Serial.printf("target=%d, position=%d\n", servo.getTarget(), servo.getPosition());
    delay(100);
  }
  delay(1000);

  servo.setTarget(90, 10);
  Serial.println("p=90, spped=10");
  while (servo.getPosition() != 90) {
    Serial.printf("target=%d, position=%d\n", servo.getTarget(), servo.getPosition());
    delay(100);
  }
  delay(1000);
}

少し長くなりましたが、速度指定が追加されています。

EspEasyServoEx servo(LEDC_CHANNEL_0, GPIO_NUM_2);

宣言は同じです。

  // set 0(min)-90(center)-180(max), speed 1(min)-100(max)
  servo.setTarget(0, 100);
  Serial.println("p=0, spped=100");
  while (servo.getPosition() != 0) {
    Serial.printf("target=%d, position=%d\n", servo.getTarget(), servo.getPosition());
    delay(100);
  }

上記が使い方になります。パラメータにスピードが追加されています。100%と書いてありますが、実はサーボはものによって速度が異なります。なので手元にあったのでなんとなく100%ぐらいかなという速度を設定しています。(内部的には120とかわざと設定できるようにしています)

servo.getTarget()で設定した角度が取得でき、servo.getPosition()で移動中の角度が取得できます。基本的にゆっくりサーボを移動したいときに利用するので、遅めのスピードで徐々に移動しているのがわかります。

実装

#ifndef CONFIG_FREERTOS_UNICORE
#define ESP_EASY_SERVO_TASK_CPU_NUM APP_CPU_NUM
#else
#define ESP_EASY_SERVO_TASK_CPU_NUM PRO_CPU_NUM
#endif

class EspEasyServoEx {
public:
  ledc_channel_t _ledc;
  gpio_num_t _gpio;
  uint16_t _frequency;
  uint8_t _bit;
  uint16_t _min;
  uint16_t _max;
  TaskHandle_t _taskHandle;
  float _target;
  float _position;
  uint8_t _speed;
  float _d;

  EspEasyServoEx(ledc_channel_t ledc, gpio_num_t gpio, uint8_t initPosition = 90) {
    _ledc = ledc;
    _gpio = gpio;
    _frequency = 50;
    _bit = 10;
    _min = ((0.5 / 20) * (1 << _bit)) + 0.5;  // (0.5ms/20ms)*1024 + 0.5(round)
    _max = ((2.4 / 20) * (1 << _bit)) + 0.5;  // (2.4ms/20ms)*1024 + 0.5(round)
    _speed = 100;

    ledcSetup(_ledc, _frequency, _bit);
    ledcAttachPin(_gpio, _ledc);

    _position = initPosition;
    _target = initPosition;
    ledcWrite(_ledc, _min + ((_max - _min) * initPosition / 180));

    xTaskCreateUniversal(
      _task,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      ESP_EASY_SERVO_TASK_CPU_NUM);
  };

  static void _task(void *pvParameters) {
    EspEasyServoEx *servo = (EspEasyServoEx *)pvParameters;
    servo->_update();
  };

  void _update() {
    while (1) {
      if (_position != _target) {
        float newPosition;
        if (_position < _target) {
          // + Move
          if (_d < (_target - _position)) {
            newPosition = _position + _d;
          } else {
            newPosition = _target;
          }
        } else {
          // - Move
          if (_d < (_position - _target)) {
            newPosition = _position - _d;
          } else {
            newPosition = _target;
          }
        }
        ledcWrite(_ledc, _min + ((_max - _min) * newPosition / 180));
        _position = newPosition;
      }
      delay(10);
    }
  };

  void setTarget(uint8_t degree, uint8_t speed = 0) {
    if (180 < degree) {
      degree = 90;
    }
    if (speed != 0) {
      setSpeed(speed);
    }

    _target = degree;
  };

  void setSpeed(uint8_t speed) {
    if (speed != 0) {
      _speed = speed;
      _d = 2.0 * _speed / 100;
    }
  };

  uint8_t getPosition() {
    return (uint8_t)_position;
  };

  uint8_t getTarget() {
    return (uint8_t)_target;
  };
};

上記の実装になります。ちょっと長いので確認が大変です。

  EspEasyServoEx(ledc_channel_t ledc, gpio_num_t gpio, uint8_t initPosition = 90) {
    _ledc = ledc;
    _gpio = gpio;
    _frequency = 50;
    _bit = 10;
    _min = ((0.5 / 20) * (1 << _bit)) + 0.5;  // (0.5ms/20ms)*1024 + 0.5(round)
    _max = ((2.4 / 20) * (1 << _bit)) + 0.5;  // (2.4ms/20ms)*1024 + 0.5(round)
    _speed = 100;

    ledcSetup(_ledc, _frequency, _bit);
    ledcAttachPin(_gpio, _ledc);

    _position = initPosition;
    _target = initPosition;
    ledcWrite(_ledc, _min + ((_max - _min) * initPosition / 180));

    xTaskCreateUniversal(
      _task,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      ESP_EASY_SERVO_TASK_CPU_NUM);
  };

初期化はサーボのときと同じような処理に、タスク追加が増えています。サーボにはスピードを指定する方法はないですので、指示する角度をゆっくりと変更することで擬似的にスピード制御をしています。ゴールの角度を指定すると最速で動くので、わざと中間の角度をゆっくりと刻むことで遅く移動させます。

  void setTarget(uint8_t degree, uint8_t speed = 0) {
    if (180 < degree) {
      degree = 90;
    }
    if (speed != 0) {
      setSpeed(speed);
    }

    _target = degree;
  };

setTarget()で最終的な角度を設定します。ここでは変数に設定するだけで、サーボには何も出力しません。

  void setSpeed(uint8_t speed) {
    if (speed != 0) {
      _speed = speed;
      _d = 2.0 * _speed / 100;
    }
  };

setSpeed()も変数に設定するだけになります。_dが移動する量を制御する変数になります。本当はこの値をいろいろ変更させたかったのですが面倒なので諦めました。

_dが徐々に大きくなることで加速的な動作になりますし、ゴールに近づくと遅くなるとゆったりした動作になります。ただし、無指定がサーボの最速ですので、そこより早くなることはありません。できるのは遅くすることで、動きに表情をつけるだけになります。

  void _update() {
    while (1) {
      if (_position != _target) {
        float newPosition;
        if (_position < _target) {
          // + Move
          if (_d < (_target - _position)) {
            newPosition = _position + _d;
          } else {
            newPosition = _target;
          }
        } else {
          // - Move
          if (_d < (_position - _target)) {
            newPosition = _position - _d;
          } else {
            newPosition = _target;
          }
        }
        ledcWrite(_ledc, _min + ((_max - _min) * newPosition / 180));
        _position = newPosition;
      }
      delay(10);
    }
  };

_update()が内部のタスクで定期的に実行されている処理になります。ゴールとなるターネットと現在のポジションが違う場合には_d分だけ移動させています。ここではじめてledcWrite()が呼び出されて実際にサーボに出力がでることとなります。

delay(10)が最後にあるので10ミリ秒間隔でこの判定を行っています。本当はわざとターゲットを超えてのオーバーランからの、_dを少なくしつつ揺れ戻す処理とかが実装したかったです。

まとめ

サーボは単純に動かすだけでも楽しいのですが、ゆらぎてきな遊びをいれるともっと表情がでて楽しめると思います。

このライブラリは実用、学習用途でもあるのですが、サンプル実装の面もあるので自作クラスのベースとして使ってみてください。サーボも複数ある場合には本当は処理タスクは共通にして、複数のサーボを制御したほうが無駄がなくなると思います。

コメント