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

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

日ごとの更新件数をEctoで集計したいとき

日ごとの更新件数をEctoで集計したいとき

データベースへは時刻(日時)で記録しておき、利用するときに日付単位で集計したいときがあります。

例えば更新の頻度を日付単位で集計したいばあい、次のような SQL になると思います。

select
  date(updated_at) as updated_date,
  count(1)
from
  items
group by
  updated_date
order by
  updated_date

これを Ecto で実現したい、というのが今回のテーマです。

ちなみに補足をしておくと。 Ecto はデータベース操作のための Elixir のライブラリです。 ですので今回も Elixir の話題です。 念のため。

前提

ここで Repoリポジトリモジュール、Itemitems テーブルのスキーマモジュール、Ecto.Query モジュールは import 済みとします。

hexdocs.pm

hexdocs.pm

hexdocs.pm

日付のみを取得する

まず、データベース上で日付のみを取得したいので、時刻から日付を取得するデータベースの関数 date を利用することが前提になります。

データベースの関数を利用するには、記述した SQL 片を直接データベースに適用する Ecto.Query.API.fragment/1 を利用します。

hexdocs.pm

from(
  i in Item,
  select: fragment("date(?) as updated_date", i.updated_at)
)
|> Repo.all()

生成される SQL は次のようになります(読みやすいように整形しています)。

SELECT
  date(i0."updated_at") as updated_date
 FROM
   "items" AS i0

頻度を数える

頻度を得るためには group by を利用しますが、group_by: "updated_date"group_by: i.updated_date とは記述できません。 updated_dateSQL に「直書き」された名前なので、参照するばあいにも fragment を使う必要があります。

from(
  i in Item,
  group_by: fragment("updated_date"),
  select: %{
    updated_date: fragment("date(?) as updated_date", i.updated_at),
    count: count(1)
  }
)
|> Repo.all()

生成される SQL を確認すると、最初に書いた SQL と同じ構造になっていることがわかります。 これでまずは目的を達成することができました。

SELECT
  date(i0."updated_at") as updated_date,
  count(1)
FROM
  "items" AS i0
GROUP BY
  updated_date

…できましたが。 クエリとして融通が効きませんし、SQL 片を直接組み立てているので危うさがあります。

サブクエリを使う

もっとよい方法がないかと Elixir Forum を探した結果、サブクエリ Ecto.Query.subquery/2を使う方法に行き当たりました。

elixirforum.com

hexdocs.pm

まず、日付だけを抽出するクエリを組み立てます。 このとき select のパラメータはマップ形式にしてキーで値を引けるようにしておきます。

dates =
  from(
    i in Item,
    select: %{updated_date: fragment("date(?)", i.updated_at)}
  )

日付のクエリを subquery を使って頻度を取得するクエリに埋め込みます。 日付の値をキーで取得できるようにしておいたので、新しいクエリの中でもそのキーを利用することができます。

from(
  d in subquery(dates),
  group_by: d.updated_date,
  select: %{
    updated_date: d.updated_date,
    count: count(1)
  }
)
|> Repo.all()

生成される SQL も、当然ですが、サブクエリで構築されています。

SELECT
  s0."updated_date",
  count(1)
FROM
  (
    SELECT
      date(si0."updated_at") AS "updated_date"
     FROM
      "items" AS si0
  ) AS s0
GROUP BY
  s0."updated_date"

今回のケースではサブクエリを使うまでもないかもしれませんが、複数の手段を用意しておいて使い分けできるとよさそうです。

いつか読むはずっと読まない:没後20年の遺作

ティーブン・ジェイ・グールドが2002年に亡くなられて20年近くが経ちましたが。 亡くなる直前に刊行された書籍の邦訳が今秋刊行されました。

実は。 グールド最後のエッセイ集を、読んでしまうのが惜しいと、手をつけられずにいます。

ですが。 これを機に、ページを開こうかと思います。

選択したファイルをプレビュー表示する

ファイルをアップロードするときに利用する input タグ <input type="file"> ですが、ファイルを選択した時点でブラウザ上で画像データを取得することできることを利用してプレビュー表示させる方法です。

過去に使ったことがあったんですが、件のコードにしか情報がなかったので、あらためてブログに記録です。

アイディアとしては、ファイルが選択されたときの change イベントの中で、選択されたファイルを FileReader で読み出し、最終的に Base64エンコードして img タグに放り込む、というものです。

readAsArrayBuffer 関数は非同期なので、完了時に呼び出されるハンドラ onload 内に読み出したデータの処理を記述しています。

データの変換がなかなかケイオスなので、もう少しどうにかならないかという思いがあるところ。

<!DOCTYPE html>
<html>
  <body>
    <div>
      <input type="file" id="file-input">
    </div>

    <div>
      <img id="preview-image">
    </div>

    <script>
      const fileInput = document.getElementById('file-input')
      const previewImage = document.getElementById('preview-image')

      fileInput.addEventListener('change', (event) => {
        const reader = new window.FileReader()
        const file = event.target.files[0]

        reader.onload = (readerEvent) => {
          const data = new Uint8Array(readerEvent.target.result)

          const chunkSize = 0x8000;
          const chars = [];
          for (let i = 0; i < data.length; i += chunkSize) {
            chars.push(String.fromCharCode.apply(null, data.subarray(i, i + chunkSize)));
          }
          const encodedData = window.btoa(chars.join(''))

          previewImage.src = `data:${file.type};base64,${encodedData}`
        };

        reader.readAsArrayBuffer(file)
      })
    </script>
  </body>
</html>

Phoenix 1.6 と HEEx

先の日曜日、2021-09-26 に Phoenix 1.6 がリリースされました!

www.phoenixframework.org

…が、なぜかブログ記事の日付は August 26th, 2021 。

今回も目玉はいくつかあるのですが、その中でも気になる存在が HEEx 。

新しく追加されたシジル ~H を使ってコンポーネントを定義することができます。

lib/my_app_web/views/page_view.ex

defmodule MyAppWeb.PageView do
  use MyAppWeb, :view

  def greeting(assigns) do
    ~H"""
      Hello <%= @target %>
    """
  end
end

テンプレートファイルも、拡張子 .heex が標準になっていて、上で定義したコンポーネントを利用できるようになっています。

lib/my_app_web/templates/page/index.html.heex

<.greeting target="world" />

またパラメータも <%= %> に代わる構文として { } で記述することができるようになりました。

lib/my_app_web/controllers/page_controller.ex

defmodule MyAppWeb.PageController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> assign(:name, "Phoenix")
    |> render("index.html")
  end
end
<.greeting target={@name} />

…ただ、個人的には。 テンプレートがコードに混ざる構文はいまひとつ気になるところ。

このところ仕事が佳境で時間が取れていないのですが、追っていろいろと試してみようと思います。

Alpine.js が 3.x になり、いっそう利用しやすくなった

記事にするのが遅くなりましたが、6 月に Alpine.js が 3.0 にメジャーバージョンアップしました(ちなみに 2021-08-29 現在の最新は 3.2.4)。

alpinejs.dev

色々と改善されていますが、個人的に一番大きな利点はロジックをグローバルスコープに公開しなくてもよくなった点と思っています。

Alpine.js については昨年もブログに書いていますが、この時点では ActionCable のロジックと、グローバルスコープにある Alpine.js のロジックをどう繋げるか頭を捻りました。

blog.emattsan.org

3.x では Alpine.data という関数が追加されたことで、ロジックを分離して記述することが楽になりました。

Ajax を使ったサンプル

何かしらのユーザの一覧、ID と名前とメールアドレスの一覧、を Ajax で表示するサンプルとして書いてみました。

サーバ

単純にユーザの配列を JSON 形式で返すサーバです。

Sinatra で記述しています。 ユーザデータは Faker を使って生成しています。

require 'json'
require 'sinatra'
require 'faker'

users = 20.times.map {|i| {id: i, name: Faker::Name.name, email: Faker::Internet.email} }

get '/' do
  users.to_json
end

クライアント

イベントのハンドリングと状態の管理を Alpine.js で、HTTP リクエストを Aixos で記述しています。

3.x になり、明示的にスタートを記述することが必須になりました。 これも個人的にはタイミングを自分で管理できるのでよい変更と感じています。

スタートすると document のイベント alpine:init が発火するので Alpine.data 関数で HTML の要素と Alpine.js のデータを関連付けます。

初期化時に init() が呼び出されるので、ここで Axios を利用してサーバからユーザの一覧を取得します。

import Alpine from "alpinejs"
import axios from "axios"

function User() {
  return {
    users: [],

    fetch() {
      const url = new URL("http://localhost:4567")

      axios
        .get(url)
        .then((resp) => {
          this.users = resp.data
        })
    },

    init() {
      this.fetch()
    }
  }
}

document.addEventListener("alpine:init", () => {
  Alpine.data("User", User)
})

document.addEventListener("DOMContentLoaded", () => {
  Alpine.start()
})

HTML

ユーザの一覧を表示する HTML です。

x-data="User" を指定した HTML 要素に Alpine.data で指定したデータが関連付けられます。

x-for で Alpine.js のデータの users の内容を表示します。

    <div x-data="User">
      <table>
        <thead>
          <tr>
            <th>id</th>
            <th>name</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          <template x-for="user in users", :key="user.id">
            <tr>
              <td x-text="user.id"></td>
              <td x-text="user.name"></td>
              <td x-text="user.email"></td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>

これだけで Ajax を使ったユーザの一覧表示が実現できました。

f:id:E_Mattsan:20210829194745p:plain

フィルタを追加してみる

次に Alpine.js を使ったフィルタを追加してみます。

サーバ

サーバ側では、パラメータで受け取った文字列にマッチしたユーザデータだけを返すようにします。

require 'json'
require 'sinatra'
require 'faker'

users = 20.times.map {|i| {id: i, name: Faker::Name.name, email: Faker::Internet.email} }

get '/' do
  filter = params['filter']
  users.select {|user| user[:name].include?(filter) }.to_json
end

クライアント

クライアントでは、フィルタの文字列を格納する filterString と、フィルタリング実行のイベントを受ける filtrate 関数を追加します。

fetch 関数は、クエリ文字列にフィルタの文字列を付けてリクエストを送るように修正します。

import Alpine from "alpinejs"
import axios from "axios"

function User() {
  return {
    users: [],
    filterString: '',

    fetch() {
      const url = new URL("http://localhost:4567")
      url.search = `filter=${this.filterString}`

      axios
        .get(url)
        .then((resp) => {
          this.users = resp.data
        })
    },

    init() {
      this.fetch()
    },

    filtrate() {
      this.fetch()
    }
  }
}

document.addEventListener("alpine:init", () => {
  Alpine.data("User", User)
})

document.addEventListener("DOMContentLoaded", () => {
  Alpine.start()
})

HTML

フィルタの文字列を入力するフォームを追加します。

フォームのイベント submit に、関数 filtrate を割り当てます。 ここで preventpreventDefault 関数と同じ働きをしています。

次に input には x-modelfilterStrign を割り当てます。 これによって inputvalueflterString が連動するようになるので、JavaScript のコードから filterString を参照することで inputvalue が参照でき、filterString を変更することで inputvalue を変更するすることができるようになります。 また同じように inputvalue を参照/変更すると、filterString を参照/変更できます。

    <div x-data="User">
      <form @submit.prevent="filtrate">
        <input type="text" x-model="filterString" />
      </form>
      <table>
        <thead>
          <tr>
            <th>id</th>
            <th>name</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          <template x-for="user in users", :key="user.id">
            <tr>
              <td x-text="user.id"></td>
              <td x-text="user.name"></td>
              <td x-text="user.email"></td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>

このように振る舞いを手順ではなく宣言で記述することができるようになっています。

f:id:E_Mattsan:20210829200249p:plain

Alpine.js の利点

フロントエンドを構築する JavaScript のライブラリは多数存在していますが、個人的に Alpine.js を推す理由は、このロジックを分離できる点です。 個人的には、ロジックはロジック、ビューはビューで分離して考えたいという考えをしています。

わたしがフロントエンドに明るいわけでないので、誤った認識をしているかもしれませんが、どうもフロントエンドのライブラリというと、ロジックからビューまでをまとめたコンポーネント単位で開発する印象があります。 その点で、Alpine.js はその辺の結合が緩いため、他の単なる JavaScript のライブラリと同じようにロジックを .js ファイルに記述し、ビューを .html ファイルに記述するだけで利用することができ、コンポーネント形式で記述されたファイルを変換するような工程がいりません。

また、わたしの場合ウェブアプリケーションの開発には主に Ruby on Rails を利用していますが、そこでも HTML のレンダリングRails にまかせた上で、JavaScript のロジックだけに注力して利用できるという利点があります。

実のところ。Ruby on Rails でアプリケーション開発をしていく中、どこかで効果的に活用しようとタイミングを見計っているところです。

いつか読むはずっと読まない:余裕なき Slack

"Slack" というチャットサービスがありますが。 個人的に相性がよくないのか、使い勝手がよくありません。

なんか、使っていると余裕がなくなると言いますか、落ち着かなくなると言いますか、気楽にメッセージをポストできる感じでないのです。

なんか…こう…気楽に使えるチャットサービスが欲しいですね…。

PDF.js を使って PDF をブラウザに表示するための覚書

仕事で PDF をブラウザのページ中に埋め込んで表示したいことがあり、そのときに PDF.js を利用しました。

のちのち再び使いたくなったときのために、骨格を抜き出してまとめたものです。

サーバには Elixir の Phoenix framework を使っていますが、ほぼ静的なページで Phoenix らしいことは特に何もしていないので、Rails などでも同じように使える、はずです。

mozilla.github.io

assets/js/MyPdf.js

PDF.js の利用をこの一つのファイルに分離しています。

内容としては PDF.js の example にあるものと同じですが、class を使った記述に書き換えています。

class MyPdf {
  constructor(canvas, afterRendered) {
    this.canvas = canvas
    this.context = this.canvas.getContext('2d')
    this.pageNumber = 1
    this.rendering = false
    this.afterRendered = afterRendered
  }

  setAfterRendered(afterRendered) {
    this.afterRendered = afterRendered
  }

  loadDocument(path) {
    const renderPdf = (pdf) => {
      this.pdf = pdf
      this.getPage()
    }

    pdfjsLib
      .getDocument(path)
      .promise
      .then(renderPdf)
  }

  setPage(pageNumber) {
    if (1 <= pageNumber && pageNumber <= this.pdf.numPages && !this.rendering) {
      this.pageNumber = pageNumber
      this.getPage()
    }
  }

  nextPage() {
    this.setPage(this.pageNumber + 1)
  }

  prevPage() {
    this.setPage(this.pageNumber - 1)
  }

  getPage() {
    if (!this.rendering) {
      this.rendering = true
      this.pdf.getPage(this.pageNumber).then(this.renderPage.bind(this))
    }
  }

  renderPage(page) {
    const viewport = page.getViewport({scale: 1})

    this.canvas.height = viewport.height
    this.canvas.width = viewport.width

    page
      .render({ canvasContext: this.context, viewport })
      .promise
      .then(() => {
        if (this.afterRendered) { this.afterRendered(this.pdf.numPages, this.pageNumber) }
        this.rendering = false
      })
  }
}

export { MyPdf }

assets/js/app.js

MyPdf を利用します。 HTML の要素の操作はこちらで済ませて MyPdf ではそれらを意識せずに済むようにしています。

import "../css/app.scss"
import "phoenix_html"

import { MyPdf } from './MyPdf'

const myPdf = new MyPdf(document.getElementById('the-canvas'))

myPdf.setAfterRendered((numPages, pageNumber) => {
  document.getElementById('page').innerText = `${pageNumber} / ${numPages}`
})

document.getElementById('next').addEventListener('click', myPdf.nextPage.bind(myPdf))
document.getElementById('prev').addEventListener('click', myPdf.prevPage.bind(myPdf))

myPdf.loadDocument('sample.pdf')

lib/pdfjs_web/templates/layout/app.html.eex

レイアウトテンプレートです。

CDN から PDF.js を読み込んでいます。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Pdfjs · Phoenix Framework</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.9.359/pdf.min.js" integrity="sha512-U5C477Z8VvmbYAoV4HDq17tf4wG6HXPC6/KM9+0/wEXQQ13gmKY2Zb0Z2vu0VNUWch4GlJ+Tl/dfoLOH4i2msw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
  <body>
    <main role="main" class="container">
      <%= @inner_content %>
    </main>
  </body>
</html>

lib/pdfjs_web/templates/page/index.html.eex

PDF の表示領域とページ送りのボタンを配置したテンプレートです。

先に書いた通り、このテンプレートの要素と MyPdf を、 assets/js/app.js が結びつけています。

<button id="prev">&lt;&lt;</button>
<span id="page"></span>
<button id="next">&gt;&gt;</button>

<canvas id="the-canvas"></canvas>

Discord Webhook で Ruby からファイルを POST する

昨年の 10 月に Discord Webhook で Elixir からファイルを POST する方法を記事に書きましたが、その Ruby 版です。

テキストをPOSTする

テキストのみの場合、net/httpNet::HTTP.post_form メソッドを使うことで簡単に POST できます。 パラメータとして、本文は content 、ユーザ名は username をキーにした Hash を渡します。 このとき content は必須です。

そのほかのフィールドは Discord のドキュメントを参照してください。

ここでは Webhook の URL は次のように環境変数 WEBHOOK_URL に設定してあることにします。

$ export WEBHOOK_URL=https://discord.com/api/webhooks/****/*******

あとは Webhook の URL 、JSON エンコードした body 、ヘッダにコンテントタイプを指定して POST すれば OK です。

require 'net/http'

Net::HTTP.post_form(
  URI.parse(ENV['WEBHOOK_URL']),
  {
    username: 'emattsan',
    content: 'Hello'
  }
)

ファイルを POST する

前回の Elixir の記事でも書きましたが、ファイルをアップロードするときは JSON でなくて multipart/form-data で POST する必要があります。

multipart-post gem を使う

ありがたいことに multipart を扱うための gem が公開されています。

multipart で POST するためのクラス Net::HTTP::Post::Multipart と、ファイルを扱うためのヘルパクラス UploadIO を使います。

まず POST したいファイルから UploadIO のオブジェクトを作成します。

次に New::HTTP::Post::Multipart のオブジェクトを作成します。 ここでドキュメントにあるように、パラメータ の Hash にキーを file にして UploadIO のオブジェクトを渡します。

あとは Net::HTTP::Post を利用するばあいと同じ手順で POST できます。

require 'json'
require 'net/http'
require 'net/http/post/multipart'

url = URI.parse(ENV['WEBHOOK_URL'])

file =
  UploadIO.new(
    File.new('./images/image.jpg'), # POST するファイル
    'image/jpeg',                   # ファイルの Content-Type
    'image.jpg'                     # POST されたときのファイル名
  )

req =
  Net::HTTP::Post::Multipart.new(
    url.path,
    username: 'emattsan',
    content: 'Hello',
    file: file
  )

http = Net::HTTP.new(url.host, url.port)

http.use_ssl = true

http.request(req)

自力で multipart を書く

有用な gem があるのでわざわざ自力で書かなくてもよいのですが、multipart の理解のため、また万が一公開されている gem を利用できないケースのため、一例を上げておきます。

multipart ではバウンダリで区切られた複数のコンテンツを POST の body に記述します。

えー…、詳しくは公式の資料とか Multipart messages - MIME - Wikipedia とか参照してみてください。

require 'net/http'

boundary = 'DiscordMultipartMessage'

url = URI.parse(ENV['WEBHOOK_URL'])

req =
  Net::HTTP::Post.new(
    url.path,
    'Content-Type': "multipart/form-data; boundary=#{boundary}"
  )

# body にバウンダリで区切ったフィールドを記述する
# このときファイルは読み込んだファイルの内容を挿入します
req.body = <<~BODY
--#{boundary}
Content-Disposition: form-data; name="username"

emattsan

--#{boundary}
Content-Disposition: form-data; name="content"

Hello

--#{boundary}
Content-Disposition: form-data; name="file"; filename="image.jpg"

#{File.read('./images/image.jpg')}

--#{boundary}--
BODY

http = Net::HTTP.new(url.host, url.port)

http.use_ssl = true

http.request(req)

いつか読むはずっと読まない:組合せ数学

組合せ論って面白いですよね。 読んでいるといつの間にか「どう書く」の問題を考えてしまっています。

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! の文字が消えます。

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

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

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

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

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

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

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

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

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

コンサルタントの道具箱

コンサルタントの道具箱