ModBoss

ModBoss logo

Elixir CI

Show that Bus who's Boss!

ModBoss is an Elixir library that maps Modbus registers to human-friendly names and provides automatic encoding/decoding of values—making your application logic simpler and more readable, and making testing of modbus concerns easier.

Note that ModBoss doesn't handle the actual reading/writing of modbus registers—it simply assists in providing friendlier access to register values. You'll likely be wrapping another library such as Modbux for the actual reads/writes.

Installation

If available in Hex, the package can be installed by adding modboss to your list of dependencies in mix.exs:

def deps do
  [
    {:modboss, "~> 0.1.0"}
  ]
end

Usage

1. Map your schema

Starting with the type of register, you'll define the addresses to include and a friendly name for the mapping.

The :as option dictates how values will be encoded before being written to Modbus or decoded after being read from Modbus. You can use translation functions from another module—like those found in ModBoss.Encoding—or provide your own as shown here with as: :fw_version.

When providing your own translation functions, ModBoss expects that you'll provide functions corresponding to the :as option but with encode_ / decode_ prefixes added as applicable. These functions will receive the value to be translated and should return either {:ok, translated_value} or {:error, message}.

defmodule MyDevice.Schema do
  use ModBoss.Schema

  modbus_schema do
    holding_register 1, :outdoor_temp, as: {ModBoss.Encoding, :signed_int}
    holding_register 2..5, :model_name, as: {ModBoss.Encoding, :ascii}
    holding_register 6, :version, as: :fw_version, mode: :rw
    # Also supports: input_register / coil / discrete_input
  end

  def encode_fw_version(value) do
    encoded_value = do_encode(value)
    {:ok, encoded_value}
  end

  def decode_fw_version(value) do
    decoded_value = some_decode_logic(value)
    {:ok, decoded_value}
  end
end

In this example:

  • Holding register at address 1 is named outdoor_temp and uses the built-in signed_int decoder that ships with ModBoss.
  • Holding registers 2–5 are grouped under the name model_name and use a built-in ASCII decoder.
  • Holding register 6 is named version and uses encode_fw_version/1 and decode_fw_version/1 to translate values being written or read respectively.

2. Provide generic read/write functions

You'll need to provide a read_func/3 and a write func/3 for actually interacting on the Modbus. In practice, these functions will likely build on a library like Modbux along with state stored in a GenServer (e.g. a modbux_pid, IP Address, etc.) to perform the read/write operations.

For each batch, the read_func will be provided the type of register (:holding_register, :input_register, :coil, or :discrete_input), the starting address, and the number of addresses to read. It must return either {:ok, result} or {:error, message}.

read_func = fn register_type, starting_address, count ->
  result = custom_read_logic()
  {:ok, result}
end

For each batch, the write_func will be provided the type of register (:holding_register or :coil), the starting address for the batch to be written, and a list of values to write. It must return either :ok or {:error, message}.

write_func = fn register_type, starting_address, value_or_values ->
  result = custom_write_logic()
  {:ok, result}
end

3. Read & Write by name!

From here you can read and write by name…

Requesting a single value returns just one value:

iex> ModBoss.read(MyDevice.Schema, read_func, :outdoor_temp)
{:ok, 72}

Requesting multiple values returns a map:

iex> ModBoss.read(MyDevice.Schema, read_func, [:outdoor_temp, :model_name, :version])
{:ok, %{outdoor_temp: 72, model_name: "AI4000", version: "0.1"}}

Writing is performed via a keyword list or map:

iex> ModBoss.write(MyDevice.Schema, write_func, version: "0.2")
:ok

Benefits

Extracting your Modbus schema allows you to isolate the encode/decode logic making it much more testable. Your primary application logic becomes simpler and more readable since it references registers by name and doesn't need to worry about encoding/decoding of values. It also becomes fairly straightforward to set up virtual devices with the exact same register mappings as your physical devices (e.g. using an Elixir Agent to hold the state of the registers in a map). And it makes for easier troubleshooting since you don't need to memorize (or look up) the register mappings when you're at an iex prompt.