Elixir で ArtNet を扱うライブラリを作ってみた

## はじめに
この記事は [Elixir Advent Calendar 2024](https://qiita.com/advent-calendar/2024/elixir) の 12/24 の記事です。

昨日の記事では、ArtNet のパケットを Elixir でパースする処理を紹介しました。

https://qiita.com/nasshu2916/items/d0fd5dfc9559d68f6255

今日は、ArtNet をパースするライブラリを作成したので、その紹介と実装内容を紹介します。

## artnet_ex

名前の通り、ArtNet を Elixir で扱うためのライブラリです。

https://github.com/nasshu2916/artnet_ex

昨日の記事では ArtNet のパケットをParseした結果を map で返していましが、
struct で返した方がパターンマッチなどで使いやすい場合があります。
そのため、このライブラリでは ArtNet のパケットを decode すると struct で返すようにしています。
また、encode にも対応しているため、ArtNet の struct からバイナリに変換も可能です。

ArtNet のパケットの parse の仕方は OpCode によって異なるため、op_code 毎に `defstruct` を定義し、parse のパターンマッチを書いていくのは大変です。
また、parse 結果の struct からバイナリへの変換ロジックも定義するのも非常に面倒です。
そのため、このライブラリではマクロを使って struct と型定義を自動生成するようにしています。

artnet_ex ではよく使われる op_code の ArtDmx, ArtPoll, ArtPollReply に対応しています。

## 使い方

使い方に関しては GitHub の README に使い側を少し書いていますが、リポジトリ内の livemd にサンプルコードを書いています。
このサンプルでは私が作成したライブラリをインストールし、 ArtNet パケットの encode/decode を行い UDP で送受信する方法を紹介しています。

https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/livebook/artnet_sample.livemd

使い方に関しては基本的に livemd の内容と同じなので、詳しくは livebook の内容をご覧ください。
この記事では livemd の内容を一部抜粋して紹介します。

またこの記事では livebook のインストール方法や使い方については省略します。livebook については以下の公式サイトを参照してください。

https://livebook.dev/


余談なのですが、livebook をダウンロードしてセットアップするだけで Elixir のライブラリをインストールして動作する環境を作れるのはすごいですよね。
GitHub で公開する際にサンプルコードを livemd で書いておけば、動作環境を共有できるのは便利です。

#### livebook のセットアップ

まず最初に livebook でライブラリが使えるように `Mix.install/1` を使って今回紹介するライブラリをインストールします。

```elixir
Mix.install([
  {:art_net, "~> 0.1.0", github: "nasshu2916/artnet_ex", branch: "master"}
])
```

#### Art-Net パケットの Decode/Encode

Art-Net の struct から パケットのバイナリへの encode は `ArtNet.encode/1`, `ArtNet.encode!/1` で行います。
`ArtNet.encode/1` は `{:ok, encoded_message}` か `{:error, reason}` を返します。
`ArtNet.encode!/1` は encode に失敗した場合は例外を投げます。

```elixir
iex> dmx_packet = %ArtNet.Packet.ArtDmx{sequence: 1, physical: 0, sub_universe: 0, net: 0, length: 1, data: [255]}
iex> dmx_message = ArtNet.encode!(dmx_packet)
<<65, 114, 116, 45, 78, 101, 116, 0, 0, 80, 0, 14, 1, 0, 0, 0, 0, 1, 255>>
```

Art-Net パケットのバイナリから Art-Net の struct への decode は `ArtNet.decode/1`, `ArtNet.decode!/1` で行います。
`ArtNet.decode/1` は `{:ok, decoded_packet}` か `{:error, reason}` を返します。
`ArtNet.decode!/1` は decode に失敗した場合は例外を投げます。


```elixir
iex> ArtNet.decode!(dmx_message)
%ArtNet.Packet.ArtDmx{sequence: 1, physical: 0, sub_universe: 0, net: 0, length: 1, data: [255]}
```

encode/decode が成功すると Art-Net パケットの struct が返されます。
また、encode/decode に失敗した場合は例外もしくは `{:error, reason}` が返されます。

```elixir
iex> ArtNet.decode("invalid message")
{:error, %ArtNet.DecodeError{reason: {:invalid_data, "Invalid identifier"}}}

iex> art_dmx = %ArtNet.Packet.ArtDmx{sequence: 1, physical: 0, sub_universe: 0, net: 0, length: 1, data: [255, 255]}
iex> ArtNet.decode(art_dmx)
{:error, %ArtNet.DecodeError{reason: {:invalid_data, "Invalid identifier"}}}
```

ArtDmx の パケットで encode/decode しましたが、ArtPoll のパケットでも同様に encode/decode が可能です。

```elixir
iex> poll_message = <<65, 114, 116, 45, 78, 101, 116, 0, 0, 32, 0, 14, 0, 0>>
iex> poll_packet = ArtNet.decode(poll_message)
{:ok,
 %ArtNet.Packet.ArtPoll{
   talk_to_me: %ArtNet.Packet.BitField.TalkToMe{
     reply_on_change: false,
     diagnostics: false,
     diag_unicast: false,
     vlc: false
   },
   priority: :dp_all
 }}
iex> ArtNet.encode!(poll_packet)
<<65, 114, 116, 45, 78, 101, 116, 0, 0, 32, 0, 14, 0, 0>>
```

ArtNet パケットの encode/decode ができれば erlang の `:gen_udp` を使って Art-Net パケットを送受信することができます。
送受信の方法について記載するともう一つ記事が書けそうなぐらい長くなるので、詳しくは livemd を参照してください。

### Art-Net パケットの定義

ArtNet のパケット構造を `defpacket` マクロで定義すると各モジュールで encode/decode の関数が作られます。
例えば Dmx パケットの定義は以下の様になります。

https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/lib/art_net/packet/art_dmx.ex

```elixir
defmodule ArtNet.Packet.ArtDmx do
  use ArtNet.Packet.Schema

  defpacket do
    field(:sequence, {:integer, 8}, default: 0)
    field(:physical, {:integer, 8}, default: 0)
    field(:sub_universe, {:integer, 8}, default: 0)
    field(:net, {:integer, 8}, default: 0)
    field(:length, {:integer, 16})
    field(:data, [{:integer, 8}])
  end

  @impl ArtNet.Packet.Schema
  def validate(packet) do
    %{length: data_length, data: data} = packet

    if data_length == length(data) do
      :ok
    else
      {:error, "Data length does not match the length field"}
    end
  end
end
```

この `ArtNet.Packet.ArtDmx` モジュールの関数は以下の様になります。

```elixir
iex> ArtNet.Packet.ArtDmx.__info__(:functions)
[
  __struct__: 0,
  __struct__: 1,
  decode: 1,
  encode: 1,
  op_code: 0,
  require_version_header?: 0,
  schema: 0,
  validate: 1
]
```

`defpacket` マクロで `decode/1`, `encode/1` 関数が作られたことがわかります。
この decode/encode 関数は対応する ArtNet パケットの encode/decode のみ行えます。

```elixir
iex> ArtNet.Packet.ArtDmx.decode(poll_message)
{:error, %ArtNet.DecodeError{reason: {:invalid_data, "Invalid op code"}}}

iex> ArtNet.Packet.ArtDmx.decode(dmx_message)
{:ok,
 %ArtNet.Packet.ArtDmx{
   sequence: 57,
   physical: 0,
   sub_universe: 0,
   net: 0,
   length: 1,
   data: [255]
 }}
```

`validate` 関数ですが、decode したパケットが正しいかどうかを検証するためのコールバック関数になります。
ArtDmx の場合は data の長さが length と一致しているかどうかを検証しています。

`schema` 関数はマクロで定義した field の情報を返す関数になります。
この関数の情報をもとに ArtNet パケットの encode/decode の処理が行われます。

```elixir
iex> ArtNet.Packet.ArtDmx.schema()
[
  sequence: {{:integer, 8}, [default: 0]},
  physical: {{:integer, 8}, [default: 0]},
  sub_universe: {{:integer, 8}, [default: 0]},
  net: {{:integer, 8}, [default: 0]},
  length: {{:integer, 16}, []},
  data: {[integer: 8], []}
]
```

#### encode/decode の処理

`defpacket` マクロを実際に見てもらうと分かるのですが、decode/encode の関数は `ArtNet.Packet.decode/2`, `ArtNet.Packet.encode/2` を呼び出す関数になっていて、
実際の decode/encode の処理は `ArtNet.Packet` モジュールで行っています。

https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/lib/art_net/packet/schema.ex#L74-L82

この `ArtNet.Packet` モジュールでは `schema/0` 関数の情報ももとに ArtNet パケットの encode/decode の処理を行っていきます。
各 field の encode/decode の処理は `ArtNet.Decoder.decode/3`, `ArtNet.Encoder.encode/3` に各 field の型情報を渡して処理を行っています。

https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/lib/art_net/packet.ex#L25-L34
https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/lib/art_net/packet.ex#L112-L127


#### 型定義の生成

実際にコードを書く際には Dialyzer を使った型検査を行うことが多いと思います。
そのため、このライブラリでは `defpakcet` マクロで定義した field の情報から AST 木を生成し、`@type` を定義するようにしています。

```elixir
iex> t ArtNet.Packet.ArtDmx
@type t() :: %ArtNet.Packet.ArtDmx{
        data: [:integer],
        length: :integer,
        net: :integer,
        physical: :integer,
        sequence: :integer,
        sub_universe: :integer
      }
```

`@type` を定義するための AST 木の生成方法は[マクロのこの辺](https://github.com/nasshu2916/artnet_ex/blob/7071b2a90cf6414ea4c452add5a2c474d32f8c02/lib/art_net/packet/schema.ex#L141-L151)の処理になります。


#### OpCode とモジュールの紐付け

`defpakcet` マクロで ArtNet パケットの定義を行っていますが、このマクロで定義したモジュールと OpCode を紐付けを `ArtNet.OpCode` モジュールの module attribute に定義しています。
この module attribute に定義することで `ArtNet.decode/1`, `ArtNet.encode/1` 関数を呼んだ際に、OpCode に対応するモジュールの decode/encode 関数を呼び出しています。