Skip to content

Instantly share code, notes, and snippets.

@mnishiguchi
Last active August 19, 2025 10:47
Show Gist options
  • Select an option

  • Save mnishiguchi/25568e3540191b3d713e21c451f18f1d to your computer and use it in GitHub Desktop.

Select an option

Save mnishiguchi/25568e3540191b3d713e21c451f18f1d to your computer and use it in GitHub Desktop.
3.5インチ SPI TFT を Elixir で駆動 (ili9486_elixir なしだけどili9486_elixir風)

3.5インチ SPI TFT を Elixir で駆動 (ili9486_elixir風)

Mix.install([
  {:circuits_gpio, "~> 2.1"},
  {:circuits_spi, "~> 2.0"},
  {:kino, "~> 0.15"}
])

はじめに

このノートブックでは、Raspberry Pi 上で、3.5インチ SPI LCD(グラフィック用 ILI9486 コントローラ)と抵抗膜式タッチパネル(XPT2046)を動かします。

ハードウェア

必要なハードウェア

対応 Raspberry Pi モデル

動作確認済み

  • Raspberry Pi 3 Model B
  • Raspberry Pi 4 Model B

その他の機種について
以下の機能を備えていれば、他の Pi ボードでも動作するはずです:

  • SPI0(ディスプレイ用)、SPI1(タッチ用)
  • GPIO 24(DC)、GPIO 25(RST)
# LCD (A) の場合: false
# LCD (C) の場合: true
is_high_speed = true

配線

LCD 側ピン 用途 Pi 側 GPIO ピン番号
DC Data/Command GPIO 24 18
RST Reset GPIO 25 22
CS LCD Select SPI0 CS0 24
T_CS Touch Select SPI1 CS0 26
SCLK SPI Clock GPIO 11 23
MOSI SPI Data Out GPIO 10 19
MISO SPI Data In GPIO 9 21
GND Ground GND 6
VCC Power (3.3V) 3.3V 1
VCC Power (5V) 5V 2

GPIO を開く

LCD を制御するうえで重要な信号を2つ GPIO で扱います:

  • DC(Data/Command):コマンドとデータを切り替える信号
  • RST(Reset):起動時にディスプレイをリセットする信号

これらのピンは一度だけ開き、その参照をキャッシュして使い回す設計にします。
以下のモジュールは Circuits.GPIO.open/2 で得たピン参照を保持し、重複オープンを防ぎます。

defmodule MyApp.GPIOCache do
  def open(pin, direction) do
    key = {:gpio_ref, pin}

    case :persistent_term.get(key, :not_found) do
      %Circuits.GPIO.CDev{} = ref ->
        ref

      _ ->
        case Circuits.GPIO.open(pin, direction) do
          {:ok, %Circuits.GPIO.CDev{} = ref} ->
            :persistent_term.put(key, ref)
            ref

          {:error, :already_open} ->
            raise """
            GPIO #{pin} is already open (not via GPIOCache).
            Please restart the notebook runtime and open pins only via GPIOCache.open/2.
            """

          {:error, reason} ->
            raise "Failed to open GPIO #{pin}: #{inspect(reason)}"
        end
    end
  end
end

alias MyApp.GPIOCache

gpio_dc  = GPIOCache.open(24, :output)
gpio_rst = GPIOCache.open(25, :output)

[gpio_dc, gpio_rst]

SPI を開く

ディスプレイ用とタッチ用の2つの SPI バスを開き、その設定を確認します。

# ディスプレイ用
{:ok, spi_lcd} =
  if is_high_speed do
    Circuits.SPI.open("spidev0.0", speed_hz: 125_000_000)
  else
    Circuits.SPI.open("spidev0.0", speed_hz: 16_000_000)
  end

# タッチ用, 50kHz
{:ok, spi_touch} = Circuits.SPI.open("spidev0.1", speed_hz: 50_000)

# 設定を表示
{:ok, spi_lcd_cfg} = Circuits.SPI.config(spi_lcd)
{:ok, spi_touch_cfg} = Circuits.SPI.config(spi_touch)

[
  Map.put(spi_lcd_cfg, :bus, "spidev0.0"),
  Map.put(spi_touch_cfg, :bus, "spidev0.1")
]
|> Kino.DataTable.new()

ILI9486 ドライバを初期化

前項で準備した SPI および GPIO バスを用いて、ILI9486 ドライバを初期化し、ディスプレイの制御を行います。

本記事では、ili9486_elixir パッケージを参考にしながら、動作確認に必要な最小限の機能を備えた ILI9486 モジュールを実装してみます。

なお、実際の開発では ili9486_elixir パッケージをそのまま利用すると便利です。

defmodule MyApp.ILI9486 do
  use GenServer
  import Bitwise

  ## Public interface

  def new(opts \\ []) do
    GenServer.start_link(__MODULE__, opts)
  end

  def reset(display_pid) do
    GenServer.call(display_pid, :reset)
  end

  def size(display_pid) do
    GenServer.call(display_pid, :size)
  end

  def pix_fmt(display_pid) do
    GenServer.call(display_pid, :pix_fmt)
  end

  def set_pix_fmt(display_pid, pix_fmt)
      when pix_fmt == :bgr565 or pix_fmt == :rgb565 or pix_fmt == :bgr666 or pix_fmt == :rgb666 do
    GenServer.call(display_pid, {:set_pix_fmt, pix_fmt})
  end

  def set_display(display_pid, status) when status == :on or status == :off do
    GenServer.call(display_pid, {:set_display, status})
  end

  def set_frame_rate(display_pid, frame_rate) do
    GenServer.call(display_pid, {:set_frame_rate, frame_rate})
  end

  def set_display_mode(display_pid, display_mode) do
    GenServer.call(display_pid, {:set_display_mode, display_mode})
  end

  def display_666(display_pid, image_data) when is_binary(image_data) or is_list(image_data) do
    GenServer.call(display_pid, {:display_666, image_data})
  end

  def display(display_pid, image_data, source_color)
      when is_binary(image_data) and (source_color == :rgb888 or source_color == :bgr888) do
    GenServer.call(display_pid, {:display, image_data, source_color})
  end

  def display_565(display_pid, image_data) when is_binary(image_data) or is_list(image_data) do
    GenServer.call(display_pid, {:display_565, image_data})
  end

  def command(display_pid, cmd, opts \\ []) when is_integer(cmd) do
    GenServer.call(display_pid, {:command, cmd, opts})
  end

  def data(_self_pid, []), do: :ok

  def data(display_pid, data) do
    GenServer.call(display_pid, {:data, data})
  end

  def send(display_pid, bytes, is_data)
      when (is_integer(bytes) or is_list(bytes)) and is_boolean(is_data) do
    GenServer.call(display_pid, {:send, bytes, is_data})
  end

  ## Callbacks

  @impl true
  def init(opts) do
    Process.flag(:trap_exit, true)

    width = opts[:width] || 480
    height = opts[:height] || 320
    offset_top = opts[:offset_top] || 0
    offset_left = opts[:offset_left] || 0
    pix_fmt = opts[:pix_fmt] || :bgr565
    rotation = opts[:rotation] || 90
    mad_mode = opts[:mad_mode] || :right_down
    display_mode = opts[:display_mode] || :normal
    frame_rate = opts[:frame_rate] || 70
    diva = opts[:diva] || 0b00
    rtna = opts[:rtna] || 0b10001
    is_high_speed = opts[:is_high_speed] || false
    spi_lcd = opts[:spi_lcd]
    spi_touch = opts[:spi_touch]
    gpio_dc = opts[:gpio_dc]
    gpio_rst = opts[:gpio_rst]

    calc_chunk_size = fn spi_bus ->
      from_opts = opts[:chunk_size]

      desired_size =
        cond do
          is_integer(from_opts) and from_opts > 0 -> from_opts
          is_high_speed -> 0x8000
          true -> 4_096
        end

      driver_limit =
        cond do
          function_exported?(Circuits.SPI, :max_transfer_size, 1) ->
            Circuits.SPI.max_transfer_size(spi_bus)

          function_exported?(Circuits.SPI, :max_transfer_size, 0) ->
            Circuits.SPI.max_transfer_size()

          true ->
            desired_size
        end

      effective_limit = if driver_limit > 0, do: driver_limit, else: desired_size

      min(desired_size, effective_limit)
    end

    data_bus = if is_high_speed, do: :parallel_16bit, else: :parallel_8bit

    display =
      %{
        spi_lcd: spi_lcd,
        spi_touch: spi_touch,
        gpio: [dc: gpio_dc, rst: gpio_rst],
        opts: [
          width: width,
          height: height,
          offset_top: offset_top,
          offset_left: offset_left
        ],
        pix_fmt: pix_fmt,
        rotation: rotation,
        mad_mode: mad_mode,
        data_bus: data_bus,
        display_mode: display_mode,
        frame_rate: frame_rate,
        diva: diva,
        rtna: rtna,
        chunk_size: calc_chunk_size.(spi_lcd)
      }
      |> _reset()
      |> _init(is_high_speed)

    {:ok, display}
  end

  @impl true
  def terminate(_reason, %{spi_lcd: spi_lcd, spi_touch: spi_touch, gpio: gpio}) do
    dc_pin = gpio[:dc]
    rst_pin = gpio[:rst]

    Circuits.SPI.close(spi_lcd)
    if spi_touch, do: Circuits.SPI.close(spi_touch)

    Circuits.GPIO.close(dc_pin)
    if rst_pin, do: Circuits.GPIO.close(rst_pin)

    :ok
  end

  @impl true
  def handle_call(:reset, _from, display) do
    {:reply, :ok, _reset(display)}
  end

  @impl true
  def handle_call(:size, _from, %{opts: opts} = display) do
    ret = %{height: opts[:height], width: opts[:width]}
    {:reply, ret, display}
  end

  @impl true
  def handle_call(:pix_fmt, _from, %{pix_fmt: pix_fmt} = display) do
    {:reply, pix_fmt, display}
  end

  @impl true
  def handle_call({:set_pix_fmt, pix_fmt}, _from, display) do
    {:reply, :ok, _set_pix_fmt(display, pix_fmt)}
  end

  @impl true
  def handle_call({:set_display, status}, _from, display) do
    {:reply, :ok, _set_display(display, status)}
  end

  @impl true
  def handle_call({:set_display_mode, display_mode}, _from, display) do
    {:reply, :ok, _set_display_mode(display, display_mode)}
  end

  @impl true
  def handle_call({:set_frame_rate, frame_rate}, _from, display) do
    {:reply, :ok, _set_frame_rate(display, frame_rate)}
  end

  @impl true
  def handle_call({:display_565, image_data}, _from, display) do
    {:reply, :ok, _display_565(display, image_data)}
  end

  @impl true
  def handle_call({:display_666, image_data}, _from, display) do
    {:reply, :ok, _display_666(display, image_data)}
  end

  @impl true
  def handle_call({:display, image_data, source_color}, _from, display) do
    {:reply, :ok, _display(display, image_data, source_color)}
  end

  @impl true
  def handle_call({:command, cmd, opts}, _from, display) do
    {:reply, :ok, _command(display, cmd, opts)}
  end

  @impl true
  def handle_call({:data, data}, _from, display) do
    {:reply, :ok, _data(display, data)}
  end

  @impl true
  def handle_call({:send, bytes, is_data}, _from, display) do
    {:reply, :ok, _send(display, bytes, is_data)}
  end

  ## Private helpers

  defp _reset(display = %{gpio: gpio}) do
    gpio_rst = gpio[:rst]

    if gpio_rst != nil do
      Circuits.GPIO.write(gpio_rst, 1)
      Process.sleep(500)
      Circuits.GPIO.write(gpio_rst, 0)
      Process.sleep(500)
      Circuits.GPIO.write(gpio_rst, 1)
      Process.sleep(500)
    end

    display
  end

  defp _set_pix_fmt(display = %{}, pix_fmt)
       when pix_fmt == :bgr565 or pix_fmt == :rgb565 or pix_fmt == :bgr666 or pix_fmt == :rgb666 do
    %{display | pix_fmt: pix_fmt}
    |> _command(kMADCTL(), cmd_data: _mad_mode(display))
  end

  defp _set_display(display = %{}, :on) do
    _command(display, kDISPON())
  end

  defp _set_display(display = %{}, :off) do
    _command(display, kDISPOFF())
  end

  defp _set_display_mode(display = %{}, display_mode = :normal) do
    %{display | display_mode: display_mode}
    |> _command(kNORON())
  end

  defp _set_display_mode(display = %{}, display_mode = :partial) do
    %{display | display_mode: display_mode}
    |> _command(kPTLON())
  end

  defp _set_display_mode(display = %{}, display_mode = :idle) do
    %{display | display_mode: display_mode}
    |> _command(display, kIDLEON())
  end

  defp _set_frame_rate( display = %{display_mode: display_mode, diva: diva, rtna: rtna}, frame_rate) do
    index = Enum.find_index(_valid_frame_rates(display_mode), fn valid -> valid == frame_rate end)

    p1 =
      index
      |> bsl(4)
      |> bor(diva)

    %{display | frame_rate: frame_rate}
    |> _command(kFRMCTR1())
    |> _data(p1)
    |> _data(rtna)
  end

  defp _valid_frame_rates(:normal) do
    [28, 30, 32, 34, 36, 39, 42, 46, 50, 56, 62, 70, 81, 96, 117, 117]
  end

  defp _display_565(display, image_data) when is_binary(image_data) do
    _display_565(display, :binary.bin_to_list(image_data))
  end

  defp _display_565(display, image_data) when is_list(image_data) do
    display
    |> _set_window(x0: 0, y0: 0, x1: nil, y2: nil)
    |> _send(image_data, true, false)
  end

  defp _display_666(display, image_data) when is_binary(image_data) do
    _display_666(display, :binary.bin_to_list(image_data))
  end

  defp _display_666(display, image_data) when is_list(image_data) do
    display
    |> _set_window(x0: 0, y0: 0, x1: nil, y2: nil)
    |> _send(image_data, true, false)
  end

  defp _display(display = %{pix_fmt: target_color}, image_data, source_color)
       when is_binary(image_data) and (source_color == :rgb888 or source_color == :bgr888) and
              (target_color == :rgb565 or target_color == :bgr565) do
    _display_565(display, _to_565(image_data, source_color, target_color))
  end

  defp _display(display = %{pix_fmt: target_color}, image_data, source_color)
       when is_binary(image_data) and (source_color == :rgb888 or source_color == :bgr888) and
              (target_color == :rgb666 or target_color == :bgr666) do
    _display_666(display, _to_666(image_data, source_color, target_color))
  end

  defp _display(display, image_data, source_color)
       when is_list(image_data) and (source_color == :rgb888 or source_color == :bgr888) do
    _display(
      display,
      Enum.map(image_data, &Enum.into(&1, <<>>, fn bit -> <<bit::8>> end)),
      source_color
    )
  end

  defp _command(display, cmd, opts \\ [])

  defp _command(display = %{data_bus: :parallel_8bit}, cmd, opts) when is_integer(cmd) do
    cmd_data = opts[:cmd_data] || []
    delay = opts[:delay] || 0

    display
    |> _send(cmd, false, false)
    |> _data(cmd_data)

    Process.sleep(delay)
    display
  end

  defp _command(display = %{data_bus: :parallel_16bit}, cmd, opts) when is_integer(cmd) do
    cmd_data = opts[:cmd_data] || []
    delay = opts[:delay] || 0

    display
    |> _send(cmd, false, true)
    |> _data(cmd_data)

    Process.sleep(delay)
    display
  end

  defp _data(display, []), do: display

  defp _data(display = %{data_bus: :parallel_8bit}, data) do
    _send(display, data, true, false)
  end

  defp _data(display = %{data_bus: :parallel_16bit}, data) do
    _send(display, data, true, true)
  end

  defp to_be_u16(u8_bytes) do
    u8_bytes
    |> Enum.map(fn u8 -> [0x00, u8] end)
    |> IO.iodata_to_binary()
  end

  defp chunk_binary(binary, chunk_size) when is_binary(binary) do
    total_bytes = byte_size(binary)
    full_chunks = div(total_bytes, chunk_size)

    chunks =
      if full_chunks > 0 do
        for i <- 0..(full_chunks - 1), reduce: [] do
          acc -> [:binary.part(binary, chunk_size * i, chunk_size) | acc]
        end
      else
        []
      end

    remaining = rem(total_bytes, chunk_size)

    chunks =
      if remaining > 0 do
        [:binary.part(binary, chunk_size * full_chunks, remaining) | chunks]
      else
        chunks
      end

    Enum.reverse(chunks)
  end

  defp _send(display, bytes, is_data, to_be16 \\ false)

  defp _send(display = %{}, bytes, true, to_be16) do
    _send(display, bytes, 1, to_be16)
  end

  defp _send(display = %{}, bytes, false, to_be16) do
    _send(display, bytes, 0, to_be16)
  end

  defp _send(display = %{}, bytes, is_data, to_be16)
       when (is_data == 0 or is_data == 1) and is_integer(bytes) do
    _send(display, <<Bitwise.band(bytes, 0xFF)>>, is_data, to_be16)
  end

  defp _send(display = %{}, bytes, is_data, to_be16)
       when (is_data == 0 or is_data == 1) and is_list(bytes) do
    _send(display, IO.iodata_to_binary(bytes), is_data, to_be16)
  end

  defp _send(
         display = %{gpio: gpio, spi_lcd: spi, chunk_size: chunk_size},
         bytes,
         is_data,
         to_be16
       )
       when (is_data == 0 or is_data == 1) and is_binary(bytes) do
    gpio_dc = gpio[:dc]
    bytes = if to_be16, do: to_be_u16(:binary.bin_to_list(bytes)), else: bytes

    Circuits.GPIO.write(gpio_dc, is_data)

    for xfdata <- chunk_binary(bytes, chunk_size) do
      {:ok, _ret} = Circuits.SPI.transfer(spi, xfdata)
    end

    display
  end

  defp _get_channel_order(%{pix_fmt: :rgb565}), do: kMAD_RGB()
  defp _get_channel_order(%{pix_fmt: :bgr565}), do: kMAD_BGR()
  defp _get_channel_order(%{pix_fmt: :rgb666}), do: kMAD_RGB()
  defp _get_channel_order(%{pix_fmt: :bgr666}), do: kMAD_BGR()

  defp _get_pix_fmt(%{pix_fmt: :rgb565}), do: k16BIT_PIX()
  defp _get_pix_fmt(%{pix_fmt: :bgr565}), do: k16BIT_PIX()
  defp _get_pix_fmt(%{pix_fmt: :rgb666}), do: k18BIT_PIX()
  defp _get_pix_fmt(%{pix_fmt: :bgr666}), do: k18BIT_PIX()

  defp _mad_mode(display = %{rotation: 0, mad_mode: :right_down}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_DOWN())
  end

  defp _mad_mode(display = %{rotation: 90, mad_mode: :right_down}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_DOWN())
    |> bor(kMAD_VERTICAL())
  end

  defp _mad_mode(display = %{rotation: 180, mad_mode: :right_down}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_UP())
  end

  defp _mad_mode(display = %{rotation: 270, mad_mode: :right_down}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_UP())
    |> bor(kMAD_VERTICAL())
  end

  defp _mad_mode(display = %{rotation: 0, mad_mode: :right_up}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_UP())
  end

  defp _mad_mode(display = %{rotation: 90, mad_mode: :right_up}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_DOWN())
    |> bor(kMAD_VERTICAL())
  end

  defp _mad_mode(display = %{rotation: 180, mad_mode: :right_up}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_DOWN())
  end

  defp _mad_mode(display = %{rotation: 270, mad_mode: :right_up}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_UP())
    |> bor(kMAD_VERTICAL())
  end

  defp _mad_mode(display = %{rotation: 0, mad_mode: :rgb_mode}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_DOWN())
  end

  defp _mad_mode(display = %{rotation: 90, mad_mode: :rgb_mode}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_DOWN())
  end

  defp _mad_mode(display = %{rotation: 180, mad_mode: :rgb_mode}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_RIGHT())
    |> bor(kMAD_Y_UP())
  end

  defp _mad_mode(display = %{rotation: 270, mad_mode: :rgb_mode}) do
    display
    |> _get_channel_order()
    |> bor(kMAD_X_LEFT())
    |> bor(kMAD_Y_UP())
  end

  defp _init(display = %{frame_rate: frame_rate}, false) do
    display
    |> _command(kSWRESET(), delay: 120)
    |> _command(kRGB_INTERFACE(), cmd_data: 0x00)
    |> _command(kSLPOUT(), delay: 200)
    |> _command(kPIXFMT(), cmd_data: _get_pix_fmt(display))
    |> _command(kMADCTL(), cmd_data: _mad_mode(display))
    |> _command(kPWCTR3(), cmd_data: 0x44)
    |> _command(kVMCTR1())
    |> _data(0x00)
    |> _data(0x00)
    |> _data(0x00)
    |> _data(0x00)
    |> _command(kGMCTRP1())
    |> _data(0x0F)
    |> _data(0x1F)
    |> _data(0x1C)
    |> _data(0x0C)
    |> _data(0x0F)
    |> _data(0x08)
    |> _data(0x48)
    |> _data(0x98)
    |> _data(0x37)
    |> _data(0x0A)
    |> _data(0x13)
    |> _data(0x04)
    |> _data(0x11)
    |> _data(0x0D)
    |> _data(0x00)
    |> _command(kGMCTRN1())
    |> _data(0x0F)
    |> _data(0x32)
    |> _data(0x2E)
    |> _data(0x0B)
    |> _data(0x0D)
    |> _data(0x05)
    |> _data(0x47)
    |> _data(0x75)
    |> _data(0x37)
    |> _data(0x06)
    |> _data(0x10)
    |> _data(0x03)
    |> _data(0x24)
    |> _data(0x20)
    |> _data(0x00)
    |> _command(kDGCTR1())
    |> _data(0x0F)
    |> _data(0x32)
    |> _data(0x2E)
    |> _data(0x0B)
    |> _data(0x0D)
    |> _data(0x05)
    |> _data(0x47)
    |> _data(0x75)
    |> _data(0x37)
    |> _data(0x06)
    |> _data(0x10)
    |> _data(0x03)
    |> _data(0x24)
    |> _data(0x20)
    |> _data(0x00)
    |> _set_display_mode(:normal)
    |> _command(kINVOFF())
    |> _command(kSLPOUT(), delay: 200)
    |> _command(kDISPON())
    |> _set_frame_rate(frame_rate)
  end

  defp _init(display = %{frame_rate: frame_rate}, true) do
    display
    |> _command(kSWRESET(), delay: 120)
    |> _command(kRGB_INTERFACE(), cmd_data: 0x00)
    |> _command(kSLPOUT(), delay: 250)
    |> _command(kPIXFMT(), cmd_data: _get_pix_fmt(display))
    |> _command(kPWCTR3(), cmd_data: 0x44)
    |> _command(kVMCTR1(), cmd_data: [0x00, 0x00, 0x00, 0x00])
    |> _command(kGMCTRP1())
    |> _data(0x0F)
    |> _data(0x1F)
    |> _data(0x1C)
    |> _data(0x0C)
    |> _data(0x0F)
    |> _data(0x08)
    |> _data(0x48)
    |> _data(0x98)
    |> _data(0x37)
    |> _data(0x0A)
    |> _data(0x13)
    |> _data(0x04)
    |> _data(0x11)
    |> _data(0x0D)
    |> _data(0x00)
    |> _command(kGMCTRN1())
    |> _data(0x0F)
    |> _data(0x32)
    |> _data(0x2E)
    |> _data(0x0B)
    |> _data(0x0D)
    |> _data(0x05)
    |> _data(0x47)
    |> _data(0x75)
    |> _data(0x37)
    |> _data(0x06)
    |> _data(0x10)
    |> _data(0x03)
    |> _data(0x24)
    |> _data(0x20)
    |> _data(0x00)
    |> _command(kDGCTR1())
    |> _data(0x0F)
    |> _data(0x32)
    |> _data(0x2E)
    |> _data(0x0B)
    |> _data(0x0D)
    |> _data(0x05)
    |> _data(0x47)
    |> _data(0x75)
    |> _data(0x37)
    |> _data(0x06)
    |> _data(0x10)
    |> _data(0x03)
    |> _data(0x24)
    |> _data(0x20)
    |> _data(0x00)
    |> _set_display_mode(:normal)
    |> _command(kINVOFF())
    |> _command(kDISPON(), delay: 100)
    |> _command(kMADCTL(), cmd_data: _mad_mode(display))
    |> _set_frame_rate(frame_rate)
  end

  defp _set_window(display = %{opts: board}, opts = [x0: 0, y0: 0, x1: nil, y2: nil]) do
    width = board[:width]
    height = board[:height]
    offset_top = board[:offset_top]
    offset_left = board[:offset_left]
    x0 = opts[:x0]
    x1 = opts[:x1]
    x1 = if x1 == nil, do: width - 1
    y0 = opts[:y0]
    y1 = opts[:y1]
    y1 = if y1 == nil, do: height - 1
    y0 = y0 + offset_top
    y1 = y1 + offset_top
    x0 = x0 + offset_left
    x1 = x1 + offset_left

    display
    |> _command(kCASET())
    |> _data(bsr(x0, 8))
    |> _data(band(x0, 0xFF))
    |> _data(bsr(x1, 8))
    |> _data(band(x1, 0xFF))
    |> _command(kPASET())
    |> _data(bsr(y0, 8))
    |> _data(band(y0, 0xFF))
    |> _data(bsr(y1, 8))
    |> _data(band(y1, 0xFF))
    |> _command(kRAMWR())
  end

  defp _to_565(image_data, source_color, target_color) when is_binary(image_data) do
    image_data
    |> CvtColor.cvt(source_color, target_color)
    |> :binary.bin_to_list()
  end

  defp _to_666(image_data, :bgr888, :bgr666) when is_binary(image_data) do
    image_data
    |> :binary.bin_to_list()
  end

  defp _to_666(image_data, source_color, target_color) when is_binary(image_data) do
    image_data
    |> CvtColor.cvt(source_color, target_color)
    |> :binary.bin_to_list()
  end

  def kNOP, do: 0x00
  def kSWRESET, do: 0x01
  def kRDDID, do: 0x04
  def kRDDST, do: 0x09
  def kRDMODE, do: 0x0A
  def kRDMADCTL, do: 0x0B
  def kRDPIXFMT, do: 0x0C
  def kRDIMGFMT, do: 0x0D
  def kRDSELFDIAG, do: 0x0F
  def kSLPIN, do: 0x10
  def kSLPOUT, do: 0x11
  def kPTLON, do: 0x12
  def kNORON, do: 0x13
  def kINVOFF, do: 0x20
  def kINVON, do: 0x21
  def kGAMMASET, do: 0x26
  def kDISPOFF, do: 0x28
  def kDISPON, do: 0x29
  def kCASET, do: 0x2A
  def kPASET, do: 0x2B
  def kRAMWR, do: 0x2C
  def kRAMRD, do: 0x2E
  def kPTLAR, do: 0x30
  def kVSCRDEF, do: 0x33
  def kMADCTL, do: 0x36
  def kVSCRSADD, do: 0x37
  def kIDLEOFF, do: 0x38
  def kIDLEON, do: 0x39
  def kPIXFMT, do: 0x3A
  def kRGB_INTERFACE, do: 0xB0
  def kFRMCTR1, do: 0xB1
  def kFRMCTR2, do: 0xB2
  def kFRMCTR3, do: 0xB3
  def kINVCTR, do: 0xB4
  def kDFUNCTR, do: 0xB6
  def kPWCTR1, do: 0xC0
  def kPWCTR2, do: 0xC1
  def kPWCTR3, do: 0xC2
  def kPWCTR4, do: 0xC3
  def kPWCTR5, do: 0xC4
  def kVMCTR1, do: 0xC5
  def kVMCTR2, do: 0xC7
  def kRDID1, do: 0xDA
  def kRDID2, do: 0xDB
  def kRDID3, do: 0xDC
  def kRDID4, do: 0xDD
  def kGMCTRP1, do: 0xE0
  def kGMCTRN1, do: 0xE1
  def kDGCTR1, do: 0xE2
  def kDGCTR2, do: 0xE3
  def kMAD_RGB, do: 0x08
  def kMAD_BGR, do: 0x00
  def k18BIT_PIX, do: 0x66
  def k16BIT_PIX, do: 0x55
  def kMAD_VERTICAL, do: 0x20
  def kMAD_X_LEFT, do: 0x00
  def kMAD_X_RIGHT, do: 0x40
  def kMAD_Y_UP, do: 0x80
  def kMAD_Y_DOWN, do: 0x00
  def kHISPEEDF1, do: 0xF1
  def kHISPEEDF2, do: 0xF2
  def kHISPEEDF8, do: 0xF8
  def kHISPEEDF9, do: 0xF9
end

alias MyApp.ILI9486

まず ILI9486 ドライバを起動してディスプレイを初期化します。

続いて、size/1 で解像度、pix_fmt/1 で現在のピクセルフォーマットを確認し、最後に set_display/2 でディスプレイの表示を一時的にオフ→オンに切り替えて動作を確かめます。

display_width = 320
display_height = 480

{:ok, display} =
  ILI9486.new(
    spi_lcd: spi_lcd,
    spi_touch: spi_touch,
    gpio_dc: gpio_dc,
    gpio_rst: gpio_rst,
    width: display_width,
    height: display_height,
    rotation: 0,
    pix_fmt: :rgb565,
    is_high_speed: is_high_speed
  )

# 表示サイズを確認
ILI9486.size(display) |> IO.inspect(label: "解像度")

# 現在のピクセルフォーマットを確認
ILI9486.pix_fmt(display) |> IO.inspect(label: "ピクセルフォーマット")

# 表示をオフ→オンで切り替え
ILI9486.set_display(display, :off)
Process.sleep(200)
ILI9486.set_display(display, :on)

RGB565 カラーの扱い

ILI9486 パネルは 16 ビットカラーを採用しており、赤(5 ビット)、緑(6 ビット)、青(5 ビット)をまとめて 1 つの 16 ビット数値(0bRRRRRGGGGGGBBBBB)として扱います。

注意: 多くの 3.5″ SPI TFT はデフォルトで BGR565(赤と青が入れ替わった配線)になっています。その場合は、ドライバの pix_fmt: :bgr565 を指定するか、色変換時に赤/青チャンネルを入れ替えてください。

このモジュールを使うと、8ビット(0~255)の RGB 値を RGB565 整数に変換できます。

defmodule MyApp.Color do
  import Bitwise

  def rgb565(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do
    (r &&& 0xF8) <<< 8 ||| (g &&& 0xFC) <<< 3 ||| b >>> 3
  end

  def rgb565_binary(r, g, b), do: <<rgb565(r, g, b)::16-big>>

  def rgb565_hex(r, g, b), do: to_hex(rgb565(r, g, b), 4, "0x")

  def rgb565_bits(r, g, b), do: to_bits(rgb565(r, g, b), 16)

  defp to_hex(int_value, width, prefix) do
    int_value
    |> Integer.to_string(16)
    |> String.upcase()
    |> String.pad_leading(width, "0")
    |> (&(prefix <> &1)).()
  end

  defp to_bits(int_value, width) do
    int_value
    |> Integer.to_string(2)
    |> String.pad_leading(width, "0")
  end
end

alias MyApp.Color

以下のコードでは、いくつかの基本的な色を RGB565 形式に変換し、 それらの色を ディスプレイ全体 に順番に描画し、500ms ごとに切り替えてみます。

colors = [
  red: {255, 0, 0},
  green: {0, 255, 0},
  blue: {0, 0, 255},
  white: {255, 255, 255},
  black: {0, 0, 0},
  yellow: {255, 255, 0},
  cyan: {0, 255, 255},
  magenta: {255, 0, 255},
  gray: {128, 128, 128}
]

for {color_name, {red, green, blue}} <- colors do
  IO.puts("表示中: #{color_name}")
  pixel = Color.rgb565_binary(red, green, blue)
  buffer = :binary.copy(pixel, display_width * display_height)
  ILI9486.display_565(display, buffer)
  Process.sleep(500)
end

for {color_name, {red, green, blue}} <- colors do
  [
    name: Atom.to_string(color_name),
    red: red,
    green: green,
    blue: blue,
    rgb565_hex: Color.rgb565_hex(red, green, blue),
    rgb565_bits: Color.rgb565_bits(red, green, blue)
  ]
end
|> Kino.DataTable.new()

5×7 ビットマップフォントの作成

ASCII 文字(0x20–0x7E)のための最小限の 5×7 ビットマップフォントを定義します。
このフォントを使って、テキストをフレームバッファに描画できるようになります。

defmodule MyApp.Font5x7 do
  @font %{
    ?\s => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
    ?! => [0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00000, 0b00100],
    ?" => [0b01010, 0b01010, 0b01010, 0b00000, 0b00000, 0b00000, 0b00000],
    ?# => [0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010],
    ?$ => [0b00100, 0b01111, 0b10100, 0b01110, 0b00101, 0b11110, 0b00100],
    ?% => [0b11000, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b00011],
    ?& => [0b01100, 0b10010, 0b10100, 0b01000, 0b10101, 0b10010, 0b01101],
    ?' => [0b00100, 0b00100, 0b01000, 0b00000, 0b00000, 0b00000, 0b00000],
    ?( => [0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010],
    ?) => [0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000],
    ?* => [0b00000, 0b00100, 0b10101, 0b01110, 0b10101, 0b00100, 0b00000],
    ?+ => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
    ?, => [0b00000, 0b00000, 0b00000, 0b00000, 0b00110, 0b00100, 0b01000],
    ?- => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
    ?. => [0b00000, 0b00000, 0b00000, 0b00000, 0b01100, 0b01100, 0b00000],
    ?/ => [0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b00000, 0b00000],
    ?0 => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
    ?1 => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
    ?2 => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
    ?3 => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
    ?4 => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
    ?5 => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
    ?6 => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
    ?7 => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
    ?8 => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
    ?9 => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
    ?: => [0b00000, 0b01100, 0b01100, 0b00000, 0b01100, 0b01100, 0b00000],
    ?; => [0b00000, 0b01100, 0b01100, 0b00000, 0b01100, 0b00100, 0b01000],
    ?< => [0b00010, 0b00100, 0b01000, 0b10000, 0b01000, 0b00100, 0b00010],
    ?= => [0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000],
    ?> => [0b01000, 0b00100, 0b00010, 0b00001, 0b00010, 0b00100, 0b01000],
    ?? => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b00000, 0b00100],
    ?@ => [0b01110, 0b10001, 0b00001, 0b01101, 0b10101, 0b10101, 0b01110],
    ?A => [0b01110, 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001],
    ?B => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
    ?C => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
    ?D => [0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100],
    ?E => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
    ?F => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
    ?G => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111],
    ?H => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
    ?I => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
    ?J => [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
    ?K => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
    ?L => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
    ?M => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
    ?N => [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
    ?O => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
    ?P => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
    ?Q => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
    ?R => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
    ?S => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
    ?T => [0b11111, 0b10101, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
    ?U => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
    ?V => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
    ?W => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b10101, 0b01010],
    ?X => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
    ?Y => [0b10001, 0b10001, 0b10001, 0b01110, 0b00100, 0b00100, 0b00100],
    ?Z => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
    ?[ => [0b01110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b01110],
    ?\\ => [0b10001, 0b01010, 0b11111, 0b00100, 0b11111, 0b00100, 0b00100],
    ?] => [0b01110, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110],
    ?^ => [0b00100, 0b01010, 0b10001, 0b00000, 0b00000, 0b00000, 0b00000],
    ?_ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111],
    ?` => [0b01000, 0b00100, 0b00010, 0b00000, 0b00000, 0b00000, 0b00000],
    ?a => [0b00000, 0b00000, 0b01110, 0b00001, 0b01111, 0b10001, 0b01111],
    ?b => [0b10000, 0b10000, 0b10110, 0b11001, 0b10001, 0b10001, 0b11110],
    ?c => [0b00000, 0b00000, 0b01110, 0b10000, 0b10000, 0b10001, 0b01110],
    ?d => [0b00001, 0b00001, 0b01101, 0b10011, 0b10001, 0b10001, 0b01111],
    ?e => [0b00000, 0b00000, 0b01110, 0b10001, 0b11111, 0b10000, 0b01110],
    ?f => [0b00110, 0b01001, 0b01000, 0b11110, 0b01000, 0b01000, 0b01000],
    ?g => [0b00000, 0b01111, 0b10001, 0b10001, 0b01111, 0b00001, 0b01110],
    ?h => [0b10000, 0b10000, 0b10110, 0b11001, 0b10001, 0b10001, 0b10001],
    ?i => [0b00100, 0b00000, 0b01100, 0b00100, 0b00100, 0b00100, 0b01110],
    ?j => [0b00010, 0b00000, 0b00110, 0b00010, 0b00010, 0b10010, 0b01100],
    ?k => [0b10000, 0b10000, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010],
    ?l => [0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
    ?m => [0b00000, 0b00000, 0b11010, 0b10101, 0b10101, 0b10101, 0b10101],
    ?n => [0b00000, 0b00000, 0b10110, 0b11001, 0b10001, 0b10001, 0b10001],
    ?o => [0b00000, 0b00000, 0b01110, 0b10001, 0b10001, 0b10001, 0b01110],
    ?p => [0b00000, 0b00000, 0b11110, 0b10001, 0b11110, 0b10000, 0b10000],
    ?q => [0b00000, 0b00000, 0b01101, 0b10011, 0b01111, 0b00001, 0b00001],
    ?r => [0b00000, 0b00000, 0b10110, 0b11001, 0b10000, 0b10000, 0b10000],
    ?s => [0b00000, 0b00000, 0b01110, 0b10000, 0b01110, 0b00001, 0b11110],
    ?t => [0b01000, 0b01000, 0b11111, 0b01000, 0b01000, 0b01001, 0b00110],
    ?u => [0b00000, 0b00000, 0b10001, 0b10001, 0b10001, 0b10011, 0b01101],
    ?v => [0b00000, 0b00000, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
    ?w => [0b00000, 0b00000, 0b10001, 0b10001, 0b10101, 0b10101, 0b01010],
    ?x => [0b00000, 0b00000, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001],
    ?y => [0b00000, 0b00000, 0b10001, 0b10001, 0b01111, 0b00001, 0b01110],
    ?z => [0b00000, 0b00000, 0b11111, 0b00010, 0b01000, 0b10000, 0b11111]
  }

  @doc """
  指定した `char` の 5×7 ビットマップを返します。
  定義がなければ空白(すべて 0)を返します。
  """
  def get(char), do: Map.get(@font, char, default_blank())

  defp default_blank, do: List.duplicate(0b00000, 7)
end

alias MyApp.Font5x7

フレームバッファの作成

個別に描画コマンドを発行する代わりに、Elixir で 320x480 の RGB565 バッファを組み立て、
図形や文字を描画したうえで一度に送信します。

defmodule MyApp.Framebuffer do
  import Bitwise

  defstruct width: 0, height: 0, buffer: <<>>

  @doc """
  `width`×`height` のフレームバッファを作成し、`fill_color`(RGB565)で塗りつぶします。
  """
  def new(width, height, fill_color \\ 0x0000)
      when width > 0 and height > 0 and fill_color in 0..0xFFFF do
    pixel = <<fill_color::16-big>>
    %__MODULE__{width: width, height: height, buffer: :binary.copy(pixel, width * height)}
  end

  @doc """
  座標 `{x, y}` のピクセルを `color`(RGB565)に設定します。  
  画面外の座標は無視されます。
  """
  def put_pixel(%__MODULE__{width: w, height: h, buffer: buf} = fb, {x, y}, color)
      when x >= 0 and x < w and y >= 0 and y < h and color in 0..0xFFFF do
    idx = (y * w + x) * 2
    pixel = <<color::16-big>>
    <<head::binary-size(idx), _::binary-size(2), tail::binary>> = buf
    %{fb | buffer: head <> pixel <> tail}
  end

  def put_pixel(fb, _, _), do: fb

  @doc """
  左上 `{x0, y0}` から幅 `fw` 高さ `fh` の矩形を `color`(RGB565)で塗りつぶします。
  """
  def draw_filled_rect(fb, {x0, y0}, {fw, fh}, color)
      when is_integer(x0) and is_integer(y0) and fw > 0 and fh > 0 do
    0..(fh - 1)
    |> Enum.reduce(fb, fn dy, acc ->
      0..(fw - 1)
      |> Enum.reduce(acc, fn dx, row_acc ->
        put_pixel(row_acc, {x0 + dx, y0 + dy}, color)
      end)
    end)
  end

  @doc """
  フォントを使ってテキストを描画します。  
  オプション:`scale`(拡大率、デフォルト 1)、`color`(RGB565、デフォルト 0xFFFF)。
  """
  def draw_text(fb, text, {x0, y0}, opts \\ []) when is_binary(text) do
    scale = Keyword.get(opts, :scale, 1)
    color = Keyword.get(opts, :color, 0xFFFF)
    spacing = 1

    text
    |> String.to_charlist()
    |> Enum.with_index()
    |> Enum.reduce(fb, fn {ch, i}, acc ->
      x = x0 + i * (5 + spacing) * scale
      draw_char(acc, ch, {x, y0}, scale, color)
    end)
  end

  defp draw_char(%__MODULE__{} = fb, ch, {x, y}, scale, color)
       when scale > 0 and color in 0..0xFFFF do
    Font5x7.get(ch)
    |> Enum.with_index()
    |> Enum.reduce(fb, fn {row, dy}, acc ->
      for dx <- 0..4, reduce: acc do
        buf_acc ->
          if (row &&& 1 <<< (4 - dx)) != 0 do
            for sy <- 0..(scale - 1), sx <- 0..(scale - 1), reduce: buf_acc do
              b -> put_pixel(b, {x + dx * scale + sx, y + dy * scale + sy}, color)
            end
          else
            buf_acc
          end
      end
    end)
  end
end

alias MyApp.Framebuffer

まず、ディスプレイの幅・高さに合わせたフレームバッファを生成します。
四隅に色付きの四角形を描画し、中央に「Hello, Nerves!」をテキスト表示したあと、 display_565/2 で一括転送します。

# ディスプレイのサイズを取得
%{width: w, height: h} = ILI9486.size(display)

# フレームバッファに図形と文字を描画
frame_buffer =
  Framebuffer.new(w, h)
  # 左上 {0,0} に 🔴
  |> Framebuffer.draw_filled_rect({0, 0}, {50, 50}, Color.rgb565(255, 0, 0))
  # 右上 {w-50,0} に 🟢
  |> Framebuffer.draw_filled_rect({w - 50, 0}, {50, 50}, Color.rgb565(0, 255, 0))
  # 左下 {0,h-50} に 🔵
  |> Framebuffer.draw_filled_rect({0, h - 50}, {50, 50}, Color.rgb565(0, 0, 255))
  # 右下 {w-50,h-50} に 🟡
  |> Framebuffer.draw_filled_rect({w - 50, h - 50}, {50, 50}, Color.rgb565(255, 255, 0))
  # 中央にテキスト表示
  |> Framebuffer.draw_text(
    "Hello, Nerves!",
    {round(w / 2) - 80, round(h / 2) - 16},
    scale: 2,
    color: Color.rgb565(255, 255, 255)
  )

# フレームバッファを一括転送
ILI9486.display_565(display, frame_buffer.buffer)

スプライト (.rgb565) ファイルの表示

このセクションでは、Kino のファイル入力ウィジェットを使ってローカルの .rgb565 スプライトファイルをドラッグ&ドロップまたはクリックで選択し、そのままディスプレイに表示します。

ファイルサイズが画面バッファと異なる場合は自動でパディング/トリミングを行うので、手軽に好きな画像を試すことができます。

# ファイル入力ウィジェットを作成(.rgb565 ファイルのみ受け付け)
file_input = Kino.Input.file("スプライト (.rgb565) をドラッグ&ドロップまたはクリックで選択", accept: [".rgb565"])

# 新しいKinoフレームを作成してウィジェットを表示
kino_frame = Kino.Frame.new()
Kino.Frame.render(kino_frame, file_input)

# ディスプレイ解像度から期待バッファサイズを計算
%{width: w, height: h} = ILI9486.size(display)
expected = w * h * 2

# ファイル選択イベントのハンドリング
Kino.listen(file_input, fn
  %{type: :change, value: %{file_ref: file_ref, client_name: name}} ->
    # 選択されたファイルを読み込み
    path = Kino.Input.file_path(file_ref)
    body = File.read!(path)
    len = byte_size(body)

    # バッファサイズが異なる場合はパディング/トリミング
    image_buffer =
      cond do
        len == expected ->
          body

        len < expected ->
          # 足りない分を黒で埋める
          pad_pixel = <<0::16-big>>
          body <> :binary.copy(pad_pixel, expected - len)

        len > expected ->
          # 余分な分は切り捨て
          :binary.part(body, 0, expected)
      end

    # ディスプレイに転送して表示
    ILI9486.display_565(display, image_buffer)
    IO.puts("➡️ 表示: #{name} (#{len} bytes)")

    :ok

  _ ->
    :ok
end)

# インタラクティブ表示を維持するためフレームを返す
kino_frame

ディスプレイ上に現在時刻を表示

このセクションでは、GenServer を使ってディスプレイ上に現在時刻を表示・定期更新する最小限の「時計サーバー」を作成します。

defmodule MyApp.ClockServer do
  use GenServer

  @interval_ms 100

  ## Public interface

  def start_link(opts \\ []) do
    init_args = {opts[:display], opts[:frame_buffer]}
    GenServer.start_link(__MODULE__, init_args, name: __MODULE__)
  end

  def stop do
    GenServer.stop(__MODULE__)
  end

  ## Callbacks

  @impl true
  def init({display, %{buffer: _} = frame_buffer}) when is_pid(display) do
    # 表示をオンにしてサイズ取得
    ILI9486.set_display(display, :on)
    %{width: w, height: h} = ILI9486.size(display)

    # 初回の更新をスケジュール
    schedule_tick()

    {:ok, %{display: display, width: w, height: h, frame_buffer: frame_buffer}}
  end

  @impl true
  def handle_info(:tick, state) do
    # 現在時刻を JST 文字列で取得
    timestamp =
      DateTime.utc_now()
      |> DateTime.shift_zone!("Asia/Tokyo")
      |> Calendar.strftime("%H:%M:%S")

    # フレームバッファに描画
    fb =
      state.frame_buffer
      |> Framebuffer.draw_text(timestamp, {65, 10}, scale: 4, color: Color.rgb565(255, 255, 255))

    # 一括転送
    ILI9486.display_565(state.display, fb.buffer)

    # 次の更新をスケジュール
    schedule_tick()

    {:noreply, state}
  end

  defp schedule_tick do
    Process.send_after(self(), :tick, @interval_ms)
  end
end

alias MyApp.ClockServer

ClockServer.start_link(display: display, frame_buffer: frame_buffer)

時計を止めたくなったら:

ClockServer.stop()

XPT2046 タッチパネル入力の可視化

XPT2046 タッチパネルから定期的に入力を取得し、その生座標を画面ピクセルに線形マッピングします。マッピング結果はディスプレイ上に赤いドットで表示し、同時に Livebook 上で生座標とマッピング後の座標を確認できます。

defmodule MyApp.TouchServer do
  use GenServer
  import Bitwise
  alias MyApp.ILI9486
  alias MyApp.Framebuffer
  alias MyApp.Color

  @poll_ms 200
  @cmd_read_x 0b1001_0000
  @cmd_read_y 0b1101_0000
  @dot_r 10

  def start_link(opts) when is_list(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def stop, do: GenServer.stop(__MODULE__, :normal)

  @impl true
  def init(opts) do
    display = Keyword.fetch!(opts, :display)
    frame_buffer = Keyword.fetch!(opts, :frame_buffer)
    kino_frame = Keyword.fetch!(opts, :kino_frame)
    rotation = Keyword.get(opts, :rotation, 0)

    %{spi_touch: spi_touch, spi_lcd: spi_lcd} = :sys.get_state(display)
    %{width: w, height: h} = ILI9486.size(display)

    state = %{
      display: display,
      spi_touch: spi_touch,
      spi_lcd: spi_lcd,
      kino_frame: kino_frame,
      frame_buffer: frame_buffer,
      w: w,
      h: h,
      rotation: rotation
    }

    send(self(), :poll)

    {:ok, state}
  end

  @impl true
  def handle_info(:poll, state) do
    %{w: w, h: h, rotation: rotation} = state
    {raw_x, raw_y} = read_raw(state.spi_touch)

    unless raw_x == 0xFFF and raw_y == 0 do
      {mx, my} = map_coords(raw_x, raw_y, w, h, rotation)

      size = @dot_r * 2 + 1
      top_left = {mx - @dot_r, my - @dot_r}

      fb =
        state.frame_buffer
        |> Framebuffer.draw_filled_rect(top_left, {size, size}, Color.rgb565(255, 0, 0))

      ILI9486.display_565(state.display, fb.buffer)

      Kino.Frame.render(
        state.kino_frame,
        Kino.Markdown.new("""
        🎯 **生座標**:   x=#{raw_x}, y=#{raw_y}  
        🖥 **画面座標**: x=#{mx}, y=#{my}
        """)
      )
    end

    Process.send_after(self(), :poll, @poll_ms)
    {:noreply, state}
  end

  defp read_raw(spi) do
    {:ok, <<_::8, xh::8, xl::8>>} = Circuits.SPI.transfer(spi, <<@cmd_read_x, 0, 0>>)
    {:ok, <<_::8, yh::8, yl::8>>} = Circuits.SPI.transfer(spi, <<@cmd_read_y, 0, 0>>)

    raw_x = (xh <<< 8 ||| xl) >>> 3
    raw_y = (yh <<< 8 ||| yl) >>> 3
    {raw_x, raw_y}
  end

  defp map_coords(raw_x, raw_y, w, h, rotation) do
    inv_x = 4095 - raw_x
    inv_y = 4095 - raw_y

    sx = div(inv_y * (w - 1), 4095)
    sy = div(inv_x * (h - 1), 4095)

    case rotation do
      0 -> {sx, sy}
      90 -> {h - 1 - sy, sx}
      180 -> {w - 1 - sx, h - 1 - sy}
      270 -> {sy, w - 1 - sx}
    end
  end
end

alias MyApp.TouchServer

# Kino フレームを作成
kino_frame = Kino.Frame.new()

# TouchServer を起動(タッチ入力のポーリング&表示を開始)
TouchServer.start_link(
  spi: spi_touch,
  display: display,
  frame_buffer: frame_buffer,
  kino_frame: kino_frame
)

# インタラクティブ表示を維持
kino_frame
TouchServer.stop()
@mnishiguchi
Copy link
Author

mnishiguchi commented Jul 28, 2025

ElixirConf 2021 - Frank Hunleth - Embedded Elixir with Nerves Livebook
https://youtu.be/dZzM-rLu-zo?si=7dKf8q3ueuixm8CN

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment