Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v0.1.1]

### Added

- Add `ModBoss.encode/2` for encoding mappings to objects without actually writing via Modbus.

### Changed

- Don't allow `:all` as an object name in a schema; reserve it as a special keyword for requesting
all readable registers.

### Removed

- Remove undocumented `ModBoss.read_all/2`. In practice, it seems simpler to use a reserved `:all`
keyword to read all objects configured as readable.

### Fixed

- Allow address reuse across object types per the Modbus spec.

## [v0.1.0]

### Initial Release
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

## Show that Bus who's Boss!

ModBoss is an Elixir library that maps Modbus registers to human-friendly names and provides
ModBoss is an Elixir library that maps Modbus objects 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
Note that ModBoss doesn't handle the actual reading/writing of modbus objects—it simply assists
in providing friendlier access to object values. You'll likely be wrapping another library such
as [Modbux](https://hexdocs.pm/modbux/readme.html) for the actual reads/writes.

## Installation
Expand All @@ -31,12 +31,12 @@ end

### 1. Map your schema

Starting with the type of register, you'll define the addresses to include and a friendly name
Starting with the type of object, 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`.
The `:as` option dictates how values will be translated. 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.
Expand Down Expand Up @@ -81,23 +81,23 @@ interacting on the Modbus. In practice, these functions will likely build on a l
[Modbux](https://hexdocs.pm/modbux/readme.html) 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
For each batch, the read_func will be provided the object type
(`: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}`.

```elixir
read_func = fn register_type, starting_address, count ->
read_func = fn object_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
For each batch, the `write_func` will be provided the type of object (`: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}`.

```elixir
write_func = fn register_type, starting_address, value_or_values ->
write_func = fn object_type, starting_address, value_or_values ->
result = custom_write_logic(…)
{:ok, result}
end
Expand Down Expand Up @@ -129,8 +129,8 @@ iex> ModBoss.write(MyDevice.Schema, write_func, version: "0.2")

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
readable** since it references mappings 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.
same object mappings as your physical devices (e.g. using an Elixir Agent to hold the state of
the modbus objects in a map). And it makes for **easier troubleshooting** since you don't need to
memorize (or look up) the object mappings when you're at an `iex` prompt.
117 changes: 54 additions & 63 deletions lib/modboss.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,21 @@ defmodule ModBoss do
alias ModBoss.Mapping

@typep mode :: :readable | :writable | :any
@type register_type :: :holding_register | :input_register | :coil | :discrete_input
@type read_func :: (register_type(), starting_address :: integer(), count :: integer() ->
@type object_type :: :holding_register | :input_register | :coil | :discrete_input
@type read_func :: (object_type(), starting_address :: integer(), count :: integer() ->
{:ok, any()} | {:error, any()})
@type write_func :: (register_type(), starting_address :: integer(), value_or_values :: any() ->
@type write_func :: (object_type(), starting_address :: integer(), value_or_values :: any() ->
:ok | {:error, any()})
@type values :: [{atom(), any()}] | %{atom() => any()}

@doc false
def read_all(module, read_func, opts \\ []) do
readable_mappings =
module.__modbus_schema__()
|> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end)
|> Enum.map(fn {name, _mapping} -> name end)

read(module, read_func, readable_mappings, opts)
end

@doc """
Read from modbus using named mappings.

This function takes either an atom or a list of atoms representing the mappings to read,
batches the mappings into contiguous addresses per type, then reads and decodes the values
before returning them.

For each batch, `read_func` will be called with the type of register (`:holding_register`,
For each batch, `read_func` will be called with the type of modbus object (`:holding_register`,
`:input_register`, `:coil`, or `:discrete_input`), the starting address for the batch
to be read, and the count of addresses to read from. It must return either `{:ok, result}`
or `{:error, message}`.
Expand All @@ -46,7 +36,7 @@ defmodule ModBoss do

## Examples

read_func = fn register_type, starting_address, count ->
read_func = fn object_type, starting_address, count ->
result = custom_read_logic(…)
{:ok, result}
end
Expand All @@ -70,25 +60,26 @@ defmodule ModBoss do
@spec read(module(), read_func(), atom() | [atom()], keyword()) ::
{:ok, any()} | {:error, any()}
def read(module, read_func, name_or_names, opts \\ []) do
readable_mappings =
module.__modbus_schema__()
|> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end)
|> Enum.map(fn {name, _mapping} -> name end)

{names, plurality} =
case name_or_names do
:all -> {readable_mappings, :plural}
:all -> {readable_mappings(module), :plural}
name when is_atom(name) -> {[name], :singular}
names when is_list(names) -> {names, :plural}
end

with {:ok, mappings} <- get_mappings(:readable, module, names),
{:ok, mappings} <- read_registers(module, mappings, read_func),
{:ok, mappings} <- read_mappings(module, mappings, read_func),
{:ok, mappings} <- decode(mappings) do
collect_results(mappings, plurality, opts)
end
end

defp readable_mappings(module) do
module.__modbus_schema__()
|> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end)
|> Enum.map(fn {name, _mapping} -> name end)
end

defp collect_results(mappings, plurality, opts) do
field_to_return = if Keyword.get(opts, :decode, true), do: :value, else: :encoded_value

Expand Down Expand Up @@ -130,8 +121,8 @@ defmodule ModBoss do
mapping.encoded_value
|> List.wrap()
|> Enum.with_index(mapping.starting_address)
|> Enum.map(fn {value_for_register, address} ->
{{mapping.type, address}, value_for_register}
|> Enum.map(fn {value_for_object, address} ->
{{mapping.type, address}, value_for_object}
end)
end)
|> Enum.into(%{})
Expand All @@ -141,9 +132,9 @@ defmodule ModBoss do
Write to modbus using named mappings.

ModBoss automatically encodes your `values`, then batches any encoded values destined for
contiguous registers—creating separate batches per register type.
contiguous objects—creating separate batches per object type.

For each batch, `write_func` will be called with the type of register (`:holding_register` or
For each batch, `write_func` will be called with the type of object (`: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}`.

Expand All @@ -163,7 +154,7 @@ defmodule ModBoss do

## Example

write_func = fn register_type, starting_address, value_or_values ->
write_func = fn object_type, starting_address, value_or_values ->
result = custom_write_logic(…)
{:ok, result}
end
Expand All @@ -176,7 +167,7 @@ defmodule ModBoss do
with {:ok, mappings} <- get_mappings(:writable, module, get_keys(values)),
mappings <- put_values(mappings, values),
{:ok, mappings} <- encode(mappings),
{:ok, _mappings} <- write_registers(module, mappings, write_func) do
{:ok, _mappings} <- write_mappings(module, mappings, write_func) do
:ok
end
end
Expand All @@ -191,11 +182,11 @@ defmodule ModBoss do
end

@spec get_mappings(mode(), module(), list()) :: {:ok, [Mapping.t()]} | {:error, String.t()}
defp get_mappings(mode, module, register_names) when is_list(register_names) do
defp get_mappings(mode, module, mapping_names) when is_list(mapping_names) do
schema = module.__modbus_schema__()

{mappings, unknown_names} =
register_names
mapping_names
|> Enum.map(fn name ->
case Map.get(schema, name, :unknown) do
:unknown -> name
Expand All @@ -214,11 +205,11 @@ defmodule ModBoss do

mode == :readable and Enum.any?(unreadable(mappings)) ->
names = unreadable(mappings) |> Enum.map_join(", ", fn %{name: name} -> inspect(name) end)
{:error, "Register(s) #{names} in #{inspect(module)} are not readable."}
{:error, "ModBoss Mapping(s) #{names} in #{inspect(module)} are not readable."}

mode == :writable and Enum.any?(unwritable(mappings)) ->
names = unwritable(mappings) |> Enum.map_join(", ", fn %{name: name} -> inspect(name) end)
{:error, "Register(s) #{names} in #{inspect(module)} are not writable."}
{:error, "ModBoss Mapping(s) #{names} in #{inspect(module)} are not writable."}

true ->
{:ok, mappings}
Expand All @@ -228,20 +219,20 @@ defmodule ModBoss do
defp unreadable(mappings), do: Enum.reject(mappings, &Mapping.readable?/1)
defp unwritable(mappings), do: Enum.reject(mappings, &Mapping.writable?/1)

@spec read_registers(module(), [Mapping.t()], fun) :: {:ok, [Mapping.t()]} | {:error, any()}
defp read_registers(module, mappings, read_func) do
with {:ok, all_values} <- do_read_registers(module, mappings, read_func) do
@spec read_mappings(module(), [Mapping.t()], fun) :: {:ok, [Mapping.t()]} | {:error, any()}
defp read_mappings(module, mappings, read_func) do
with {:ok, all_values} <- do_read_mappings(module, mappings, read_func) do
Enum.map(mappings, fn
%Mapping{register_count: 1} = mapping ->
%Mapping{address_count: 1} = mapping ->
value = Map.fetch!(all_values, mapping.starting_address)
%{mapping | encoded_value: value}

%Mapping{register_count: _plural} = mapping ->
registers = Enum.to_list(mapping.addresses)
%Mapping{address_count: _plural} = mapping ->
addresses = Enum.to_list(mapping.addresses)

values =
all_values
|> Map.take(registers)
|> Map.take(addresses)
|> Enum.sort_by(fn {address, _value} -> address end)
|> Enum.map(fn {_address, value} -> value end)

Expand All @@ -251,15 +242,15 @@ defmodule ModBoss do
end
end

@spec do_read_registers(module(), [Mapping.t()], fun) :: {:ok, map()}
defp do_read_registers(module, mappings, read_func) do
@spec do_read_mappings(module(), [Mapping.t()], fun) :: {:ok, map()}
defp do_read_mappings(module, mappings, read_func) do
mappings
|> chunk_mappings(module, :read)
|> Enum.map(fn [first | _rest] = chunk ->
initial_acc = {first.type, first.starting_address, 0}

Enum.reduce(chunk, initial_acc, fn mapping, {type, starting_address, register_count} ->
{type, starting_address, register_count + mapping.register_count}
Enum.reduce(chunk, initial_acc, fn mapping, {type, starting_address, address_count} ->
{type, starting_address, address_count + mapping.address_count}
end)
end)
|> Enum.reduce_while({:ok, %{}}, fn batch, {:ok, acc} ->
Expand All @@ -271,13 +262,13 @@ defmodule ModBoss do
end

@spec read_batch(fun(), {any(), integer(), integer()}) :: {:ok, map()} | {:error, any()}
defp read_batch(read_func, {type, starting_address, register_count}) do
with {:ok, value_or_values} <- read_func.(type, starting_address, register_count) do
defp read_batch(read_func, {type, starting_address, address_count}) do
with {:ok, value_or_values} <- read_func.(type, starting_address, address_count) do
values = List.wrap(value_or_values)
value_count = Enum.count(values)

if value_count != register_count do
raise "Attempted to read #{register_count} registers starting from address #{starting_address} but received #{value_count} values."
if value_count != address_count do
raise "Attempted to read #{address_count} values starting from address #{starting_address} but received #{value_count} values."
end

batch_results =
Expand All @@ -289,7 +280,7 @@ defmodule ModBoss do
end
end

defp write_registers(module, mappings, write_func) do
defp write_mappings(module, mappings, write_func) do
mappings
|> chunk_mappings(module, :write)
|> Enum.map(fn [first | _rest] = chunk ->
Expand All @@ -313,24 +304,24 @@ defmodule ModBoss do
end

@spec chunk_mappings([Mapping.t()], module(), :read | :write) ::
[{register_type(), integer(), [any()]}]
[{object_type(), integer(), [any()]}]
defp chunk_mappings(mappings, module, mode) do
chunk_fun = fn %Mapping{type: type, addresses: %Range{first: address}} = mapping, acc ->
max_chunk = module.__max_batch__(mode, type)

case acc do
{[], 0} when mapping.register_count <= max_chunk ->
{:cont, {[mapping], mapping.register_count}}
{[], 0} when mapping.address_count <= max_chunk ->
{:cont, {[mapping], mapping.address_count}}

{[prior | _] = mappings, count}
when prior.addresses.last + 1 == address and count + mapping.register_count <= max_chunk ->
{:cont, {[mapping | mappings], count + mapping.register_count}}
when prior.addresses.last + 1 == address and count + mapping.address_count <= max_chunk ->
{:cont, {[mapping | mappings], count + mapping.address_count}}

{mappings, _count} when mapping.register_count <= max_chunk ->
{:cont, Enum.reverse(mappings), {[mapping], mapping.register_count}}
{mappings, _count} when mapping.address_count <= max_chunk ->
{:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}}

{_, _} when mapping.register_count > max_chunk ->
raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} registers."
{_, _} when mapping.address_count > max_chunk ->
raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects."
end
end

Expand Down Expand Up @@ -363,7 +354,7 @@ defmodule ModBoss do
defp encode_value(%Mapping{} = mapping) do
with {module, function, args} <- get_encode_mfa(mapping),
{:ok, encoded} <- apply(module, function, args),
:ok <- verify_register_count(mapping, encoded) do
:ok <- verify_value_count(mapping, encoded) do
{:ok, encoded}
end
end
Expand All @@ -375,9 +366,9 @@ defmodule ModBoss do
# we only pass the value to be encoded.
#
# However, when calling built-in encoding functions, we pass both the value to be encoded
# _and_ the mapping. We do this because in some cases we need to know how many registers
# _and_ the mapping. We do this because in some cases we need to know how many objects
# we're encoding for in order to provide truly generic encoders. For example, when encoding
# a string to ASCII, we may need to add padding to fill out the mapped registers.
# a string to ASCII, we may need to add padding to fill out the mapped objects.
arguments =
case module do
ModBoss.Encoding -> [mapping.value, mapping]
Expand Down Expand Up @@ -421,16 +412,16 @@ defmodule ModBoss do
end
end

defp verify_register_count(mapping, encoded) do
expected_count = mapping.register_count
defp verify_value_count(mapping, encoded) do
expected_count = mapping.address_count

case List.wrap(encoded) |> length() do
^expected_count ->
:ok

_ ->
{:error,
"Encoded value #{inspect(encoded)} for #{inspect(mapping.name)} does not match the number of registers."}
"Encoded value #{inspect(encoded)} for #{inspect(mapping.name)} does not match the number of mapped addresses."}
end
end

Expand Down
Loading