エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Raspberry Pi 用タッチスクリーンを Elixir で利用する

以前購入した Raspberry Pi 用のタッチスクリーンを Elixir から利用する方法をまとめましたので、ご報告がてらの記事です。

今回、表示周りには Scenic を、入力周りには InputEvent を利用しています。 それぞれの詳細については、各々の資料をご参照ください。

Raspberry Pi のセットアップについても各所によい資料がありますので、そちらのご参照をお願いします。

また入力のドライバの記述は scenic_driver_nerves_touch を参考にしています。

…と言いますか、元々は scenic_driver_nerves_touch で済ませるつもりだったんですが、購入したタッチスクリーンに対応していなかったので自分で書き直したというのが本当のところ。

今回の作業はすべて Raspberry Pi 上で行っています。

また最終的に記述したコードは GitHub にも push しています。

InputEvent - タッチスクリーンの入力を得る

まず InputEvent を使って入力を取得するところから始めます。

適当なプロジェクトを用意します。

$ mix new touch_screen
$ cd touch_screen

mix.exs を編集して、depsinput_event を追加します。

  defp deps do
    [
      {:input_event, "~> 0.4"}
    ]
  end

取得できるイベントを確認する

依存パッケージを取得して iex を起動します。

$ mix desp.get
$ iex -S mix

InputEvent.enumerate/0 で利用できるデバイスの一覧を取得します。

iex(1)> InputEvent.enumerate()
[
  {"/dev/input/event0",
   %InputEvent.Info{
     bus: 0,
     input_event_version: "1.0.1",
     name: "ADS7846 Touchscreen",
     product: 0,
     report_info: [
       ev_abs: [
         abs_x: %{flat: 0, fuzz: 0, max: 4095, min: 0, resolution: 0, value: 0},
         abs_y: %{flat: 0, fuzz: 0, max: 4095, min: 0, resolution: 0, value: 0},
         abs_pressure: %{
           flat: 0,
           fuzz: 0,
           max: 65535,
           min: 0,
           resolution: 0,
           value: 0
         }
       ],
       ev_key: [:btn_touch]
     ],
     vendor: 0,
     version: 0
   }}
]

ここから次のようなことがわかります。

これらの知識を持って、実際に入力を確認します。

イベントを取得する

InputEvent は InputEvent.start_link/1 でプロセスを起動すると、起動した親プロセスにメッセージでイベントを送るので、GenServer で親プロセスを実装し、イベントを受けたらログに出力するようにしてみます。

lib/touch_screen.ex を編集します。

defmodule TouchScreen do
  use GenServer

  require Logger

  def start_link(device) do
    GenServer.start_link(__MODULE__, [device: device])
  end

  def init(opts) do
    {path, _} =
      InputEvent.enumerate()
      |> Enum.find(fn {_, %{name: name}} -> name == opts[:device] end)

    InputEvent.start_link(path)

    {:ok, %{path: path}}
  end

  def handle_info(message, state) do
    Logger.debug(inspect(message))

    {:noreply, state}
  end
end

起動時にデバイス名を指定できるようにしました。

バイス名を指定してプロセスを起動します。 起動したらタッチスクリーンに触れて動かしてみてください。 次のようなログが出力されると思います。

iex(1)> TouchScreen.start_link("ADS7846 Touchscreen")
{:ok, #PID<0.189.0>}
iex(2)> 
10:30:45.185 [debug] {:input_event, "/dev/input/event0", [{:ev_key, :btn_touch, 1}, {:ev_abs, :abs_x, 3246}, {:ev_abs, :abs_y, 1151}, {:ev_abs, :abs_pressure, 64778}]}
10:30:45.214 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 3252}, {:ev_abs, :abs_y, 1162}, {:ev_abs, :abs_pressure, 64753}]}
10:30:45.214 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 3249}, {:ev_abs, :abs_y, 1196}, {:ev_abs, :abs_pressure, 64745}]}
10:30:45.215 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 3293}, {:ev_abs, :abs_y, 1247}, {:ev_abs, :abs_pressure, 64724}]}
10:30:45.233 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 3156}, {:ev_abs, :abs_y, 1341}, {:ev_abs, :abs_pressure, 64777}]}
10:30:45.250 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 3050}, {:ev_abs, :abs_y, 1453}, {:ev_abs, :abs_pressure, 64695}]}
10:30:45.270 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 2944}, {:ev_abs, :abs_y, 1546}, {:ev_abs, :abs_pressure, 64835}]}
10:30:45.290 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 2894}, {:ev_abs, :abs_y, 1603}, {:ev_abs, :abs_pressure, 64888}]}
10:30:45.310 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 2872}, {:ev_abs, :abs_y, 1626}, {:ev_abs, :abs_pressure, 64895}]}
10:30:45.330 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 2882}, {:ev_abs, :abs_y, 1618}, {:ev_abs, :abs_pressure, 64886}]}
10:30:45.350 [debug] {:input_event, "/dev/input/event0", [{:ev_abs, :abs_x, 2886}, {:ev_abs, :abs_y, 1609}, {:ev_abs, :abs_pressure, 64898}]}
10:30:45.368 [debug] {:input_event, "/dev/input/event0", [{:ev_key, :btn_touch, 0}, {:ev_abs, :abs_pressure, 0}]}

メッセージは次の 3 つの値のタプルで構成されていることが確認できます。

  • :input_event
  • バイスのパス
  • イベントのリスト
    • 先に確認した touch, x, y, pressure の 4 種類のイベントのリストになっています

handle_info/2 の引数を次のように書くことで、指定したデバイスの InputEvent のメッセージのみをハンドリングすることができそうです。

  def handle_info({:input_event, path, events}, %{path: path} = state) do
    # ...
  end

一つのイベントに畳み込む

イベントは touch や x や y とった要素に別れた状態で受け取りますが、利用するばあいには「(x, y) の位置にタッチされた」といった一つのイベントになっていた方が扱いやすくなります。

Enum.reduce/2 を使って、複数のイベントの要素を一つのマップに畳み込みます(このあと pressure の値を使う予定がないので、pressure は畳み込みの対象から外しています)。

加えて、畳み込んだ結果の :touch の値を調べて 1 であれば down0 であれば up 、それ以外であれば move と扱うようにしました。 これら down, up, move のイベントは、あとで Scenic の入力になります。

  def handle_info({:input_event, path, events}, %{path: path} = state) do
    events
    |> Enum.reduce(%{touch: nil, x: 0, y: 0}, fn
      {:ev_abs, :abs_x, x}, event -> %{event | x: x}
      {:ev_abs, :abs_y, y}, event -> %{event | y: y}
      {:ev_key, :btn_touch, touch}, event -> %{event | touch: touch}
      _, event -> event
    end)
    |> case do
      %{touch: 1, x: x, y: y} ->
        Logger.debug("down (#{x}, #{y})")

      %{touch: 0, x: x, y: y} ->
        Logger.debug("up (#{x}, #{y})")

      %{x: x, y: y} ->
        Logger.debug("move (#{x}, #{y})")

      event ->
        Logger.debug("unknown event #{inspect(event)}")
    end

    {:noreply, new_state}
  end

Scenic - タッチスクリーンに表示する

次に Scenic を使ってタッチスクリーンに表示してみます。

プロジェクトを用意する

まず Scenic のプロジェクトを作成するために、 scenic_new をインストールします。

hex.pm

$ mix archive.install hex scenic_new

mix scenic.new というタスクが追加されるので、これを使って新しいプロジェクトを作成します。

$ mix scenic.new touch_screen_scenic
$ cd touch_screen_scenic

mix.exsdeps/0 を編集します。

Raspberry Pi の環境ですので、 scenic_driver_glfwscenic_driver_nerves_rpi を置き換えます。

また入力では input_event を利用するので、これも追加しておきます。

なお、この記事を書いている 2021-05-23 現在、font_metrics の最新は 0.5.1 なのですが Scenic がこのバージョンに対応していない様子でした。 このため、font_metrics のバージョンを 0.3.x に固定するために font_metricsdeps/0 に記述しておきます。

  defp deps do
    [
      {:scenic, "~> 0.10"},
      {:scenic_driver_nerves_rpi, "~> 0.10.1"},
      {:input_event, "~> 0.4"},
      {:font_metrics, "~> 0.3.0"}
    ]
  end

依存するパッケージを取得しコンパイルします。

$ mix deps.get
$ mix deps.compile

もし C のコードのコンパイルに失敗するばあいには、include と lib のパスを追加して試してみてください。

$ export C_INCLUDE_PATH=/opt/vc/include:$C_INCLUDE_PATH
$ export LIBRARY_PATH=/opt/vc/lib:$LIBRARY_PATH

config を編集する

config/config.exs を編集し設定をデバイスに合わせます。

:size を利用している表示のサイズに合わせます。 サイズは /boot/config.txthdmi_cvt で設定されていると思いますのでその値にします。 わたしは 480x320 で利用しているので、size: {480, 320} と設定しています。

また、ドライバを scenic_driver_nerves_rpi に置き換えましたので、ドライバに指定するモジュールも Scenic.Driver.Nerves.Rpi に変更します。

config :touch_screen_scenic, :viewport, %{
  name: :main_viewport,
  size: {480, 320},
  default_scene: {TouchScreenScenic.Scene.Home, nil},
  drivers: [
    %{
      module: Scenic.Driver.Nerves.Rpi
    }
  ]
}

表示を確認します。

iex -S mix もしくは mix scenic.run で起動してみてください。 問題がなければスクリーンに lib/scenes/home.ex に記述された文字列( This is a very simple starter application. 等)が表示されると思います。

入力用のモジュールを用意する

先ほど入力を確認するために書いたモジュールを再利用します。 このあとドライバとして利用する予定ですので、lib/touch_screen_scenic/driver.ex にコピーしてモジュール名も TouchScreenScenic.Driver としておきます。

defmodule TouchScreenScenic.Driver do
  use GenServer

  # ...
end

iex -S mix で起動し、モジュールが機能することを確認しておきます。

iex(1)>TouchScreenScenic.Driver.start_link("ADS7846 Touchscreen")
{:ok, #PID<0.203.0>}
iex(2)> 
10:46:21.065 [debug] down (2921, 1721)
10:46:21.081 [debug] move (2879, 1718)

calibration

先にタッチスクリーンの入力は x, y それぞれ 0〜4095 ということを確認しました。 一方で表示領域のサイズは 480x320 です。

これをタッチした位置と表示の位置が一致するようにキャリブレーションします。

キャリブレーションの方法

結論から言うと、タッチした位置 (x_touch, y_touch) から表示の位置 (x_screen, y_screen) は、次のような式で求めることができます。

x_screen = ax * x_touch + bx * y_touch + dx
y_screen = ay * x_touch + by * y_touch + dy

この 6 つの定数 ax, bx, dx, ay, by, dy は 3 点での対応がわかれば算出することができます。

Texas Instruments の ADS7846 のデータシートのページからキャリブレーションの方法についてのドキュメントのリンクがあります。 詳しくはこれらの資料を確認してみてください。

タッチの位置と表示の位置の対応を調べる

スクリーン上に指定した位置に表示した図形をタッチし、その時の入力を確認することで、タッチした位置と表示の位置の対応を調べます。

ここでは、スクリーンの上下左右それぞれ 20 ピクセルの位置に縦横 2 本ずつの線を表示します。

lib/scenes/home.ex の init/2 を次のように編集します。

  def init(_, opts) do
    graph =
      Graph.build(font: :roboto, font_size: @text_size)
      |> line({{0, 20}, {480, 20}}, stroke: {1, :white})
      |> line({{0, 300}, {480, 300}}, stroke: {1, :white})
      |> line({{20, 0}, {20, 320}}, stroke: {1, :white})
      |> line({{460, 0}, {460, 320}}, stroke: {1, :white})

    {:ok, graph, push: graph}
  end

iex -S mix で起動すると 4 本の線が表示され、交点が 4 つできます。 このうちの 3 点をタッチしそのときのログを確認します。

わたしが確認したときは次のような値を得ることができました。

表示上の点 タッチした点
左上 (20, 20) (3653, 3777)
右上 (460, 20) (3682, 369)
右下 (460, 300) (425, 352)

ここから行列演算すれば欲しい値が得られるわけですが、計算はそれらが得意な言語にまかせることにします。

おもむろに Julia を起動して計算します。

julia> a = [3653 3777 1; 3682 369 1; 425 352 1]
3×3 Matrix{Int64}:
 3653  3777  1
 3682   369  1
  425   352  1

a の逆行列をスクリーン上の 3 点の x 座標にかけると、ax, bx, dx が求まります。

julia> inv(a) * [20; 460; 460]
3-element Vector{Float64}:
   0.000673852686974119
  -0.1291022471455627
 505.15760360327397

同様に ay, by, dy を求めます。

julia> inv(a) * [20; 20; 300]
3-element Vector{Float64}:
  -0.0859648647083078
  -0.0007315085318488684
 336.7925585042416

4 桁目で丸めるてだいたいこれくらい。

ax = 0.0006739
bx = -0.1291
dx = 505.2
ay =-0.08596
by = -0.0007315
dy = 336.8

入力を扱う TouchScreenScenic.Driverキャリブレーションのコードを追加します。

calibrate/1 を追加して、受け取ったイベントの x, y の値を変換します。

あらためて iex -S mix で起動し直します。 TouchScreenScenic.Driver.start_link("ADS7846 Touchscreen") で入力プロセスを起動しスクリーンにタッチすると、今度はスクリーンの位置とタッチした位置がおおよそ一致することが確認できると思います。

  def handle_info({:input_event, path, events}, %{path: path} = state) do
    events
    |> Enum.reduce(%{touch: nil, x: 0, y: 0}, fn
      {:ev_abs, :abs_x, x}, event -> %{event | x: x}
      {:ev_abs, :abs_y, y}, event -> %{event | y: y}
      {:ev_key, :btn_touch, touch}, event -> %{event | touch: touch}
      _, event -> event
    end)
    |> case do
      %{touch: 1, x: x, y: y} ->
        Logger.debug("down #{inspect(calibrarte({x, y}))}")

      {%{touch: 0, x: x, y: y}, _} ->
        Logger.debug("up #{inspect(calibrarte({x, y}))}")

      %{x: x, y: y} ->
        Logger.debug("move #{inspect(calibrarte({x, y}))}")

      event ->
        Logger.debug("unknown event #{inspect(event)}")
    end

    {:noreply, new_state}
  end

  defp calibrate({x, y}) do
    ax = 0.0006739
    bx = -0.1291
    dx = 505.2
    ay =-0.08596
    by = -0.0007315
    dy = 336.8

    {
      ax * x + bx * y + dx,
      ay * x + by * y + dy
    }
  end

Scenic 用ドライバを書く

ここまででスクリーンへの表示とタッチ入力の取得ができるようになりました。 最後に TouchScreenScenic.Driver が Scenic のドライバとして利用できるように、手を加えていきます。

TouchScreenScenic.Driver を Scenic のドライバに指定する

まず TouchScreenScenic.Driver を Scenic のドライバとして登録します。

すでに Scenic.Driver.Nerves.Rpi の登録で見たように、ドライバとして登録するには config/config.exs を編集し、:drivers のリストに設定を追加します。

今回はドライバに渡す設定(デバイス名とキャリブレーションの数値)も一緒に記述します。 ドライバの設定はマップからできていて、キー :module にモジュールの識別子を指定します。

次にキー :opts に、ドライバに渡したい値を記述します。 ここでは :device にデバイス名を、 :calibrationキャリブレーションの数値を記述します。 この :opts の値は後述の init/3 に第 3 引数としてそのまま渡ります。

config :touch_screen_scenic, :viewport, %{
  name: :main_viewport,
  size: {480, 320},
  default_scene: {TouchScreenScenic.Scene.Home, nil},
  drivers: [
    %{
      module: Scenic.Driver.Nerves.Rpi
    },
    %{
      module: TouchScreenScenic.Driver,
      opts: [
        device: "ADS7846 Touchscreen",
        calibration: {
          {0.0006739, -0.1291, 505.2},
          {-0.08596, -0.0007315, 336.8}
        }
      ]
    }
  ]
}

Scenic.ViewPort.Driver を use する

Scenic のドライバとして利用するには、use で GenServer の代わりに Scenic.ViewPort.Driver を指定します。

defmodule TouchScreenScenic.Driver do
  use Scenic.ViewPort.Driver

  # ...
end

実装を確認すると Scenic.ViewPort.DriverGenServer として起動され、メッセージを受信したらそれを use したモジュールに転送する、というしくみになっています。

このようなしくみため、 GenServer を起動するための関数 start_link/1 が不要になります。 Scenic.ViewPort.Driver から削除します。

init/3 - ドライバを初期化する

初期化のときは GenServerinit/2 に代わり init/3 がコールバックで呼び出されるようになります。

init/3 は 3 つの引数をとります。

  1. Scenic.ViewPort プロセスの pid 。入力したイベントをこのプロセスに送ります
  2. スクリーンサイズ。config/config.exs で :size に指定した値です。今回は利用しません
  3. config/config.exs で :opts に設定したオプション。config/config.exs で設定したデバイス名とキャリブレーションの値をここから取り出します

また ViewPort の pid とキャリブレーションの値はタッチ入力のイベントを受けたときに利用しますので、state に格納しておきます。

  def init(viewport, {_, _}, opts) do
    {path, _} =
      InputEvent.enumerate()
      |> Enum.find(fn {_, %{name: name}} -> name == opts[:device] end)

    InputEvent.start_link(path)

    {:ok, %{path: path, viewport: viewport, calibration: opts[:calibration]}}
  end

handle_info/2 & calibrate/2

最後に handle_info/2 でイベントを受け取ったら Scenic.ViewPort.input/2 に送る処理を書きます。

down, up, move と書いたイベントは、 Scenic.ViewPort.input/2 のイベントに対応づけることができます。

入力 Scenic.ViewPort.input/2 に送るイベント
down :cursor_button:press
up : cursor_button:release
move :cursor_pos

:cursor_button はボタンの種類(左、右、中央)を指定できますが、ここでは左ボタン :left として扱うことにします。

他にどのようなイベントを指定できるかはScenic.ViewPort.input/2 のドキュメントを参照してみてください。

またキャリブレーションは、state に格納した :calibration の値を渡して計算するように修正します。

  def handle_info({:input_event, path, events}, %{path: path} = state) do
    events
    |> Enum.reduce(%{touch: nil, x: 0, y: 0}, fn
      {:ev_abs, :abs_x, x}, event -> %{event | x: x}
      {:ev_abs, :abs_y, y}, event -> %{event | y: y}
      {:ev_key, :btn_touch, touch}, event -> %{event | touch: touch}
      _, event -> event
    end)
    |> case do
      %{touch: 1, x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("down #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_button, {:left, :press, 0, pos}})

      %{touch: 0, x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("up #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_button, {:left, :release, 0, pos}})

      %{x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("move #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_pos, pos})

      event ->
        Logger.debug("unknown event #{inspect(event)}")
    end

    {:noreply, state}
  end

  defp calibrate({{ax, bx, dx}, {ay, by, dy}}, {x, y}) do
    {
      (ax * x) + (bx * y) + dx,
      (ay * x) + (by * y) + dy
    }
  end

TouchScreenScenic.Driver 全体像

最終的に TouchScreenScenic.Driver は次のようになりました。

defmodule TouchScreenScenic.Driver do
  use Scenic.ViewPort.Driver

  require Logger

  def init(viewport, {_, _}, opts) do
    {path, _} =
      InputEvent.enumerate()
      |> Enum.find(fn {_, %{name: name}} -> name == opts[:device] end)

    InputEvent.start_link(path)

    {:ok, %{path: path, x: 0, y: 0, viewport: viewport, calibration: opts[:calibration]}}
  end

  def handle_info({:input_event, path, events}, %{path: path} = state) do
    events
    |> Enum.reduce(%{touch: nil, x: 0, y: 0}, fn
      {:ev_abs, :abs_x, x}, event -> %{event | x: x}
      {:ev_abs, :abs_y, y}, event -> %{event | y: y}
      {:ev_key, :btn_touch, touch}, event -> %{event | touch: touch}
      _, event -> event
    end)
    |> case do
      %{touch: 1, x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("down #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_button, {:left, :press, 0, pos}})

      %{touch: 0, x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("up #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_button, {:left, :release, 0, pos}})

      %{x: x, y: y} ->
        pos = calibrate(state.calibration, {x, y})
        Logger.debug("move #{inspect(pos)}")
        Scenic.ViewPort.input(state.viewport, {:cursor_pos, pos})

      event ->
        Logger.debug("unknown event #{inspect(event)}")
    end

    {:noreply, state}
  end

  defp calibrate({{ax, bx, dx}, {ay, by, dy}}, {x, y}) do
    {
      (ax * x) + (bx * y) + dx,
      (ay * x) + (by * y) + dy
    }
  end
end

Scenic で利用する

最後に、本当に Scenic でイベントを受け取れるか、確認します。

lib/scenes/home.ex を次のように編集します。

defmodule TouchScreenScenic.Scene.Home do
  use Scenic.Scene
  require Logger

  alias Scenic.Graph

  import Scenic.Primitives
  import Scenic.Components

  @text_size 24

  def init(_, _opts) do
    graph =
      Graph.build(font: :roboto, font_size: @text_size)
      |> button("Greet", id: :btn_greet, translate: {200, 140}, width: 80, height: 40)
      |> text("", id: :text_greet, translate: {240, 200}, text_align: :center)

    {:ok, %{graph: graph, text: ""}, push: graph}
  end

  def filter_event({:click, :btn_greet}, _, %{graph: graph, text: text} = state) do
    {caption, text} =
      case text do
        "" -> {"Clear", "Hello!"}
        _ -> {"Greet", ""}
      end

    graph =
      graph
      |> Graph.modify(:btn_greet, &button(&1, caption))
      |> Graph.modify(:text_greet, &text(&1, text))

    {:noreply, %{state | graph: graph, text: text}, push: graph}
  end

  def handle_input(event, _context, state) do
    Logger.info("Received event: #{inspect(event)}")
    {:noreply, state}
  end
end

実行すると、Greet と書かれたボタンが一つスクリーンの中央に表示されます。

ボタンを押すと、ボタンの下に Hello! と表示されるはずです。 ボタンの表示は Clear に替わり、もう一度ボタンを押すと Hello! の文字が消えます。

ドライバがきちんと設定できていれば、ボタンが表示されている領域を押したときだけボタンが反応してくれます。

いつか読むはずっと読まない:知恵の箱、金の鍵、勇気の棒、願いの杖、…

「知恵の箱」 自分にとって何が正しくて何が正しくないかを見分ける能力。

「金の鍵」 新たに学習し、実践する分野への扉を開き、それが今の自分に合わなければ閉める能力。

「勇気の棒」 新しいことに挑戦し、失敗の危険を犯す勇気。

「願いの杖」 自分の欲しいものを求め、必要とあれば、それが手に入らなくてもどうにかする能力。

何年も前に手にした本です。 当時もこの本に勇気付けられた。

あらためてページを開くと、当時よりもより響く感じがします。 当時よりものごとが見えるようになったから、と思いたいところ。

あらためて読み返してみようと思います。

コンサルタントの道具箱

コンサルタントの道具箱