Raspberry PiでフルカラーLEDイルミネーションを作るには

フルカラーLEDをRaspberry Piで制御して、様々な色を表現するイルミネーションを作ってみたいと思います。

デジタル的に単にLEDをチカチカ(ON/OFF)させるのではなく、アナログ的に輝度(明るさ)を4096段階調整可能です。調節はPWM(Pulse Width Modulation)制御基板を使用しました。

「イルミネーション」に相応しく、PWM制御基板1つで16チャンネル=フルカラーLEDを5つ接続することができます。複数のPWM制御基板を並列に接続することで最大992チャンネル、330個のフルカラーLEDを接続し、Raspberry Pi 1台で制御することが可能です。

この記事では、そのようなフルカラーLEDを制御する方法を記録してみたいと思います。


Raspberry PiのフルカラーLEDイルミネーション作成手順

使用する材料

Raspberry Pi本体

OSはRaspbianを使用したいと思います。どのRaspberry Piの機種でも大丈夫かと思います。

PWM制御基板

PCA9685を採用したPWM制御基板は多くの種類が出回っているようです。同じコントローラーを採用していてI2C端子に3.3Vの信号線を接続可能ならどれでも問題ないかと思います。

この記事では秋月電子通商さんの製品を使用させて頂きました。こちらは部品実装済みですが、ピンヘッダのはんだ付けが必要です。

電源分岐用microB端子

USB電源を分岐して、Raspberry PiとPWM制御基板に給電することで、GPIOの電力不足を解決しようと思います。秋月電子通商さんのこちらの製品を使用させて頂きました。

CPU、GPUの負荷が低く、USB端子に何も接続せず、少数のモーターやLEDを接続する場合でしたら、5V 3A程度のUSB電源をご用意頂ければ、電力不足の問題は発生し辛いかと思います。

フルカラーLED

カソードコモンの製品を使用しました。アノードコモンの場合、PWM信号(3.3V)と電源電圧(5V)の電位差で赤色が完全に消灯しない感じでした。PWM端子側の電源容量の都合から、LEDを大量に接続する場合、アノードコモンのほうが良いかもしれません。

カーボン抵抗/可変抵抗

手持ちの200Ωを使用しました。カソードコモンのフルカラーLEDの場合、3.3Vで抵抗値を計算します。LEDは個体差があるようです。本格的なイルミネーションを作成する場合、LEDの寿命を考慮して正しい値の電流が流れるように可変抵抗で調整する方法をお勧め致します。抵抗値を変えても明るさは変わらないため、LEDの輝度の個体差はPWM制御のパラメータで調節します。

配線・ブレッドボード

Raspberry PiとPWM制御基板はピンヘッダで接続するため、ジャンパーワイヤーのメス-メスを使用すると配線が楽です。


配線図

電源・Raspberry Pi・PWM制御基板

Raspberry PiとPWM基板の配線はこちらの記事を御覧ください。

フルカラーLED

フルカラーLEDを1つ接続する場合の配線は下図になります。

ブレッドボードを使用すると、全体的にこのような感じになります。


OSの設定

I2Cバスドライバの有効化

raspi-configでi2cバスを使用できるように設定後、再起動します。

sudo raspi-config


PythonやJavaScriptでPWMの制御が可能

PythonでPWM制御プログラムを作成する場合、たとえばこちらのライブラリを使用して作成が可能のようです。

この記事ではNode.jsで制御画面を作成、そのままNode.jsでPWM制御も行っています。下記のライブラリを使用させて頂きました。

制御画面が無くても、最低限、つぎのAPI2つでLEDを点灯可能のようです。周波数を指定して初期化:pwm = new Pca9685Driver()、チャンネルごとにON/OFFのステップを指定:pwm.setPulseRange()

画面はNode.jsで作成して、制御はPythonプログラムを使用したい場合、後ほど作成するws-event-handler.jsファイルからPythonプログラムを実行すると、うまく連携できるかと思います。

以下、ソフトウェア的に制御画面を作成する内容になります。


フルカラーLED制御画面作成

コントロール用スイッチの設計

LEDの明るさを段階的に変えたいわけですが。そのための何かボリュームやスライダーがあると便利そうです。

ここではそのようなスイッチをソフトウェア的に作成して、スマートフォンやPCからLEDをコントロールできるようにしたいと思います。

完成形はこのような画面になります。PWM制御に必要な最低限のパラメータ、周波数とRGB各チャンネルのターンオン・ターンオフステップを入力するかたちにしようと思います。


Node.js+Express.jsプロジェクト作成

こちらの記事を参考に、Express.jsのプロジェクトを作成します。

  1. プロジェクト名は「ledcontrol」にしてみました。
    #node.jsインストール
    sudo apt-get install nodejs npm
    #express-generatorインストール
    sudo npm install -g express-generator
    #expressプロジェクト作成
    express --view=ejs ledcontrol
    #動作確認
    cd ledcontrol
    npm install
    DEBUG=ledcontrol:* npm start
    #Webブラウザで確認 http://<Raspberry PiのIPアドレス>:3000
  2. PWM制御に使用するモジュールをインストールしておきます。
    npm install --save i2c-bus pca9685

あとで画面を変更したい場合、Raspberry Piで直接プロジェクトを作成せずに、WindowsやMac等のPCで作成後、SFTP、Git、Subversion、Nextcloud等でRaspberry Piへファイルを転送(同期)したほうが変更しやすいかと思います。Visual Studio Codeを使用する方法は宜しければこちらを御覧ください。


 ソースファイルの作成・変更

app.jsファイル変更

app.jsファイルを編集します。

vi app.js

「var app = express();」行の下に下記を追加しました。

var app = express();
// --------------------------------------------------
// WebSocketのインスタンス生成
var WebSocket = require('ws').Server;
var wss = new WebSocket({port:3001});

// Raspberry Piで何かイベント処理をさせるWsEventHandlerインスタンス生成
// ws-event-handler.jsファイルにその処理を書きましょう
var WsEventHandler = require('./ws-event-handler');
var ev = new WsEventHandler;

// WebSocketの送受信処理
wss.on('connection', function connection(ws) {
  console.log('WebSocket:接続しました')
  // デバイスの初期化など
  ev.connected(wss);

  ws.on('message', function incoming(data){
    console.log("WebSocket:受信しました: " + data);

    // 何かイベントに応じたデバイスの処理実行
    ev.handleEvent(wss, JSON.parse(data));

    // 受け取ったイベントをWebSocketクライアントにログとして表示
    wss.sendAllClients(data);
  });
  ws.on('close',function close(){
    // デバイスの終了処理など
    ev.closed(wss);
    console.log('WebSocket:切断しました');
  });
});

// 全クライアントにデータを送信するメソッドを追加しておきます
wss.sendAllClients = function SendAllClients(data) {
  this.clients.forEach(function each(client){
    // 全クライアントにデータ送信
    client.send(data);
  });
}
// --------------------------------------------------

WebSocketサーバを作成、データを送受信しつつ、ws-event-handler.jsファイルの関数を実行する内容です。


views/index.ejsファイル変更

index.ejsファイルをそっくり入れ替えます。

vi views/index.ejs

こちらがコントロール画面になります。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>フルカラーLEDコントローラー</title>
  <link rel='stylesheet' href='/stylesheets/style.css' />
  <style>
    body { margin: 0.5em; border: 0; padding: 0.5em;}
    dt { background-color: aliceblue; }
    canvas { border: 1px solid aliceblue; }
    @media screen and (min-width:760px) { 
      .clear { clear: both; }
      .log { width: 50%; }
      .setting, .graph, .log  { float: left; }
    }    
  </style>
</head>

<body>
  <h1>フルカラーLEDコントローラ</h1>
  <div id="app">
    <h2>PCA9685 PWM設定</h2>
    <section class="setting">
      <h3>共通</h3>
      <dl>
        <dt>Frequency:周波数</dt>
        <dd><input type="number" min="24" max="1526" v-model="pwmdata.frequency">Hz
          <input type="range" min="24" max="1526" step="1" v-model="pwmdata.frequency">(24-1526)</dd>
      </dl>
      <template v-for="item in pwmdata.channels">
        <h3>Ch.{{ item.ch }}:{{ item.name }}</h3>
        <dl>
          <dt>turn on steps:ONにするタイミング</dt>
          <dd>
            <input type="number" v-model="item.turnon" min="0" max="4095">
            <input type="range" min="0" v-model="item.turnon" max="4095" step="1">(0-4095)
          </dd>
          <dt>turn off steps:OFFにするタイミング</dt>
          <dd>
            <input type="number" v-model="item.turnoff" min="0" max="4095">
            <input type="range" v-model="item.turnoff" min="0" max="4095" step="1">(0-4095)
          </dd>
        </dl>
      </template>
      <h2>パラメータ情報</h2>
      <dl>
        <dt>1周期の時間</dt>
        <dd>{{ period_sec | usec | toFixed }}μs = {{ period_sec | msec | toFixed }}ms</dd>
      </dl>
      <template v-for="item in pwmdata.channels">
        <h3>Ch.{{ item.ch }}:{{ item.name }}</h3>
        <dl>
          <dt>ON時間</dt>
          <dd>{{ calcOnPeriodSec(item) | msec | toFixed }} ms</dd>
          <dt>OFF時間</dt>
          <dd>{{ calcOffPeriodSec(item) | msec | toFixed }} ms</dd>
          <dt>Duty Cycle:1周期のONの割合</dt>
          <dd>{{ calcDutyCycle(item) * 100 | toFixed }}%</dd>
        </dl>
      </template>
    </section>
    <section class="graph">
      <canvas ref="canvas_graph" width="480px" height="320px"></canvas>
    </section>
    <section class="log">
      <h2>WebSocket応答</h2>
      <ul v-for="subitem in logs">
        <li>{{ subitem }}</li>
      </ul>
    </section>
    <section class="clear">
      <small>&copy; 2018 日記というほどでも denor.jp</small>
    </section>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script src="/javascripts/pwm_graph.js"></script>
  <script>
    var PwmState = function () {
      this.data = {
        'resolution': 4096,
        'frequency': 50,
        'channels': [
          { 'ch': 0, 'name': 'R', 'turnon': 0, 'turnoff': 1 },
          { 'ch': 1, 'name': 'G', 'turnon': 0, 'turnoff': 1 },
          { 'ch': 2, 'name': 'B', 'turnon': 0, 'turnoff': 1 }
        ]
      }
    }
      PwmState.prototype.calcCycleSec = function () {
        return (1.0 / Number.parseInt(this.data.frequency));
      }
      PwmState.prototype.calcOnPeriod = function (ton, toff) {
        var turnon = Number.parseInt(ton);
        var turnoff = Number.parseInt(toff);
        var res = Number.parseInt(this.data.resolution);
        return (turnon < turnoff) ? (turnoff - turnon) : (turnoff + res - turnon);
      }
      PwmState.prototype.calcOffPeriod = function (ton, toff) {
        return Number.parseInt(this.data.resolution) - this.calcOnPeriod(ton, toff)
      }
      PwmState.prototype.calcDutyCycle = function (ton, toff) {
        return this.calcOnPeriod(ton, toff) / Number.parseInt(this.data.resolution);
      }
      PwmState.prototype.calcOnPeriodSec = function (ton, toff) {
        return this.calcCycleSec() * (this.calcOnPeriod(ton, toff) / Number.parseInt(this.data.resolution));
      }
      PwmState.prototype.calcOffPeriodSec = function (ton, toff) {
        return this.calcCycleSec() * (this.calcOffPeriod(ton, toff) / Number.parseInt(this.data.resolution));
      }

    var pwm = new PwmState();
    var graph = new PwmGraph(pwm);
    var socket;
    var app1 = new Vue({
      el: '#app',
      data: {
        'pwmdata': pwm.data,
        timerId : null,
        timerInterval : 100,
        logs: []
      },
      methods: {
        SendEventToWs: function (interval) {
          if (this.$data.timerId) return;
          var _this = this;
          this.$data.timerId = setTimeout(function(){
              socket.send(JSON.stringify(_this.$data.pwmdata));
              clearTimeout(_this.$data.timerId);
              _this.$data.timerId = null;
          }, interval);
        },
        calcDutyCycle: function (item) {
          return pwm.calcDutyCycle(item.turnon, item.turnoff);
        },
        calcOnPeriodSec: function (item) {
          return pwm.calcOnPeriodSec(item.turnon, item.turnoff);
        },
        calcOffPeriodSec: function (item) {
          return pwm.calcOffPeriodSec(item.turnon, item.turnoff);
        },
        addLogs: function(log) {
          var logs = this.$data.logs;
          logs.unshift(log);
          var maxlog = 10;
          if(logs.length > maxlog) {
            logs.splice(maxlog, logs.length - maxlog);
          }
        }
      },
      watch: {
        'pwmdata': {
          handler: function (newVal, oldVal) {
            graph.draw(this.$refs.canvas_graph);
            this.SendEventToWs(this.$data.timerInterval);
          },
          deep: true
        }
      },
      computed: {
        period_sec: function () {
          return pwm.calcCycleSec();
        }
      },
      mounted: function () {
        socket = new WebSocket('ws://' + window.location.hostname + ':3001/');
        var _this = this;
        socket.addEventListener('open', function (event) {
          _this.addLogs('接続しました');
        });
        socket.addEventListener('message', function (event) {
          _this.addLogs('Raspi応答:' + event.data);
        });
        
        graph.draw(this.$refs.canvas_graph, pwm);
        this.addLogs(this.$data.timerInterval + "msごとにPWM制御データを送信します")
        this.SendEventToWs(this.$data.timerInterval);
      },
      filters: {
        toFixed: function(val) {
          return val.toFixed(2);
        },
        msec: function(val) {
          return val * 1000;
        },
        usec: function(val) {
          return val * 1000000;
        }
      }
    })
  </script>
</body>

</html>

public/javascripts/pwm_graph.jsファイル作成

グラフの描画用にpwm_graph.jsファイルを作成しました。

vi public/javascripts/pwm_graph.js

HTML5のcanvasにグラフを書く内容です。

var PwmGraph = function (pwm) {
  this.ctx = null;
  this.pwm = pwm;
  this.client = { width: 0, height: 0 };
  this.margin = 40; //px
  this.graph = { width: 0, height: 0 };
  this.normalized = { width: 100, height: 0 };
  this.channels = 3;
  this.padding = 3;
}

PwmGraph.prototype.draw = function (cvs) {
  this.ctx = cvs.getContext('2d');
  this.client.width = cvs.width;
  this.client.height = cvs.height;
  this.clear();
  this.graph.width = this.client.width - this.margin * 2;
  this.graph.height = this.client.height - this.margin * 2;

  //座標軸描画
  this.drawAxis();

  //パラメータ描画
  this.drawParams();

  //グラフ本体描画
  this.drawGraph(2, pwm.data.channels[0], 'red');
  this.drawGraph(1, pwm.data.channels[1], 'green');
  this.drawGraph(0, pwm.data.channels[2], 'blue');
}

PwmGraph.prototype.clear = function () {
  var ctx = this.ctx;
  ctx.save();
  this.resetTransform();
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, this.client.width, this.client.height);
  ctx.restore();
}

PwmGraph.prototype.setTransformNormalized = function () {
  // 座標変換 Y軸反転、グラフの原点を描画の原点に合わせ
  // さらにグラフ横軸の座標を100に正規化します
  var ctx = this.ctx;
  this.normalized.height = this.normalized.width * this.graph.height / this.graph.width;
  ctx.setTransform(this.graph.width / this.normalized.width, 0,
    0, -this.graph.width / this.normalized.width,
    this.margin, this.client.height - this.margin);
}

PwmGraph.prototype.resetTransform = function () {
  this.ctx.setTransform(1, 0, 0, 1, 0, 0);
}

PwmGraph.prototype.drawAxis = function () {
  var ctx = this.ctx;
  ctx.beginPath();
  this.setTransformNormalized();
  ctx.lineWidth = 1;
  ctx.lineCap = 'butt';
  ctx.moveTo(0, 0);
  ctx.lineTo(0, this.normalized.height);
  ctx.moveTo(this.normalized.width, 0);
  ctx.lineTo(this.normalized.width, this.normalized.height);
  var xScale = 10;
  for (var i = 1; i < xScale; i++) {
    var xStep = this.normalized.width / xScale;
    ctx.moveTo(xStep * i, 0);
    ctx.lineTo(xStep * i, this.padding);
  }
  this.resetTransform();
  var res = Number.parseInt(this.pwm.data.resolution);
  ctx.font = "16px serif";
  ctx.fillText("0", this.margin - 14, this.margin + 14);
  ctx.fillText((res - 1).toString(), this.client.width - this.margin - 40, this.margin + 14);
  ctx.strokeStyle = 'black';
  ctx.stroke();
}

PwmGraph.prototype.drawParams = function () {
  var ctx = this.ctx;
  ctx.beginPath();
  this.resetTransform();
  ctx.font = "20px serif";
  ctx.fillText(
    (this.pwm.calcCycleSec() * 1000).toFixed(3).toString() + 'ms   ' +
    (this.pwm.calcCycleSec() * 1000000).toFixed(3).toString() + 'μs',
    this.client.width - 230, this.client.height - 20);
  ctx.strokeStyle = 'black';
  ctx.stroke();
}

PwmGraph.prototype.drawGraph = function (index, channel, color) {
  var ctx = this.ctx;
  var amp = (this.normalized.height - this.padding) / this.channels;
  var minY = this.padding * 2 + amp * index;
  var maxY = minY + amp - this.padding;
  var turnon = Number.parseInt(channel.turnon);
  var turnoff = Number.parseInt(channel.turnoff);

  ctx.beginPath();
  this.setTransformNormalized();
  ctx.lineWidth = 1.2;
  ctx.strokeStyle = color;
  if (turnon > turnoff) {
    var tmp = minY;
    minY = maxY;
    maxY = tmp;
    tmp = turnon;
    turnon = turnoff;
    turnoff = tmp;
  }
  var res = Number.parseInt(this.pwm.data.resolution);
  var tonX = turnon / res * this.normalized.width;
  var toffX = turnoff / res * this.normalized.width;
  ctx.moveTo(0, minY);
  ctx.lineTo(tonX, minY);
  ctx.lineTo(tonX, maxY);
  ctx.lineTo(toffX, maxY);
  ctx.lineTo(toffX, minY);
  ctx.lineTo(this.normalized.width, minY);
  ctx.stroke();
}      

ws-event-handler.jsファイル作成

最後に実際にPWM制御を行うソースファイルを作成します。

vi ws-event-handler.js

pca9685モジュールを使用して周波数やターンオン/ターンオフパラメータをPCA9685コントローラーにセットする内容です。

var i2cBus = require("i2c-bus");
var Pca9685Driver = require("pca9685").Pca9685Driver;

var pwm = null;
var prevFrequency = 0;

var WsEventHandler = function() {
    this.client_cnt = 0;
}

WsEventHandler.prototype.connected = function(wss) {
    this.client_cnt ++;
    if(this.client_cnt > 1)  return;
    // 以下接続開始時の処理

    prevFrequency = 0;
}

var setPluseRange = function(wss, pwm, channel) {
    
    wss.sendAllClients("setPulseRange開始:" + JSON.stringify(channel));
    pwm.setPulseRange(Number.parseInt(channel.ch),
	Number.parseInt(channel.turnon),
	Number.parseInt(channel.turnoff),
	function(err) {
        if (err) {
            wss.sendAllClients("CH" + channel.ch + ":setPulseRangeエラー");
        } else {
            wss.sendAllClients("CH" + channel.ch + ":setPulseRange完了");
        }
    });
}

WsEventHandler.prototype.handleEvent = function(wss, data) {
    if(! data) return;
    //イベントに応じた処理実行

    // 周波数が変わった場合、pwm制御オブジェクトを再作成
    var newFrequency = Number.parseInt(data.frequency);
    if (prevFrequency != newFrequency &&
        24 <= newFrequency && 1526 >= newFrequency) {
        var options = {
            i2c: i2cBus.openSync(1),
            address: 0x40,
            frequency: newFrequency,
            debug: false
        };
        wss.sendAllClients("PCA9685初期化開始:" + JSON.stringify(data));
        pwm = new Pca9685Driver(options, function(err) {
            if (err) {
                wss.sendAllClients("PCA9685:初期化エラー");
            }
            wss.sendAllClients("PCA9685:初期化完了");
        });
        prevFrequency = newFrequency;
    }
    for(var i=0; i<3; i++) {
        setPluseRange(wss, pwm, data.channels[i])
    }
}

WsEventHandler.prototype.closed = function(wss) {
    this.client_cnt --;
    if(this.client_cnt > 0)  return;
    // 以下接続終了時の処理

}

module.exports = WsEventHandler

以上で準備が完了しました。

作成したledcontrolプロジェクトを実行すると

DEBUG=ledcontrol:* npm start

スマートフォンやWebブラウザでLEDの制御画面にアクセスできるようになります。

http://<Raspberry PiのIPアドレス>:3000

スライダーを動かすと、Raspberry PiからPWM制御基板を経由してLEDの明るさを変えられるかと思います。

複数のLEDを接続すれば、空間的にさらにゴージャスなイルミネーションを作成できると思います。

お好みの色のパラメータを探して、例えばタイマーで自動的に色を変化させたりすることで、時間的なバリエーションのあるイルミネーションを演出することも可能かと思います。

宜しければいろいろとお試し下さい。

スポンサーリンク

フォローする

スポンサーリンク