１つのプロジェクトで複数のNerves機器を使い分け

# １．はじめに

Elixir言語で組み込み機器を開発するフレームワーク [Nerves](https://nerves-project.org/) を使って、実際の開発をする中で、下記のような状況に遭遇しました。

- Nervesを実行する機器が複数ある
    - CPU同じ。入出力周りの構造もおおむね同じ
    - ハードウェア回りのコードは　共通で使いまわしたい
- それぞれの機器が相互に連携する構成のため、機器ごとに動作内容が違う
    - １台目は全体統括する機能を持つので、それ用のGenServerを起動したい
    - ２台目以降は、１台目の指示を受けて動作するので、それ用のGenServerを起動したい

こういった場合、それぞれの機器ごとにプロジェクトを分けて作ることになりますが、開発中に下記のような失敗をしてしまいました。

- 共通で使いまわすコードの修正をした際、別のプロジェクトへのコードの同期を忘れてしまった
- `mix upload`するときに、間違ったデバイスにファームウェアを転送してしまった

こういった起こりえるトラブルのポカヨケの方法として、１つのプロジェクトで複数のNerves機器の動作機能を使い分ける仕組みづくりを試行してみました。


# ２．環境

下記のような構成を想定します。

```mermaid
graph LR;
    subgraph n_b["Nerves機器B"]
        Func22 --- Func28;
        Func22 --- Func29;
        Func21;
    end
    subgraph n_a["Nerves機器A"]
        Func11 --- Func18;
        Func11 --- Func19;
        Func12;
    end

%%ノード定義
Func11[["A専用機能"]]
Func12[["（起動はしない）<br>B専用機能"]]
Func18[["汎用機能<br>入出力処理"]]
Func19[["汎用機能<br>ADコンバータ処理"]]
%%スタイル定義
style n_a fill:#eeffff

%%ノード定義
Func21[["（起動はしない）<br>A専用機能"]]
Func22[["B専用機能"]]
Func28[["汎用機能<br>入出力処理"]]
Func29[["汎用機能<br>ADコンバータ処理"]]
%%スタイル定義
style n_b fill:#ffeeff
```

汎用機能はそれぞれ共通で使うものとして、機器A・機器Bの違いで、最初のSupervisorの起動対象を切り替えることで、挙動を変えます。

# ３．目標

今回は、環境変数に、対象のIPアドレスを代入することで、挙動の切り替えをする仕組みを作ってみます。

## 考え方

```mermaid
graph TD;
    export --IPアドレス---> config;
    config --選択されたconfig値---> application;
    application --supervisor起動---> Func11
    application --関数呼び出し---> Func19
    application --何もしない--- Func12

%%ノード定義
export["環境変数<br>MIX_TARGET_HOST=IPあどれす"]
config["config.ex"]
application["application.ex"]
Func11[["A専用機能GenServer"]]
Func12[["（起動しない）<br>B専用機能GenServer"]]
Func19[["共通機能（例）<br>・Node.start<br>・Node.set_cookie"]]
```

## mixコマンドの例

```sh
# ターゲットデバイスを環境変数に指定
$ export MIX_TARGET=rpi4 

# ビルドとアップロードを一つのコマンドで実行
$ export MIX_TARGET_HOST=192.168.1.23 && mix firmware && mix upload ${MIX_TARGET_HOST}

 *** > target host     : "192.168.1.23"
 *** > target settings : {"hosta", "192.168.1.23", :hosta, :role_hoge} / "passphrase"
==> nerves
==> projectname
A new version of Nerves bootstrap is available(1.11.2 < 1.11.3), You can update by running

  mix local.nerves

Nerves environment
  MIX_TARGET:   rpi4
  MIX_ENV:      dev
（･･･省略･･･）
```

# ４．コード

ここでは、Elixirのプロジェクト名は`projectname`とします。

最初に、`config/config.exs`を細工します。
渡された環境変数によって、動的に設定情報を切り替えて、`Application.get_env`関数から引き出せるようにします。

```elixir:config/config.exs
（･･･省略･･･）

target = Application.get_env(:projectname, :target)

# 環境変数[MIX_TARGET_HOST]から書き込み先IPを取得
target_ip = System.get_env("MIX_TARGET_HOST")
# 確認用のログ・環境変数で渡されたIPアドレスを表示
IO.puts(" *** > target host     : #{inspect(target_ip)}")

# 環境変数で指定のIPアドレスに対応した
# ホスト名や役割の紐づけるためのリストを
# あらかじめ用意しておく
node_host_setting_list = [
  # デバイスA用
  # 左：環境変数に対応する"KEY"、右：設定情報 {ノード名、（今回は未使用）、”役割”}
  "192.168.1.23": {"hosta", :hosta, :role_hoge},
  # デバイスB用
  "192.168.1.45": {"hostb", :hostb, :role_fuga}
  # （リストの項目はいくつでも増やせます）
]

# 上記のリストから、環境変数に与えられたIPアドレスを使って、
# 該当する設定情報を引き出す
node_host_setting =
  case node_host_setting_list[:"#{target_ip}"] do
    {nodename, b, role} ->
      # 見つかったら、設定情報の部分を返す
      {nodename, target_ip, b, role}

    _ ->
      # 見つからなかったら、デフォルト値を返す
      {"none", "127.0.0.1", nil, nil}  end

# アプリケーションの中から、現在の設定情報を参照できるようにする
config :projectname, hostselect: node_host_setting

# パスフレーズを指定する
node_host_cookie = "passphrase"
config :projectname, hostcookie: node_host_cookie

# 確認用のログ・選択中の設定内容を表示
IO.puts(" *** > target settings : #{inspect(node_host_setting)} / #{inspect(node_host_cookie)}")

（･･･省略･･･）
```

次に、`lib/projectname/application.ex`を細工します。
起動時の環境変数で選択された設定情報を使って、GenServerの起動対象や、ノード名の設定を動的に切り替えます。

```elixir:lib/projectname/application.ex
（･･･省略･･･）

 def children(_target) do
    # 選択中の設定情報を読み込み
    {nodename, ipaddr, role} =
      case Application.get_env(:projectname, :hostselect) do
        {a, b, _, d} -> {a, b, d}
        _ -> {"hostname", "127.0.0.1", false}
      end

    # 選択中の”役割”を表示
    IO.puts("App role: #{role}")

     role_nodes =
      case role do
        :role_hoge ->
            # 役割hoge
          [
            # 役割hogeで起動したいGenServer
            {ProjectName.FuncA, nil}
            {ProjectName.FuncB, nil}
          ]
        
        :role_fuga ->
            # 役割fuga
          [
            # 役割fugaで起動したいGenServer
            {ProjectName.FuncY, nil}
            {ProjectName.FuncZ, nil}
          ]

        _ ->
            # 役割が未定義の時はエラー
          IO.puts("Role error")
          nil
      end

    # 選択中のノード名・IPアドレスでノード初期化
    Node.start(:"#{nodename}@#{ipaddr}")
    # cookieを設定
    Node.set_cookie(:"#{Application.get_env(:projectname, :hostcookie)}")

（･･･省略･･･）
```

実行方法は、前述の「mixコマンドの例」を参照ください。


# ５．まとめ

１つのプロジェクトで複数のNerves機器の動作機能を使い分ける仕組みとして、
mixを実行するときに与える環境変数を使って、挙動を変える方法を試行してみました。

これを活用する場面は限定的かとは思いますが、ご参考になる方があれば幸いです。

# ６．参考資料

- [Elixirでネットワーク越しのプロセス通信](https://hotch-potch.hatenadiary.jp/entry/2022/12/29/220000)
- [Elixirのノード接続とメッセージ通信](https://dev.classmethod.jp/articles/elixir-connecting-nodes/)
- [Elixir Node](https://hexdocs.pm/elixir/Node.html)
- [Nerves - Elixir School](https://elixirschool.com/ja/lessons/misc/nerves)