はじめに
近ごろ IoT プログラミングしたい欲が再起動した e.mattsan です。
IoT とは言っても Raspberry Pi などではマシンパワーも小さくなく、外部にデバイスを接続する以外は PC のプログラミングとほぼ変わりありません。
今回の記事は、題名の通り、ブラウザ上に表示したボタンでブレッドボード上の LED の点灯状態を制御し、ブレッドボード上のスイッチでブラウザ上のアイコンの表示状態の変更をすることを試みます。
- はじめに
- Elixir とウェブアプリケーションと IoT
- 用意するもの
- Nerves アプリケーションで LED を点滅させる
- ウェブアプリケーションを作る
- ノードを接続する
- ウェブアプリケーションに LED のインタフェースを追加する
- 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 は自動的に再起動します。
起動シーケンスが終わるのを待ってスイッチを何回か押してみてください。 押すたびに点灯消灯が切り替わるのが確認できると思います。
ソフトウェアインタフェースを追加する
ウェブアプリケーションに移る前に、ソフトウェアインタフェースを用意しておきます。
点灯/消灯のための on
と off
、状態を調べるための on?
の 3 つです。
このインタフェースは、あとでウェブアプリケーションからも利用します。
モジュールに次のコードを追加します。
ここで cast
, call
という関数と handle_cast
, handle_call
というハンドラが出てきますが、これらもメッセージのやり取りで実現されています。
cast
や call
で 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 のことを「なんだかよくわからないけれども面白そうだ」と思われた方は、お手に取ってみるのもよいかもしれません。
また「なんだかよくわからないけれどもこんな記事を書く面白そうな人間がいるぞ」と関心を持たれた方は、こちらをのぞいてみるのもよいかもしれません。
お立ち寄り、お待ちしております。