4
1

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(6) GPIO入力のエッジを使ってイベントドリブンなボタチカをする

Last updated at Posted at 2020-02-27

今回は 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 でオープンした入力デバイスに対して使えます。以下の引数を取ります。

  1. オープンした GPIO の reference
  2. エッジのどちらに反応するか
  • :rising 立ち上がりで反応する
  • :falling 立ち下がりで反応する
  • :both どちらにも反応する
  • :none どちらにも反応しない
  1. オプション
  • 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/3handoe_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 モジュール全体を以下に示します。

lib/exineris3b/gpioinout.ex
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 モジュールからは何も動かさないようにしておきます。

lib/exineris3b/application.ex
  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 の名前と番号をマップにまとめるようにしました。これは、これまで関数呼び出しの時に決め打ちしていたものです。

lib/exineris3b/worker2.ex
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 関数を以下のようにします。

lib/exineris3b/application.ex
  def children(_target) do
    [
      {Exineris3b.Worker2, [:led_counter]},
    ]
  end

これを mix firmware して SD カードに焼いて再起動します。

led_counter.gif

ボタンを押すたびに2数でカウントアップしています。

まとめと課題

GPIO で入力レベルではなく入力エッジを検出できるようにしました。これを用いることで、ループさせることなしにイベントドリブンなプログラムを書くことが出来ます。今回は2進カウンタを作成してみました。

さて、今回は必ず set_interrupt を設定してしまうように書いてしまいました。設定しない場合を表現できるようには GpioInOut.start_link で ppid を指定しないときの動作を以下のようにしたら良いはずでした。また何かの機会に。

  • デフォで [] が入る場合に set_interrupts をしない
  • receiver: のみの場合はデフォの set_interrupts をする
  • receiver: ppid の場合は ppid を receiver として set_interrupts をする

謝辞

今回の内容は サッポロビーム#311 でハック開始したものです。@niku さんをはじめとするサッポロビームのみなさんに感謝いたします。

参考文献

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 から来てますので、こちらを見ても構いません。

  1. ここは本来 Logger.warn を使うのではなく Logger.info か Logger.debug にオプションで色付けするべきです。

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?