今回は単一ホストで動いている IoT システムを複数ホストで分散協調して動作するようにしてみます。これまでの応用で大変簡単にできるので、Elixir / Erlang / OTP の底力を感じるにはもってこいです。さらに今回の例では I/O をイベントドリブンで駆動するようにしてループを一切排除してみました。
なおこれは Nerves 1.5 (Elixir 1.9) でやってます。ちょうど前後して Nerves 1.6 がリリースされましたので1、そちらでも動くのか追って調べたいと思います。
概要
一つのマシンで作成していた IoT プログラムがあるとして、それを何らかの原因で複数のマシンで動かしたくなったとします。原因というのは、CPU の性能が足りなくなったとか、I/O の点数が足りなくなったとか、I/O のケーブル(I2C とか SPI)が届かない範囲に I/O デバイスが離れてしまうことになり Ethernet は届くけど…、というような状況がありえます。
こういう場合は大概の場合はかなりプログラムの書き直しが必要になりそうです。ところが Elixir で最初から綺麗にプログラムを書いておくと、あ〜ら不思議、わずかの変更で実現することができます。今回はごくごく簡単な例でそれを示します。
これまでの「はじめてNerves」シリーズで GenServer を用いてしつこく機能を小分けにしたプロセスを作るようにしてきましたが、ここでその成果が発揮されます。さらにプロセス間のメッセージングを用いてイベントドリブンなプログラミングを心がけて、ループフリーなプログラミングをしてみました。
機能
機能はごくごく単純です。ボタンとLEDが一つずつあって、ボタンを押すたびにLEDがOFF/ONします。これ、ボタンを押した回数を数えるカウンタ、ただし2進数で1桁しかなくて桁あふれは無視する…、と解釈もできます。
準備
準備することはこれまでの「はじめてNerves」シリーズでやってきたのとほとんど一緒です。念のため以下に改めて全部書いておきます。
ハードウェアの準備
ハードウェアは入力1つの出力1つで単純です。ラズパイ3Bにブレッドボードを使って以下をつなげました。
- GPIO17: タクトスイッチ
- GPIO18: LED
まず、いつも分かんなくなってしまうので、繰り返しになりますが掲載しておきます。どのピンにどんな信号が出ているかは [Raspberri Pi Pinout] (https://pinout.xyz) などをご覧ください。以下の図はこの抜粋で数字だけ書いてあるのが GPIO の番号を示しています。
ラズベリーパイの GPIO17 の入力をタクトスイッチのON/OFFの検出に使います。Elixir Circuit では以下のように正論理にしています。これに合わせればよかったのでしょうが、ついいつもの癖で負論理にしています。つまり、スイッチオンで GPIO17 が GND とつながるようにしています。
今回は正論理で組んでいても、動作はほとんど変わりません。2
GPIO18 の出力を LED の点灯用に使います。これは Circuits.GPIO の例の通りで、違っているのは 100Ωとある電流制限抵抗に 330Ωをつかっているところです。
BeagleBone の場合
BeagleBone Black や BeagleBone Green の場合は拡張コネクタのP9を使って
- GPIO17: タクトスイッチ
- GPIO18: LED
などとすると良いでしょう。回路は一緒で構いません。以下の図は Seeed Studio BeagleBone Green - Seeed Wiki からのピン配置です。
ソフトウェアの準備
いつもは mix nerves.new my_app_name
とはじめるところですが、後で書くように複数ホストを連携して動作させるために vintage_net や nerves_pack を使うには mix nerves.new my_app_name --nerves-pack
ではじめてください。
$ mix nerves.new my_app_name --nerves-pack
$ cd my_app_name
をやったのちに Elixir Circuit の GPIO を取り込みます。
def deps do
...
[{:circuits_gpio, "~> 0.4"}],
...
end
以上を追加して、ラズパイ3Bの場合は次のコマンドを実行します。
$ export MIX_TARGET=rpi3
$ mix deps.get
$ mix firmware
BeagleBone Black や BeagleBone Green の場合は以下のコマンドです。
$ export MIX_TARGET=bbb
$ mix deps.get
$ mix firmware
とやってここまできちんと出来上がるか確認してください。以下では my_app_name を remote
という名前で作成したものとします。
GPIO 操作の基本モジュール
GPIO のデバイスごとにプロセスを貼り付けます。それ用のモジュールがこちら。以前にも使っていますが再掲します。
なお、ラズパイの場合は横着して「:input の場合は強制的に pull_mode: :pullup
する」ように出来ます。スイッチ入力を正論理にしているばあいは :pulldown してください。プルアップ抵抗ないしはプルダウン抵抗がある場合は、pull_mode: のオプションはなくても構いません。
なお BeagleBone Green で試したところ、デフォルトの状態では pull_mode:
は使えませんでした。書くとエラーしてしまいます。
defmodule GpioInOut do
@behaviour GenServer
require Circuits.GPIO
require Logger
def start_link(pname, gpio_no, in_out, ppid \\ []) do
Logger.debug("#{__MODULE__} start_link: #{inspect(pname)}, #{gpio_no}, #{in_out} #{inspect(ppid)}")
GenServer.start_link(__MODULE__, {gpio_no, in_out, ppid}, name: pname)
end
def write(pname, :true), do: GenServer.cast(pname, {:write, 1})
def write(pname, :false), do: GenServer.cast(pname, {:write, 0})
def write(pname, val), do: GenServer.cast(pname, {:write, val})
def read(pname), do: GenServer.call(pname, :read)
def stop(pname), do: GenServer.stop(pname)
@impl GenServer
def init({gpio_no, in_out = :input, ppid}) do
{:ok, gpioref} = Circuits.GPIO.open(gpio_no, in_out)
# BeagleBone の場合は必ずこちら↑を
# {:ok, gpioref} = Circuits.GPIO.open(gpio_no, in_out, pull_mode: :pullup)
# ラズパイの場合でプルアップ抵抗を省略するときはこちらで良い。
Circuits.GPIO.set_interrupts(gpioref, :both, receiver: ppid)
{:ok, gpioref}
end
@impl GenServer
def init({gpio_no, in_out = :output, _ppid}) do
Circuits.GPIO.open(gpio_no, in_out) # expected to return {:ok, gpioref}
end
@impl GenServer
def handle_cast({:write, val}, gpioref) do
Circuits.GPIO.write(gpioref, val)
{:noreply, gpioref}
end
@impl GenServer
def handle_call(:read, _from, gpioref) do
{:reply, {:ok, Circuits.GPIO.read(gpioref)}, gpioref}
end
@impl GenServer
def handle_info(msg, gpioref) do
Circuits.GPIO.set_interrupts(gpioref, :both)
{:noreply, gpioref}
end
@impl GenServer
def terminate(reason, gpioref) do
Circuits.GPIO.close(gpioref)
reason
end
end
1つのホストで動作させる
ではマシンが1つだけで機能を実現するプログラムを書いてみます。
入出力用プロセス
まず、ボタン用のプロセスのモジュールを作ります。このモジュールのポイントは、ボタンの ON があった場合にメッセージを投げる先の pid を保持していることです。ここでは ppid (Parent PID) という変数名で状態としてプロセス内に保持しておきます。
defmodule Button do
@behaviour GenServer
require Logger
@button 17
def start_link(state \\ []) do
Logger.debug("#{__MODULE__}: start_link: #{inspect(state)}")
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
@impl GenServer
def init({name, ppid}) do
Logger.debug("#{__MODULE__}: init: #{inspect(name)}, #{inspect(ppid)}")
GpioInOut.start_link(name, @button, :input, self())
{:ok, ppid}
end
@impl GenServer
def handle_info({:circuits_gpio, @button, _time, on_off = 0}, ppid) do
send(ppid, on_off)
{:noreply, ppid}
end
@impl GenServer
def handle_info(caller, state) do
Logger.debug("#{__MODULE__}: handle_info #{inspect(caller)} #{inspect(state)}")
{:noreply, state}
end
end
次に LED 用のプロセスのモジュールを作ります。これは初期化をするだけのシンプルなモジュールになりました。
defmodule Led do
@behaviour GenServer
require Logger
@led 18
def start_link(state \\ []) do
Logger.debug("#{__MODULE__}: start_link: #{inspect(state)}")
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
@impl GenServer
def init(name) do
GpioInOut.start_link(name, @led, :output)
{:ok, name}
end
end
制御関係のモジュール
ボタンを押したら LED の状態を変化させるモジュールを作ります。
ボタンを押されるとLEDの状態を変化させるモジュール
Controller
モジュールは状態として {入力デバイス名, 出力デバイス名, LEDの状態(正確には次に起こる状態)}
を保持しています。ボタンが押されると handle_info/2
が呼び出され、LED を点灯もしくは消灯して、そして次にボタンが押されるアクションを待ちます。
defmodule Controller do
@behaviour GenServer
require Logger
def start_link(state \\ []) do
Logger.debug("#{__MODULE__}: start_link: #{inspect(state)}")
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
@impl GenServer
def init({input, output}) do
{:ok, {input, output, 1}}
end
@impl GenServer
def handle_info(msg, {input, output, n}) do
Logger.debug("#{__MODULE__}: handle_info: #{inspect(msg)}, #{n}")
GpioInOut.write(output, n)
{:noreply, {input, output, 1-n}}
end
@impl GenServer
def handle_info(caller, state) do
Logger.debug("#{__MODULE__}: handle_info #{inspect(caller)} #{inspect(state)}")
{:noreply, state}
end
end
各プロセスを起動するモジュール
全体の動作を機能させるのに Button
Controller
Led
の3つのモジュールのプロセスが必要です。これを起動するのを Worker
モジュールにやらせます。起動すべきモジュールのオプションが引数に指定されていると該当するモジュールを起動します。
- :cont が指定されている場合:Controller と Button モジュールを起動する
- 先に Controller を起動して、その pid を Button モジュールに渡します
- :led が指定されている場合:Led モジュールを起動する
ボタンが押されたことで発生するメッセージを渡す必要があって、Controller と Button のモジュールは一緒に動かします。
defmodule Worker do
use GenServer
require Logger
def start_link(state \\ []) do
Logger.debug("#{__MODULE__}: start_link:")
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
def init(state) do
if Enum.any?(state, fn arg -> arg == :cont end) do
{:ok, cont_pid} = Controller.start_link({{:global, :button}, {:global, :led}})
Logger.debug("#{__MODULE__}: init: #{inspect(cont_pid)}")
Button.start_link({{:global, :button}, cont_pid})
end
if Enum.any?(state, fn arg -> arg == :led end) do
Led.start_link({:global, :led})
end
{:ok, state}
end
end
アプリケーションモジュール
ホストが1台のみで、それにボタンとLEDがつながってる場合は Application
モジュールを以下のように書きます。
defmodule Remote.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Remote.Supervisor]
children =
[
# Children for all targets
# Starts a worker by calling: Remote.Worker.start_link(arg)
# {Remote.Worker, arg},
] ++ children(target())
Supervisor.start_link(children, opts)
end
# List all child processes to be supervised
def children(:host) do
[
# Children that only run on the host
# Starts a worker by calling: Remote.Worker.start_link(arg)
# {Remote.Worker, arg},
]
end
def children(_target) do
[
# led, controller and button host
{Worker, [:led, :cont]},
]
end
def target() do
Application.get_env(:remote, :target)
end
end
この中で以下が重要で、:led と :cont のオプションを Worker に渡すことによって、必要な3つのモジュールのプロセスを全部このマシン(ホスト)で起動します。
def children(_target) do
[
# led, controller and button host
{Worker, [:led, :cont]},
]
end
実行する
以上のプログラムを実行してボタンを押してみます。
ここで動いているプロセスとメッセージの関係はこんな風になってます。
ラズパイを2つにする
上ではホスト(ラズパイ)を1台で動かしていました。これを2台にしてみます。単体で動かしている状態から次の作業をします。
- 2台のホストが互いに IP でつながっている状態にする
- 2台のホストそれぞれで必要なプロセスが動いて、相互に連携して機能する
各ノードの設定
私の開発環境ではラズパイ3Bの有線ネットワークの方を管理用に使いますので、ラズパイ同士が接続するために WiFi ネットワークでつながるようにします。これには以下の動作が必要です。
- 有線のネットワークを有効にする
- 無線のネットワークを有効にする
- 双方が通信できるように、一方から他方に
Node.Connect
関数で接続する
実のところ、これを簡単な記述でスムーズにやるのは大変です。ネットワークIFの設定については、Nerves に最近 Vintage_net という機能が加わり、さらに nerves_pack というお手軽ネットワーク設定ライブラリが増えました。これを使います。私は以下の記事を参考にしました。
nerves_pack(vintage_net含む)を使ってNervesのネットワーク設定をした〜SSHログインまで〜
Nerves の立ち上がりのタイミングでは、ネットワークIFが有効になるかならないかのタイミングで Application モジュールが起動されます。ですので、プログラムで直ちに Node.connect をやりにいっても失敗してうまくいかない可能性があります。そのあたりをよろしくやってくれる記事がありますので、これを使います。
nerves_pack(vintage_net含む)を使ってNerves起動時に Node.connect() するようにした
これを仕込んだ後に Application
モジュールから Worker
を呼び出します。
Button と Controller を管理するホスト
LED を管理せず Botton と Controller を制御する方のホストの Application は以下のように変更します。
def children(_target) do
[
# controller and button host
{Bootexineris, ["rp3button", "comecomeeverybody", "rp3led@10.0.1.26"]},
{Worker, [:cont]},
]
end
LED を管理するホスト
LED のみを管理する方のホストの Application は以下のように変更します。
def children(_target) do
[
# led host
{Bootexineris, ["rp3led", "comecomeeverybody", "rp3button@10.0.1.32"]},
{Worker, [:led]},
]
end
実行する
以上のように、動作する部分に関しては Application
モジュールの変更だけをして、他のモジュールには手を付けません。それで本当に2つのホストで協調動作ができるでしょうか。これで動かしてみるとこの様に動きます。
これ、見えていませんが2つのホストは WiFi の同一 LAN 上に存在してて、互いが Node として見えています。どのプロセスがどのホストで動いているのかを示したのが以下の図です。先程の図と比較してメッセージのやりとりは一緒ですが、プロセスが動いているホストが分離しています。
まとめ
プロセスを適切に用いてシステムを構築しておくと、分散処理を行う際に、大きな改変をしなくてもシステム構成を変更できることを示しました。さらに、イベントドリブンなプログラミングを用いてループさせることなしにボタチカを実現しました。
今後は以下にチャレンジしてみたいと思います。
- Pub/Sub を使ってよりプロセスのモジュール化を進める
- 最初は単一ホストで Elixir Registry を使う
- その後で分散型の Registry モジュールに移行(おそらく Swarm と思う)
謝辞
ここにいたるまで、いくつもの「もくもく会」でもくもくして来ました。KitaQ, kokura.ex, fukuoka.ex, shinjuku.ex, Sapporo.BEAM のみなさまに感謝します。
参考文献
- Seeed Studio BeagleBone Green - Seeed Wiki
- nerves_pack(vintage_net含む)を使ってNervesのネットワーク設定をした〜SSHログインまで〜
- nerves_pack(vintage_net含む)を使ってNerves起動時に Node.connect() するようにした
- Elixir GenServer
- はじめてNerves(0) ElixirによるIoTフレームワークNervesがとにかく動くようになるためのリンク集
- はじめてNerves(2) GenServer を使ってLチカをする
- はじめてNerves(3) GPIO入力を追加してボタチカをする
- はじめてNerves(4) 独立したプロセスでボタチカをする
- はじめてNerves(6) GPIO入力のエッジを使ってイベントドリブンなボタチカをする
-
日本時間で 2020.03.22 に出たようです。Nerves 1.6 released! ↩
-
今回のプログラムだと、ボタンが負論理の場合はボタンを押した場合に LED の表示が変化しますが、正論理にするとボタンを離したときに LED の表示が変化します。 ↩