はじめに
Nerves で 3.5インチLCDを扱うプロジェクトを進める中で、サポートしたい制御ICが増えてきました。ILI9486、ST7796などはやりたいことは同じなのに、初期化手順やレジスタが違うのでどのようにしてコードを整理するのかが課題でした。
最初は defprotocol + defimpl を使うことも検討しましたが、構成が複雑になりやすく割が合わないと感じて見送りました。
そこで最終的に落ち着いたのが、振る舞い(behaviour) と 汎用GenServer を組み合わせる構成です。見通しが良く、あとから制御ICを追加する場合も手を入れる範囲が読みやすくなるので、個人的に氣に入っています。
やりたいこと
私がやりたかったのは、 制御ICの違いを内部で吸収しつつ、利用側には統一された API を提供することです。特に以下のような構成が理想でした。
- 制御ICの違いを内部で隠蔽
- 表示器とのやりとりは統一APIで行う
- GPIOやSPIの詳細、初期化手順などはプロセス内部に閉じる
当初は制御ICごとに個別実装していたのですが、規模が大きくなるにつれて保守性に限界を感じました。
振る舞い + 汎用GenServer
最終的に落ち着いた構成は、単純な役割分担でした。
- 「状態をどう更新するか」: 振る舞いを実装した制御ロジック(制御ICごとの実装)
- 「状態を持つ」: GenServer
この形にすると、GenServer 側のコードはかなり機械的になり、制御ICごとのクセや初期化手順は制御ロジック側に集約できるため、全体の見通しが良くなりました。
制御ロジックの契約を振る舞いで定義
まず、駆動部が満たすべき関数を振る舞いで定義します。ここでは初期化で状態を作り、以後は状態を更新しながら返す形にしています。
制御ロジックの実装例
制御ICごとに制御ロジック用モジュールを作り、振る舞いを実装します。
ポイントはこれだけです。
-
init/1で初期状態(構造体)を作る - 以後の関数は状態を受け取り、必要な処理を行い、更新した状態を返す
- GenServer 側はその状態を持つだけ
細かいレジスタ値などは本筋ではないので省略します。
defmodule LcdDisplay.ILI9486.Display do
@behaviour LcdDisplay.DisplayDriver.DisplayContract
defstruct [:spi, :gpio, :opts, :pixel_format]
@impl true
def init(opts) do
# ここでSPI/GPIO を開いたり、初期化シーケンスを実行したりする
%__MODULE__{spi: :spi_handle, gpio: :gpio_handle, opts: opts, pixel_format: :rgb565}
end
@impl true
def reset(display) do
# ここでハードウェアを操作する
display
end
@impl true
def size(%__MODULE__{opts: opts}) do
%{width: opts[:width] || 480, height: opts[:height] || 320}
end
@impl true
def set_pixel_format(display, fmt) do
# ここで必要な設定を書き込む
%{display | pixel_format: fmt}
end
end
状態を保持する汎用 GenServer
次に、状態を保持して制御ロジックへ委譲する汎用 GenServer を用意します。ここは「どの制御ICか」を知りません。
-
display_implに制御ロジックのモジュールを渡す -
display_stateにその状態(構造体)を持つ -
handle_callではdisplay_impl.xxx(display_state)を呼び、戻り値で状態を更新する
defmodule LcdDisplay.DisplayDriver.GenericDisplayDriver do
use GenServer
def start_link(opts) do
display_impl = Keyword.fetch!(opts, :display_impl)
name = Keyword.get(opts, :name)
GenServer.start_link(__MODULE__, {display_impl, opts}, name: name)
end
@impl true
def init({display_impl, opts}) do
Process.flag(:trap_exit, true)
display_state = display_impl.init(opts)
{:ok, %{display_impl: display_impl, display_state: display_state}}
end
@impl true
def terminate(reason, %{display_impl: impl, display_state: state}) do
impl.terminate(reason, state)
end
## 公開API(PIDまたは登録名で呼ぶ)
def reset(server), do: GenServer.call(server, :reset)
def size(server), do: GenServer.call(server, :size)
def set_pixel_format(server, fmt), do: GenServer.call(server, {:set_pixel_format, fmt})
def write_frame_565(server, data), do: GenServer.call(server, {:write_frame_565, data})
## 委譲
@impl true
def handle_call(:reset, _from, %{display_impl: impl, display_state: d} = s) do
{:reply, :ok, %{s | display_state: impl.reset(d)}}
end
@impl true
def handle_call(:size, _from, %{display_impl: impl, display_state: d} = s) do
{:reply, impl.size(d), s}
end
@impl true
def handle_call({:set_pixel_format, fmt}, _from, %{display_impl: impl, display_state: d} = s) do
{:reply, :ok, %{s | display_state: impl.set_pixel_format(d, fmt)}}
end
end
制御ICごとのラッパーモジュールを用意
利用側から見ると、display_impl を毎回渡すのは面倒です。そこで、制御ICごとのラッパーモジュールを用意しました。
まず、共通の薄い層を作るマクロです。
defmodule LcdDisplay.DisplayDriver do
defmacro __using__(opts) do
impl = Keyword.fetch!(opts, :display_impl)
quote do
alias LcdDisplay.DisplayDriver.GenericDisplayDriver
def start_link(opts \\ []) do
GenericDisplayDriver.start_link(Keyword.put(opts, :display_impl, unquote(impl)))
end
defdelegate reset(server), to: GenericDisplayDriver
defdelegate size(server), to: GenericDisplayDriver
defdelegate set_pixel_format(server, fmt), to: GenericDisplayDriver
defdelegate write_frame_565(server, data), to: GenericDisplayDriver
end
end
end
次に、制御ICごとの窓口モジュールは 1 行で済みます。
defmodule LcdDisplay.ILI9486 do
use LcdDisplay.DisplayDriver, display_impl: LcdDisplay.ILI9486.Display
end
defmodule LcdDisplay.ST7796 do
use LcdDisplay.DisplayDriver, display_impl: LcdDisplay.ST7796.Display
end
利用側はこうなります。
{:ok, lcd} =
LcdDisplay.ILI9486.start_link(
name: :main_lcd,
spi_bus: "spidev0.0",
data_command_pin: 24,
reset_pin: 25,
width: 480,
height: 320
)
LcdDisplay.ILI9486.reset(lcd)
LcdDisplay.ILI9486.write_frame_565(lcd, image_data)
制御ICを切り替えるときは、LcdDisplay.ILI9486 を LcdDisplay.ST7796 に変える(と必要な設定値を調整する)だけで済みます。利用側の関数名は固定のままです。
この形が効く場面
このパターンをどういうところで使うと効果的か考えてみました。
- 扱いたいデバイスの種類が複数ある
- やりたい操作は共通しているが、初期化手順や内部仕様が異なる
- デバイスごとに状態を持ち、プロセスとして分離したい
- 利用側のコードは、できるだけ単純なAPIで書きたい
LCD表示器に限らず、SPI や I2C で接続する周辺機器全般で使いやすい形だと感じています。
おわりに
振る舞いと汎用GenServerで役割を分け、制御ロジックを差し替える構成について一例をご紹介させていただきました。
制御ICごとの違いは制御ロジックに閉じ込め、GenServerは状態管理と委譲に専念します。その結果、新しい表示器を追加しても利用側のコードを触らずに済むようになりました。
LCDに限らず、初期化手順やI/Oが異なる周辺機器を複数扱う場面でも使いやすい形だと感じています。
![]()
![]()
![]()