10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

はじめてNerves(8) 単一ホストで動くシステムを複数ホストに分散する

Last updated at Posted at 2020-03-22

今回は単一ホストで動いている 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 の番号を示しています。
alt

ラズベリーパイの GPIO17 の入力をタクトスイッチのON/OFFの検出に使います。Elixir Circuit では以下のように正論理にしています。これに合わせればよかったのでしょうが、ついいつもの癖で負論理にしています。つまり、スイッチオンで GPIO17 が GND とつながるようにしています。
今回は正論理で組んでいても、動作はほとんど変わりません。2

alt

GPIO18 の出力を LED の点灯用に使います。これは Circuits.GPIO の例の通りで、違っているのは 100Ωとある電流制限抵抗に 330Ωをつかっているところです。
alt

BeagleBone の場合

BeagleBone Black や BeagleBone Green の場合は拡張コネクタのP9を使って

  • GPIO17: タクトスイッチ
  • GPIO18: LED

などとすると良いでしょう。回路は一緒で構いません。以下の図は Seeed Studio BeagleBone Green - Seeed Wiki からのピン配置です。

alt

ソフトウェアの準備

いつもは 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 を取り込みます。

mix.exs
  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: は使えませんでした。書くとエラーしてしまいます。

lib/remote/gpioinout.ex
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) という変数名で状態としてプロセス内に保持しておきます。

lib/remote/button.ex
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 用のプロセスのモジュールを作ります。これは初期化をするだけのシンプルなモジュールになりました。

lib/remote/led.ex
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 を点灯もしくは消灯して、そして次にボタンが押されるアクションを待ちます。

lib/remote/controller.ex
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 のモジュールは一緒に動かします。

lib/remote/worker.ex
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 モジュールを以下のように書きます。

lib/remote/application.ex
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つのモジュールのプロセスを全部このマシン(ホスト)で起動します。

lib/remote/application.ex
  def children(_target) do
    [
      # led, controller and button host
      {Worker, [:led, :cont]},
    ]
  end

実行する

以上のプログラムを実行してボタンを押してみます。

single-host.gif

ここで動いているプロセスとメッセージの関係はこんな風になってます。

fig1.png

ラズパイを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 は以下のように変更します。

lib/remote/application.ex
  def children(_target) do
    [
      # controller and button host
      {Bootexineris, ["rp3button", "comecomeeverybody", "rp3led@10.0.1.26"]},
      {Worker, [:cont]},
    ]
  end

LED を管理するホスト

LED のみを管理する方のホストの Application は以下のように変更します。

lib/remote/application.ex
  def children(_target) do
    [
      # led host
      {Bootexineris, ["rp3led", "comecomeeverybody", "rp3button@10.0.1.32"]},
      {Worker, [:led]},
    ]
  end

実行する

以上のように、動作する部分に関しては Application モジュールの変更だけをして、他のモジュールには手を付けません。それで本当に2つのホストで協調動作ができるでしょうか。これで動かしてみるとこの様に動きます。

dual-host.gif

これ、見えていませんが2つのホストは WiFi の同一 LAN 上に存在してて、互いが Node として見えています。どのプロセスがどのホストで動いているのかを示したのが以下の図です。先程の図と比較してメッセージのやりとりは一緒ですが、プロセスが動いているホストが分離しています。

fig2.png

まとめ

プロセスを適切に用いてシステムを構築しておくと、分散処理を行う際に、大きな改変をしなくてもシステム構成を変更できることを示しました。さらに、イベントドリブンなプログラミングを用いてループさせることなしにボタチカを実現しました。

今後は以下にチャレンジしてみたいと思います。

  • Pub/Sub を使ってよりプロセスのモジュール化を進める
    • 最初は単一ホストで Elixir Registry を使う
    • その後で分散型の Registry モジュールに移行(おそらく Swarm と思う)

謝辞

ここにいたるまで、いくつもの「もくもく会」でもくもくして来ました。KitaQ, kokura.ex, fukuoka.ex, shinjuku.ex, Sapporo.BEAM のみなさまに感謝します。

参考文献

  1. 日本時間で 2020.03.22 に出たようです。Nerves 1.6 released!

  2. 今回のプログラムだと、ボタンが負論理の場合はボタンを押した場合に LED の表示が変化しますが、正論理にするとボタンを離したときに LED の表示が変化します。

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?