今回は GPIO のエッジ、つまり入力の変化を検出して動くプログラムを作ります。これまではループして入力を定期的に見続けているプログラムでしたが、Elixir.Circuits.GPIO にある入力変化検出の機能を使うとループなしでもプログラムが書けます。
これは Nerves 1.5 (Elixir 1.9) を用いてます。
準備
ハードウェア・ソフトウェア共に はじめてNerves(4) 独立したプロセスでボタチカをする ができていることを前提にします。
イベントドリブンプログラミング
これまでは入出力デバイスの現在の値を見て動作するプログラムを作ってきました。この方法ですと、何かに変化があったときだけ動けば良いような仕様でも、ずっとループして状態を見続ける必要があります。これを「入出力デバイスの変化を検出してそれに基づいて動作する」ようにできればスマートになります。
これを実現するためには、入出力デバイスにおける変化を検出してそれを伝える仕組み、されに処理をしてその結果を伝えていく仕組みが必要です。前者については Elixir の Circuits.GPIO は入力の変化を捉える機能を持っています。後者については Elixir のプロセス間のメッセージが使えます。
GPIO で入力エッジを取り出せるようにする
Elixir Circuits で GPIO 入力の変化を捉えるには Circuits.GPIO.set_interrupts/2
を使います。Circuits.GPIO.open/2
でオープンした入力デバイスに対して使えます。以下の引数を取ります。
- オープンした GPIO の reference
- エッジのどちらに反応するか
- :rising 立ち上がりで反応する
- :falling 立ち下がりで反応する
- :both どちらにも反応する
- :none どちらにも反応しない
- オプション
- receiver: pid で反応した時にメッセージを送るプロセスを指定する(デフォルトは自分)
これを実行すると、入力が 0→1 や 1→0 と変化した際にメッセージがプロセスに送られます。メッセージは以下のタプルです。
- メッセージの送信元 :circuits_gpio
- GPIOの番号
- 時刻
- 変化した結果は何になったか
メッセージの送り先は Circuits.GPIO.set_interrupts\2
のオプションの第3引数を指定するとその pid へ、引数を2つで使うと自分自身のプロセスになります。
Circuits.GPIO で入力エッジを取り出す
実際に作ってみましょう。ベースは はじめてNerves(4) 独立したプロセスでボタチカをする の GpioInOut
モジュールを改変します。改変するのは3箇所です。
まずメッセージを送る先の pid を指定できるようにします。start_link/3
関数に第4引数として加えますが、バックワードコンパチビリティを考えてオプション指定にします。プロセスIDの変数名は、基本は親プロセス側にメッセージを出すだろうということで parent pid のつもりで ppid
としました。
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
これに対応するコールバック関数 init/2
関数に第3引数に pid を取れるようにして init/3
を作ります。GPIO を入力で使う場合と出力で使う場合とで処理が結構変わってきてしまうので、別関数として定義してみました。この中で Circuits.GPIO.set_interrupts/2
を呼び出してます。メッセージの送り先のプロセスIDを指定するのに receiver: ppid
としてオプション引数を指定します。
@impl GenServer
def init({gpio_no, in_out = :input, ppid}) do
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
{:ok, gpioref} = Circuits.GPIO.open(gpio_no, in_out)
Circuits.GPIO.set_interrupts(gpioref, :both, receiver: ppid)
{:ok, gpioref}
end
@impl GenServer
def init({gpio_no, in_out = :output, _ppid}) do
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
Circuits.GPIO.open(gpio_no, in_out)
end
GenServer プロセスへのメッセージはすべて handle_info/2
コールバック関数が受け取ります。つまり GenServer プロセスへの外部からの刺激は(起動・停止を除けば) handle_call/3
と handoe_cast/2
以外はすべて handle_info/2
が受け取ります。handle_call/3
関数でタイムアウトを指定したときでタイムアウトが実際に起ってしまった場合もこれが呼ばれます。
Circuits.GPIO.set_interrupt/2
を実施した場合にメッセージが自分自身に送られる場合(第3引数を指定しなかった場合)はこの handle_info/2
この関数が呼ばれますので、呼ばれたら中身をログ出力するようにします。なお Logger.debug
と違った色で見えるように Logger.warn
を使ってます1。
@impl GenServer
def handle_info(msg, gpioref) do
Logger.warn("#{__MODULE__} get_message: #{inspect(msg)}")
Circuits.GPIO.set_interrupts(gpioref, :both)
{:noreply, gpioref}
end
以上で書き直した GpioInOut
モジュール全体を以下に示します。
defmodule GpioInOut do
@behaviour GenServer
# use 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
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
{:ok, gpioref} = Circuits.GPIO.open(gpio_no, in_out)
Circuits.GPIO.set_interrupts(gpioref, :both, receiver: ppid)
{:ok, gpioref}
end
@impl GenServer
def init({gpio_no, in_out = :output, _ppid}) do
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
Circuits.GPIO.open(gpio_no, in_out)
end
@impl GenServer
def handle_cast({:write, val}, gpioref) do
# Logger.debug("#{__MODULE__} :write #{val} ")
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
Logger.warn("#{__MODULE__} get_message: #{inspect(msg)}")
Circuits.GPIO.set_interrupts(gpioref, :both)
{:noreply, gpioref}
end
@impl GenServer
def terminate(reason, gpioref) do
Logger.debug("#{__MODULE__} terminate: #{inspect(reason)}")
Circuits.GPIO.close(gpioref)
reason
end
end
基本的な試験で確認する
さてこれを実際に動かしてみます。起動時に自動でプログラムが動かないように Application
モジュールからは何も動かさないようにしておきます。
def children(_target) do
[
# none
]
end
これを mix firmware
してSDカードに焼いてラズパイをリブートします。
- ラズパイにログインする
- ロガー出力が見えるようにする
- GPIO 17 のタクトスイッチを入力でオープンする
- スイッチを on/off する
iex(1)> RingLogger.attach
:ok
iex(2)> GpioInOut.start_link(:sw, 17, :input)
09:35:22.264 [debug] Elixir.GpioInOut start_link: :sw, 17, input []
{:ok, #PID<0.1072.0>}
09:35:22.264 [debug] Elixir.GpioInOut init_open: 17, input
09:35:22.266 [warn] Elixir.GpioInOut get_message: {:circuits_gpio, 17, 40391291442, 1}
09:35:37.357 [warn] Elixir.GpioInOut get_message: {:circuits_gpio, 17, 55482391176, 0}
09:35:37.673 [warn] Elixir.GpioInOut get_message: {:circuits_gpio, 17, 55799008780, 1}
09:35:38.690 [warn] Elixir.GpioInOut get_message: {:circuits_gpio, 17, 56816182009, 0}
09:35:38.886 [warn] Elixir.GpioInOut get_message: {:circuits_gpio, 17, 57012053624, 1}
オープンした瞬間とスイッチを on/off した瞬間にログが出力されるのが見られます。
2進数カウンタを作成する
ボタンの on/off の変化で動作するプログラムを書きます。今回は「ボタンを押すごとに表示を 1 ずつ増やしていく。表示は LED で 2進数表現をする」ようなカウンタを作りました。今回は LED が 2個なので 2bit のカウンタで 00→01→10→11 をボタンを押すごとに繰り返します。
実際の中身ですが、メッセージを受けるプロセスが必要ですので、これも GenServer で記述します。init/1
関数で GpioInOut/3
関数の第4引数に self()
で自分の pid を指定することで、入力変化に伴うメッセージの受け手を自プロセスにしています。さらにメッセージが希望するボタンのメッセージなのかを判断するのに GPIO の名前と番号をマップにまとめるようにしました。これは、これまで関数呼び出しの時に決め打ちしていたものです。
defmodule Exineris3b.Worker2 do
# @behaviour GenServer # does not work with Nerves correctly
use GenServer
require Logger
@gpioifs %{:button => 17, :led_x => 18, :led_y => 23}
def start_link(state \\ []) do
Logger.debug("#{__MODULE__}: start_link: 2n dividing counter...")
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
def init(_state) do
# def init([:led_counter] = _state) do
Logger.debug("#{__MODULE__}: init: 2n dividing counter... #{inspect(self())}")
GpioInOut.start_link(:button, @gpioifs[:button], :input, self()) # GPIO の入力エッジでこのプロセスにメッセージを送るように指示する
GpioInOut.start_link(:led_x, @gpioifs[:led_x], :output)
GpioInOut.start_link(:led_y, @gpioifs[:led_y], :output)
{:ok, 0}
end
def handle_info({:circuits_gpio, gpio, _time, on_off} = msg, n) do
Logger.debug("#{__MODULE__}: #{inspect(msg)}, #{n}")
m = if (gpio == @gpioifs[:button]) && (on_off == 0), do: n + 1, else: n
GpioInOut.write(:led_x, rem(m, 2))
GpioInOut.write(:led_y, rem(div(m, 2), 2))
{:noreply, m}
end
end
起動時に自動的に動くように Application.children/1
関数を以下のようにします。
def children(_target) do
[
{Exineris3b.Worker2, [:led_counter]},
]
end
これを mix firmware
して SD カードに焼いて再起動します。
ボタンを押すたびに2数でカウントアップしています。
まとめと課題
GPIO で入力レベルではなく入力エッジを検出できるようにしました。これを用いることで、ループさせることなしにイベントドリブンなプログラムを書くことが出来ます。今回は2進カウンタを作成してみました。
さて、今回は必ず set_interrupt を設定してしまうように書いてしまいました。設定しない場合を表現できるようには GpioInOut.start_link
で ppid を指定しないときの動作を以下のようにしたら良いはずでした。また何かの機会に。
- デフォで [] が入る場合に set_interrupts をしない
- receiver: のみの場合はデフォの set_interrupts をする
- receiver: ppid の場合は ppid を receiver として set_interrupts をする
謝辞
今回の内容は サッポロビーム#311 でハック開始したものです。@niku さんをはじめとするサッポロビームのみなさんに感謝いたします。
参考文献
- Elixir Circuits.GPIO
- Elixir GenServer.handle_info
- はじめてNerves(0) ElixirによるIoTフレームワークNervesがとにかく動くようになるためのリンク集
- はじめてNerves(1) 電源ONでLチカアプリを起動させる
- はじめてNerves(2) GenServer を使ってLチカをする
- はじめてNerves(3) GPIO入力を追加してボタチカをする
- はじめてNerves(4) 独立したプロセスでボタチカをする
Circuits.GPIO.set_interrupts/2 関数のドキュメント
Circuits.GPIO.set_interrupts/2
関数の説明は公式ドキュメントには不十分な説明しか出てきません。より詳しい内容は、このモジュールを読み込んだ状態の iex においてヘルプコマンド h Elixir.Circuits.GPIO.set_interruputs
を実行した結果をご覧ください。これは GitHub の
githug: elixir-circuits/circuits_gpio/lib/gpio.ex の @spec set_interrupts
の記述がある直前の @doc
から来てますので、こちらを見ても構いません。
-
ここは本来 Logger.warn を使うのではなく Logger.info か Logger.debug にオプションで色付けするべきです。 ↩