ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

物理スイッチとウェブ UIを繋ぐ。Elixirで。

はじめに

近ごろ IoT プログラミングしたい欲が再起動した e.mattsan です。

IoT とは言っても Raspberry Pi などではマシンパワーも小さくなく、外部にデバイスを接続する以外は PC のプログラミングとほぼ変わりありません。

今回の記事は、題名の通り、ブラウザ上に表示したボタンでブレッドボード上の LED の点灯状態を制御し、ブレッドボード上のスイッチでブラウザ上のアイコンの表示状態の変更をすることを試みます。

Elixir とウェブアプリケーションと IoT

今回も、使う言語は Elixir です。

Elixir には Phoenix というウェブアプリケーションのフレームワークがあります。 また Phoenix ほど知られていないかもしれませんが、組み込みシステム向けの Nerves というプラットフォームも開発が進んでいます。 加えて。ハードウェア制御のための Elixir Circuits というライブラリ群も充実してきています。

www.phoenixframework.org nerves-project.org elixir-circuits.github.io

今回の記事では、一足飛びにウェブと LED を繋ぐのでなく、LED の自動点滅から始まって最終的にウェブからの制御まで進めてゆきます。 駆け足で実装してゆきますので、環境の用意など詳細は適宜ドキュメントを確認してください。

ハードウェア制御のハードルは意外に低い、ということをこの記事で実感していただけたら幸いです。

用意するもの

デバイスを制御するので、開発マシン以外にもいくつか用意が必要です。

  • 開発およびウェブアプリケーションを実行するマシン
    • ここに掲載したコードは MacBook Pro で開発し動作を確認しました
  • 組み込みシステムとして Raspberry Pi
    • 今回は Raspberry Pi Zero W を利用しています
  • Nerves アプリケーションを書き込む micro SD
    • 開発マシンから書き込めるようにアダプタ等を用意しておいてください
  • LED と物理スイッチ、電流制限や入力プルダウンのための抵抗器、およびそれらを配線するためのブレッドボードとケーブルをいくつか
  • 開発マシンと Raspberry Pi を接続する USB ケーブル
    • 通信と給電を兼ねます

準備ができたら、まずは定番の LED を点滅させるプログラムから作ってゆきます。

Nerves アプリケーションで LED を点滅させる

最初に Raspberry Pi 単体で LED を点滅させる Nerves アプリケーションを作成してみます。

開発マシン上で開発し、起動可能なバイナリを作成して microSD に書き込み、それを使って Raspberry Pi を起動するという段取りになります。

Nerves 公式のドキュメントに従って環境の用意ができたら、新しい Nerves アプリケーションのプロジェクトを作成します。

mix nerves.new led
cd led

対象となるハードウェアは環境変数で指定します。 今回は Raspberry Pi Zero W を対象にするので次のように指定しておきます。

export MIX_TARGET=rpi0

GPIO パッケージを追加する

Raspberry Pi の GPIO の制御には Elixir Circuits の GPIO パッケージを使います。

Nerves アプリケーションの mix.exs ファイルを編集して依存するパッケージの指定を追加し、mix deps.get コマンドでパッケージを取得します。

# mix.exs

  defp deps do
    [
      # ... 略
      {:circuits_gpio, "~> 2.0"}
    ]
  end

LED を点滅させる

LED を点滅させるプロセスを扱うモジュールを作成します。

このモジュールでは、 1 秒ごとに自分にメッセージを送信し、メッセージを受信するたびに状態を反転して GPIO に書き込むことで点滅を実現します。

# lib/led.ex

defmodule Led do
  use GenServer

  # プロセスを起動する関数
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # プロセスを初期化する関数
  def init(_opts) do
    {:ok, gpio17} = Circuits.GPIO.open("GPIO17", :output) # GPIO 17 を出力として利用

    tick()

    {:ok, %{gpio17: gpio17, led: 0}} # LED の初期状態として 0 = 消灯 を記憶
  end

  # 受信した :tick メッセージをハンドリングする関数
  def handle_info(:tick, state) do
    led = 1 - state.led # LED の状態を反転

    set_led(state.gpio17, led) # GPIO に値を書き込む
    tick()

    {:noreply, %{state | led: led}} # 新しい LED の状態で更新する
  end

  defp tick do
    Process.send_after(self(), :tick, 1_000) # 1,000 ミリ秒後に自分に :tick というメッセージを送る
  end

  defp set_led(gpio, value) do
    Circuits.GPIO.write(gpio, value)
  end
end

アプリケーションを起動したときに作成したモジュールのプロセスも起動するように、lib/led/application.ex を編集して起動プロセスの一覧にモジュール名を追加します。

# lib/led/application.ex

    children =
      [
        # ... 略
        Led
      ] ++ children(target())

mix firmware コマンドで実行してバイナリイメージを作成します。

バイナリができたら、開発マシンにアダプタなどで microSD を接続してアクセスできる状態になるのを待ち、 mix firmware.burn コマンドで書き込みます。 書き込みが完了すると自動的に microSD が取り出された状態になるので、microSD を Raspberry Pi に差し替えます。

開発マシンから直接 microSD へ書き込む必要があるのはこの初回のみで、次回以降は USB でアップロード可能です。

Raspberry Pi の準備ができたら、部品をブレッドボード上で繋ぎます。 次のような並びでつないでください。

GPIO 17 --- 抵抗器 --- LED --- GND

最後に開発マシンと Raspbery Pi を USB ケーブルを繋いで Raspberry Pi を起動します。 後ほど通信を行いますので、給電専用のポートに繋がないように注意してください。

少し待つと LED が 1 秒間隔で点滅し始めることが確認できると思います。

スイッチで LED を点灯消灯する

次に、LED の点灯をスイッチで制御できるようにします。 ここでは GPIO 17 ピンの隣の GPIO 27 ピンを入力として利用することにします。

先ほどのモジュールのコードを編集し、GPIO 27 を入力として設定します。 加えて GPIO 27 の入力の立ち上がりで割り込みが発生するように設定します。

割り込みは GPIO パッケージで Elixir のメッセージに変換され送信されます。 先ほどと同じように、そのメッセージをハンドリングすることで LED の状態を制御できるようになります。

# lib/led.ex

defmodule Led do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def init(_opts) do
    {:ok, gpio17} = Circuits.GPIO.open("GPIO17", :output)
    {:ok, gpio27} = Circuits.GPIO.open("GPIO27", :input) # GPIO 27 を入力として利用

    :ok = Circuits.GPIO.set_interrupts(gpio27, :rising) # 信号の立ち上がりの時に割り込みが発生するように設定する

    {:ok, %{gpio17: gpio17, gpio27: gpio27, led: 0}}
  end

  # 受信した GPIO メッセージをハンドリングする関数
  def handle_info({:circuits_gpio, _gpio_spec, _timestamp, _value}, state) do
    led = 1 - state.led

    set_led(state.gpio17, led)

    {:noreply, %{state | led: led}}
  end

  defp set_led(gpio, value) do
    Circuits.GPIO.write(gpio, value)
  end
end

mix firmware コマンドを実行してバイナリイメージを作成します。

バイナリを書き込む前に、ブレッドボード上にスイッチを用意します。 次のような並びでつないでください。

GPIO 27 ---+--- スイッチ --- 電源
           |
         抵抗器
           |
          GND

バイナリができたら、今度は直接 microSD に書き込む代わりに mix upload コマンドを実行して USB 経由でアップロードします。 アップロードが完了すると Raspberry Pi は自動的に再起動します。

起動シーケンスが終わるのを待ってスイッチを何回か押してみてください。 押すたびに点灯消灯が切り替わるのが確認できると思います。

ソフトウェアインタフェースを追加する

ウェブアプリケーションに移る前に、ソフトウェアインタフェースを用意しておきます。 点灯/消灯のための onoff 、状態を調べるための on? の 3 つです。 このインタフェースは、あとでウェブアプリケーションからも利用します。

モジュールに次のコードを追加します。

ここで cast, call という関数と handle_cast, handle_call というハンドラが出てきますが、これらもメッセージのやり取りで実現されています。 castcall で LED を制御するプロセスにメッセージを送り、ハンドラで受信したメッセージを処理します。 cast の場合は一方通行ですが call の場合は値を返すことができます。

# lib/led.ex

  def on do
    GenServer.cast(__MODULE__, :on)
  end

  def off do
    GenServer.cast(__MODULE__, :off)
  end

  def on? do
    GenServer.call(__MODULE__, :on?)
  end
# lib/led.ex

  def handle_cast(:on, state) do
    set_led(state.gpio17, 1)
    {:noreply, %{state | led: 1}}
  end

  def handle_cast(:off, state) do
    set_led(state.gpio17, 0)
    {:noreply, %{state | led: 0}}
  end

  def handle_call(:on?, _from, state) do
    {:reply, state.led == 1, state}
  end

もう一度バイナリを作成しアップロードします。 Raspberry Pi が再起動したら、USB で SSH 接続を試します。 開発マシンから次のように入力してください。

$ ssh nerves.local # Nerves アプリケーション標準のホスト名

接続できたら Nerves プロジェクトのロゴとプロンプトが表示されると思います。 プロンプトが表示されたら次のように関数を入力して実行してみてください。 Led.on で点灯、Led.off で消灯、Led.on? で状態の確認ができると思います。

iex(1)> Led.on
:ok
iex(2)> Led.off
:ok
iex(3)> Led.on?
false
iex(4)> Led.on
:ok
iex(5)> Led.on?
true

SSH 接続から抜けるには exit 関数を実行します。

iex(6)> exit

ウェブアプリケーションを作る

ウェブアプリケーションには Phoenix フレームワークを利用しますが、さらにブラウザ・サーバ間で双方向の通信を実現したいので LiveView という仕組みを利用します。

開発マシンで、Nerves アプリケーションのディレクトリとは別に、新しい Phoenix アプリケーションのプロジェクトを作成します。

mix phx.new web_ui --no-ecto # データベースを使用しないオプションを指定
cd web_ui

プロジェクトができたら、LiveView のモジュールの新しいファイル lib/web_ui_web/live/led_live.ex を追加します。

# lib/web_ui_web/live/led_live.ex

defmodule WebUiWeb.LedLive do
  use WebUiWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, led: 0)}
  end

  def render(assigns) do
    ~H"""
    <.button phx-click="on">ON</.button>
    <.button phx-click="off">OFF</.button>
    <.icon :if={@led != 1} name="hero-light-bulb" class="bg-gray-500" />
    <.icon :if={@led == 1} name="hero-light-bulb-solid" class="bg-blue-500" />
    """
  end

  def handle_event("on", _params, socket) do
    {:noreply, assign(socket, led: 1)}
  end

  def handle_event("off", _params, socket) do
    {:noreply, assign(socket, led: 0)}
  end
end

次に、作成した LiveView を利用するようにルーティングを変更します。 lib/web_ui_web/router.ex を編集し、get "/", PageController, :home となっている行を live "/", LedLive に書き換えます。

# lib/web_ui_web/router.ex

   scope "/", WebUiWeb do
     pipe_through :browser
 
-    get "/", PageController, :home
+    live "/", LedLive
   end

ここまでできたらサーバを起動します。

mix phx.server

サーバが起動したら https://localhost:4000 にアクセスしてみてください。 ON と OFF の 2 つのボタンととアイコンが 1 つ表示され、ボタンを押すとアイコンの状態が変化することが確認できると思います。

今はブラウザ上の表示だけの制御ですが、これをこの後ブレッドボード上の LED と連動させてゆきます。

ノードを接続する

連動のために、開発マシン上で実行されている Phoenix アプリケーションと Raspberry Pi 上で実行されている Nerves アプリケーションを接続することを考えます。

今回は Elixir のノードを接続する仕組みを利用します。 ノードとはここではそれぞれのアプリケーションのことと思っていてください。 同一マシン上で起動した複数のノードも、ネットワークで繋がった異なるマシンで起動したノードも、この仕組みで接続することが可能です。

Nerves アプリケーション側のノードを設定する

異なるマシンの間でノードを接続するには、ホストの名前を含んだノード名と、共通で利用するクッキーを設定しておく必要があります。

そのためにプロセスの初期化のコードに次の 3 行を追加します。

# lib/led.ex

  def init(_opts) do
    # ... 略

    # 次の 3 行を追加
    {_, 0} = System.cmd("epmd", ["-daemon"])
    {:ok, _} = Node.start(:"nerves@nerves.local")
    true = Node.set_cookie(:my_secret_cookie)

    {:ok, %{gpio17: gpio17, gpio27: gpio27, led: 0}}
  end

追加したら mix firmware & mix upload でアップロードします。

接続を確かめる

Raspberry Pi が再起動するのを待って、開発マシン側で Elixir のインタラクティブシェルを起動します。 Nerves アプリケーションと同じように、ここでもノード名とクッキーを設定します。

iex --name me@0.0.0.0 --cookie my_secret_cookie

プロンプトが表示されたら次のように関数を実行してください。 うまく接続できていれば LED が点灯するはずです。

GenServer.cast({Led, :"nerves@nerves.local"}, :on)

これはノードの名前を指定している以外は先ほど Nerves アプリケーションに追加した関数と同じ内容であることがわかると思います。 ノードを接続しても開発マシン側から Nerves アプリケーションで定義された関数が見えるわけではないので、内部の実装をそのまま使って実行しています。

接続できていれば GenServer.cast({Led, :"nerves@nerves.local"}, :off) で消灯したり、GenServer.call({Led, :"nerves@nerves.local"}, :on?) で状態を確認したりすることも可能です。

ウェブアプリケーションに LED のインタフェースを追加する

開発マシンから LED の点灯の制御の確認できたので、次はウェブアプリケーションからの操作を実装します。

今回は Nerves アプリケーションとウェブアプリケーションでコードを共有していないので、ウェブアプリケーションに LED 操作のための新しいモジュールを追加することにします。

ウェブアプリケーションに新しく lib/web_ui/led.ex というファイルを追加します。

# lib/web_ui/led.ex

defmodule WebUi.Led do
  def on do
    GenServer.cast({Led, :"nerves@nerves.local"}, :on)
  end

  def off do
    GenServer.cast({Led, :"nerves@nerves.local"}, :off)
  end

  def on? do
    GenServer.call({Led, :"nerves@nerves.local"}, :on?)
  end
end

LiveView のボタンイベントのハンドラでこの追加したモジュールの関数を呼び出します。

# lib/web_ui_web/live/led_live.ex

defmodule WebUiWeb.LedLive do
  use WebUiWeb, :live_view

  def mount(_params, _session, socket) do
    # 現在の LED の状態を取得して表示に反映する
    led =
      if WebUi.Led.on?() do
        1
      else
        0
      end

    {:ok, assign(socket, led: led)}
  end

  # ... 略

  def handle_event("on", _params, socket) do
    WebUi.Led.on() # 追加
    {:noreply, assign(socket, led: 1)}
  end

  def handle_event("off", _params, socket) do
    WebUi.Led.off() # 追加
    {:noreply, assign(socket, led: 0)}
  end
end

ページを再読み込みしたら ON / OFF のボタンを押してみてください。

LED の状態をウェブアプリケーションに通知する

いよいよ最後です。 LED の状態の変化をウェブアプリケーションに通知し、ブレッドボード上のスイッチ操作で点灯、消灯する LED の状態をブラウザの表示に反映させます。

通知を受けるプロセスを用意する

まず、先ほどウェブアプリケーションに追加したモジュールをプロセス化して、スイッチ操作のイベントメッセージを受信できるようにします。 表示に反映する前にログを出力することでメッセージを受信していることを確認します。

# lib/web_ui/led.ex

defmodule WebUi.Led do
  use GenServer

  require Logger # 追加

  # プロセスを起動する関数
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # ... 略

  # プロセスを初期化する関数
  def init(_opts) do
    {:ok, nil}
  end

  # スイッチ操作のイベントメッセージをハンドリングする関数
  def handle_cast(message, state) do
    Logger.debug(inspect(message)) # メッセージをログ出力する

    {:noreply, state}
  end
end

アプリケーションを起動したときにプロセスも起動するように、起動プロセスの一覧に今回作成したモジュールを追加します。

# lib/web_ui/application.ex

    children = [
      # ... 略
      WebUi.Led
    ]

Nerves アプリケーション側では LED の状態が更新されたらウェブアプリケーションへメッセージを送るようにします。

# lib/led.ex

  defp set_led(gpio, value) do
    GenServer.cast({WebUi.Led, :"me@0.0.0.0"}, {:nerves_led, value}) # 追加

    Circuits.GPIO.write(gpio, value)
  end

mix firmware & mix upload でアップロードします。

Raspberry Pi が再起動したら、ウェブアプリケーションも再起動します。 このとき、ノード名とクッキーを設定するために次のように起動してください。

elixir --name me@0.0.0.0 --cookie my_secret_cookie -S mix phx.server

ブラウザを開いて ON / OFF ボタンで操作できることが確認できたら、次はブレッドボード上のスイッチを押してみてください。 LED の状態が変化するたびにウェブアプリケーションのログにその状態が表示されることを確認できると思います。

...
[debug] {:nerves_led, 0}
[debug] {:nerves_led, 1}
...

LED の状態を表示する

ウェブアプリケーション側のプロセスでメッセージを受信できたので、今度はこのメッセージを LiveView のプロセスにブロードキャストします。 LiveView プロセスはブロードキャストされたメッセージを受けて LED の状態をブラウザの表示に反映します。

Phoenix にはこのようなときのために Phoenix.PubSub というライブラリが用意されているのでこれを利用します。

ウェブアプリケーション側の WebUi.Led モジュールに、購読を設定する subscribe 関数と、メッセージを受けた時に購読しているプロセスにブロードキャストするコードを追加します。

# lib/web_ui/led.ex

defmodule WebUi.Led do
  use GenServer

  @topic "nerves:led" # 追加

  # ... 略

  # 購読したいプロセスが呼び出す関数
  def subscribe do
    Phoenix.PubSub.subscribe(WebUi.PubSub, @topic)
  end

  # ... 略

  def handle_cast(message, state) do
    Logger.debug(inspect(message))

    # 購読しているプロセスにメッセージをブロードキャストする
    Phoenix.PubSub.broadcast(WebUi.PubSub, @topic, message)

    {:noreply, state}
  end
end

LiveView のモジュールには、購読の開始とブロードキャストされたメッセージをハンドリングするコードを追加します。

# lib/web_ui_web/live/led_live.ex

defmodule WebUiWeb.LedLive do
  use WebUiWeb, :live_view

  def mount(_params, _session, socket) do
    # 接続が確立したら購読を開始する
    if connected?(socket) do
      WebUi.Led.subscribe()
    end

    # ... 略
  end

  # ... 略

  # メッセージを受信したら表示状態を更新する
  def handle_info({:nerves_led, led}, socket) do
    {:noreply, assign(socket, led: led)}
  end
end

複数のブラウザでページを開いて、ブレッドボード上のスイッチを押したり、ブラウザ上のボタンを押したりしてみてください。 ブレッドボード上のLED とブラウザ上のアイコンの状態がすべて連動していることが確認できると思います。

考察

今回は大まかに次の図のような構成になりました。

ここで赤い線の部分が Elixir のメッセージングで通信している部分です。

  • ネットワークからのリクエスト
  • デバイスをまたいだノード間の通信
  • ハードウェアの割り込み
  • 状態通知のブロードキャスト

すべて同じメッセージングの仕組みで処理できています。

Elixir の土台である Erlang/OTP が分散コンピューティングを標榜し、元々このようなことができるように設計されているので不思議ではないのかもしれませんが。

それでも、プロセスとメッセージによるプロセス間通信という Elixir の基本的な仕組みを使って、さまざまな要素を繋ぐことができるというのは、やはり興味深いものがあります。

つい先日、Elixir の新しい書籍が出版されました。 基本的なプログラミングだけでなく Phoenix や Nerves 、今回の記事では触れなかった数値計算や機械学習についてもくわしく解説されています。

弊社とも 箱庭ラボ などを介してゆかりのある 高瀬英希 先生 が Nerves の章を執筆されています。

この記事を読んで、Elixir のことを「なんだかよくわからないけれども面白そうだ」と思われた方は、お手に取ってみるのもよいかもしれません。


また「なんだかよくわからないけれどもこんな記事を書く面白そうな人間がいるぞ」と関心を持たれた方は、こちらをのぞいてみるのもよいかもしれません。

お立ち寄り、お待ちしております。

agile.esm.co.jp