昨年から今年にかけて @kikuyuta の小水力発電所のプロジェクトに参加しました。
このプロジェクトでは、「Nerves」 と 「DIO や温度等を取得する機器」との通信に Modbus というプロトコルを採用しました。
発電所を制御する Nerves が DI(接点入力) を読んだり、DO(接点出力) を書いたりしますが、そのための通信に Modbus という通信プロトコルを使っているわけです。
この記事ではその Modbus 用の Elixir 製ライブラリ Modbuzz について紹介いたします。 Modbuzz はこのプロジェクトのために我々が新たに開発したライブラリです。
Modbus プロトコルについて
Modbus Protocolは、1979年にModiconによって開発されたメッセージング構造です。インテリジェントデバイス間のクライアントとサーバー間の通信を確立するために使用されます。事実上の標準であり、真に開放的で、工業製造環境において最も広く使用されているネットワークプロトコルである。数百のベンダーが数千種類の異なるデバイス上で導入し、ディスクリート/アナログI/Oを転送して制御デバイス間でデータを登録できるようにしています。異なるメーカー間の共通言語、つまり共通項です。ある報告書は、これを「マルチベンダー統合における事実上の標準」と呼んだ。業界アナリストは、北米およびヨーロッパだけで700万以上のModbusノードを報告しています。
ref. https://www.modbus.org/faq の What is Modbus ® protocol? より翻訳
Modbus は歴史のある産業向けのプロトコルで、シリアル通信(RS-485, RS-232) と TCP の仕様があります。仕様は公開されています。
この通信プロトコルにはクライアントとサーバーが存在し、クライアントがサーバーに対しデータ要求を行うとサーバーが応答を返します。
小水力発電所を例に取ると、 Nerves がクライアントで DIO の機器がサーバーです。 Nerves が機器に対しデータ要求を行い、機器が応答します。
シリアル通信の場合、サーバー(機器)は ID で区別されます。データフレームは以下のようになっています。※ TCP の場合は、IP アドレスで区別されます。
※仕様書上ではクライアントとサーバーを最近は使わなくなったマスターとスレーブとして呼んでいる箇所があるので注意です。クライアントがマスター、サーバーがスレーブという対応関係です。
機器に対しては、フレーム内の Function Code と Data で異なる要求ができます。
おおざっぱにはこんな感じ。
なぜ Elixir 製の Modbus ライブラリを新たに開発したか?
hex.pm で modbus で検索するといくつかヒットします。
Elixir 製の Modbus ライブラリの有名所は ModBux です。当初この Modbux の TCP クライアント機能を使おうとしました。
が、 issue を発見し、その issue は簡単には修正できないように見えました。
この「簡単には修正できないように見え」るというのは、
- 局所的な修正で直せない
- ModBux の大域的な実装方針が理解できていないために大きな変更には手を出せない
が要因としてあったと思います。
小水力発電所のプロジェクトにとって、Modbus ライブラリは核です。なぜなら機器制御の IO を司るからです。
ライブラリを選定するに当たり、仮に同じような要因を持つの別の issue を見つけてしまった場合に、コントロールできずリスクになると感じました。
そのため、勇気はいりますが、自前の Modbus ライブラリを開発することにしました。
バグを見つけたとき、理解が行き届かず手探りになるよりも理解が行き届いている自前のコードである方が良いように思えたからです。
Modbuzz について
Modbuzz は、
Yet another MODBUS library, supporting both TCP and RTU, providing gateway functionality.
です。※ Yet another は Yacc (Yet Another Compiler-Compiler) からとってちょっとカッコつけています。(中二病かもしれません)
機能として以下があります。
- TCP, RS-485 の両方をサポート
- クライアント、サーバーの両方をサポート
- ゲートウェイ(TCP <-> RS-485 変換)
今回の小水力発電所では TCP クライアントとしての機能のみを使っています。
※現場調整でのデバッグ時に RTU(RS-485) クライアントの機能を一時的に使い助かりました。両方サポートして良かったと思った記憶があります。
現時点では、データ要求は同期呼び出しのみをサポートしており、非同期でデータ要求をする場合はライブラリのユーザーサイドで Task を使ってもらうことにしています。
※ Modbus では,データはサーバー側からプッシュされることはないので、必ずクライアント側から取りに行く必要があります。このため Nerves (クライアント)が機器の最新の状態を把握するためには、一定周期でサーバーにリクエストを行い続ける必要があります。
実装して再確認する Elixir(Erlang) の強み
やはり、バイナリパターンマッチです。
通信ライブラリを作る場合、仕様に沿ったバイト列をハンドリングすることになりますが、ここでバイナリパターンマッチの強みが炸裂します。以下は TCP の Application Data Unit に関わる関数です。
-
encodeで struct をバイナリ列へ -
decodeでバイナリ列を struct へ
データフレームの定義をそのまま明示的に書くことができている点が嬉しいです。
def new(pdu, transaction_id, unit_id) when is_binary(pdu) do
%__MODULE__{
transaction_id: transaction_id,
length: byte_size(pdu) + @unit_id_byte_size,
unit_id: unit_id,
pdu: pdu
}
end
def encode(%__MODULE__{} = adu) do
<<adu.transaction_id::16, adu.protocol_id::16, adu.length::16, adu.unit_id,
adu.pdu::binary-size(adu.length - 1)>>
end
def decode(
<<transaction_id::16, protocol_id::16, length::16, unit_id, pdu::binary-size(length - 1)>>
) do
%__MODULE__{
transaction_id: transaction_id,
protocol_id: protocol_id,
length: length,
unit_id: unit_id,
pdu: pdu
}
end
ref. https://github.com/tombo-works/modbuzz/blob/v0.2.0/lib/modbuzz/tcp/adu.ex から抜粋
マッチした値をそのバイナリパターンマッチの中で使える(decode/1 における length)のは強力ですよね。
※出自が通信向け Erlang ですのでパケット分解で当然必要な機能と思いますが、本当に強力!
こんな Modbuzz をどうぞよろしく
Modbus ライブラリを使うことは、なかなか無いかもしれませんがもし使うことがあったら、 Modbuzz を思い出していただけたら、そして使ってみてフィードバックいただけたら幸いです。「ドキュメントが弱い!」とか(自覚あり。。)
あ、あと、Github に⭐をつけていただけると励みになります🎉🎉🎉
最後に
小水力発電所プロジェクトの内容は Nerves Conf EU や Open Source Conference で @kikuyuta と 私 @pojiro で発表しました。見ていただけたら幸いです🎉🎉🎉🎉🎉
Nerves Conf EU @スウェーデンでの発表(英語)
Open Source Conference 2025 Online/Fall での発表(日本語)
こちらもグッドボタンを押してもらえると嬉しいです!


