Current section
Files
Jump to
Current section
Files
lib/modboss.ex
defmodule ModBoss do
@moduledoc """
Human-friendly modbus reading, writing, and translation.
Read and write modbus values by name, with automatic encoding and decoding.
"""
alias ModBoss.Mapping
@typep mode :: :readable | :writable | :any
@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 :: (object_type(), starting_address :: integer(), value_or_values :: any() ->
:ok | {:error, any()})
@type values :: [{atom(), any()}] | %{atom() => any()}
@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 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}`.
If a single name is requested, the result will be an :ok tuple including the singule result
for that named mapping. If a list of names is requested, the result will be an :ok tuple
including a map with mapping names as keys and mapping values as results.
## Opts
* `:decode` — if `false`, returns the "raw" result as provided by `read_func`; defaults to `true`
## Examples
read_func = fn object_type, starting_address, count ->
result = custom_read_logic(…)
{:ok, result}
end
# Read one mapping
ModBoss.read(SchemaModule, read_func, :foo)
{:ok, 75}
# Read multiple mappings
ModBoss.read(SchemaModule, read_func, [:foo, :bar, :baz])
{:ok, %{foo: 75, bar: "ABC", baz: true}}
# Read *all* readable mappings
ModBoss.read(SchemaModule, read_func, :all)
{:ok, %{foo: 75, bar: "ABC", baz: true, qux: 1024}}
# Get "raw" Modbus values (as returned by `read_func`)
ModBoss.read(SchemaModule, read_func, :all, decode: false)
{:ok, %{foo: 75, bar: [16706, 17152], baz: 1, qux: 1024}}
"""
@spec read(module(), read_func(), atom() | [atom()], keyword()) ::
{:ok, any()} | {:error, any()}
def read(module, read_func, name_or_names, opts \\ []) do
{names, plurality} =
case name_or_names do
: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_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
mappings
|> Enum.map(&{&1.name, Map.get(&1, field_to_return)})
|> then(fn results ->
case {results, plurality} do
{[{_, return_value}], :singular} -> {:ok, return_value}
{results, :plural} -> {:ok, Enum.into(results, %{})}
end
end)
end
@doc """
Encode values per the mapping without actually writing them.
This can be useful in test scenarios and enables values to be encoded in bulk without actually
being written via Modbus.
Returns a map with keys of the form `{type, address}` and `encoded_value` as values.
## Example
iex> ModBoss.encode(MyDevice.Schema, foo: "Yay")
{:ok, %{{:holding_register, 15} => 22881, {:holding_register, 16} => 30976}}
"""
@spec encode(module(), values()) :: {:ok, map()} | {:error, any()}
def encode(module, values) when is_atom(module) do
with {:ok, mappings} <- get_mappings(:any, module, get_keys(values)),
mappings <- put_values(mappings, values),
{:ok, mappings} <- encode(mappings) do
{:ok, flatten_encoded_values(mappings)}
end
end
defp flatten_encoded_values(mappings) do
mappings
|> Enum.flat_map(fn %Mapping{} = mapping ->
mapping.encoded_value
|> List.wrap()
|> Enum.with_index(mapping.starting_address)
|> Enum.map(fn {value_for_object, address} ->
{{mapping.type, address}, value_for_object}
end)
end)
|> Enum.into(%{})
end
@doc """
Write to modbus using named mappings.
ModBoss automatically encodes your `values`, then batches any encoded values destined for
contiguous objects—creating separate batches per object type.
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}`.
> #### Batch values {: .info}
>
> Each batch will contain **either a list or an individual value** based on the number of
> addresses to be written—so you should be prepared for both.
> #### Non-atomic writes! {: .warning}
>
> While `ModBoss.write/3` has the _feel_ of being atomic, it's important to recognize that it
> is not! It's fully possible that a write might fail after prior writes within the same call to
> `ModBoss.write/3` have already succeeded.
>
> Within `ModBoss.write/3`, if any call to `write_func` returns an error tuple,
> the function will immediately abort, and any subsequent writes will be skipped.
## Example
write_func = fn object_type, starting_address, value_or_values ->
result = custom_write_logic(…)
{:ok, result}
end
iex> ModBoss.write(MyDevice.Schema, write_func, foo: 75, bar: "ABC")
:ok
"""
@spec write(module(), write_func(), values()) :: :ok | {:error, any()}
def write(module, write_func, values) when is_atom(module) and is_function(write_func) do
with {:ok, mappings} <- get_mappings(:writable, module, get_keys(values)),
mappings <- put_values(mappings, values),
{:ok, mappings} <- encode(mappings),
{:ok, _mappings} <- write_mappings(module, mappings, write_func) do
:ok
end
end
defp get_keys(params) when is_map(params), do: Map.keys(params)
defp get_keys(params) when is_list(params), do: Keyword.keys(params)
defp put_values(mappings, params) do
for mapping <- mappings do
%{mapping | value: params[mapping.name]}
end
end
@spec get_mappings(mode(), module(), list()) :: {:ok, [Mapping.t()]} | {:error, String.t()}
defp get_mappings(mode, module, mapping_names) when is_list(mapping_names) do
schema = module.__modbus_schema__()
{mappings, unknown_names} =
mapping_names
|> Enum.map(fn name ->
case Map.get(schema, name, :unknown) do
:unknown -> name
mapping -> mapping
end
end)
|> Enum.split_with(fn
%Mapping{} -> true
_name -> false
end)
cond do
Enum.any?(unknown_names) ->
names = unknown_names |> Enum.map_join(", ", fn name -> inspect(name) end)
{:error, "Unknown mapping(s) #{names} for #{inspect(module)}."}
mode == :readable and Enum.any?(unreadable(mappings)) ->
names = unreadable(mappings) |> Enum.map_join(", ", fn %{name: name} -> inspect(name) end)
{: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, "ModBoss Mapping(s) #{names} in #{inspect(module)} are not writable."}
true ->
{:ok, mappings}
end
end
defp unreadable(mappings), do: Enum.reject(mappings, &Mapping.readable?/1)
defp unwritable(mappings), do: Enum.reject(mappings, &Mapping.writable?/1)
@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{address_count: 1} = mapping ->
value = Map.fetch!(all_values, mapping.starting_address)
%{mapping | encoded_value: value}
%Mapping{address_count: _plural} = mapping ->
addresses = Enum.to_list(mapping.addresses)
values =
all_values
|> Map.take(addresses)
|> Enum.sort_by(fn {address, _value} -> address end)
|> Enum.map(fn {_address, value} -> value end)
%{mapping | encoded_value: values}
end)
|> then(&{:ok, &1})
end
end
@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, address_count} ->
{type, starting_address, address_count + mapping.address_count}
end)
end)
|> Enum.reduce_while({:ok, %{}}, fn batch, {:ok, acc} ->
case read_batch(read_func, batch) do
{:ok, values_by_address} -> {:cont, {:ok, Map.merge(acc, values_by_address)}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
@spec read_batch(fun(), {any(), integer(), integer()}) :: {:ok, map()} | {:error, any()}
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 != address_count do
raise "Attempted to read #{address_count} values starting from address #{starting_address} but received #{value_count} values."
end
batch_results =
values
|> Enum.with_index(starting_address)
|> Enum.into(%{}, fn {value, address} -> {address, value} end)
{:ok, batch_results}
end
end
defp write_mappings(module, mappings, write_func) do
mappings
|> chunk_mappings(module, :write)
|> Enum.map(fn [first | _rest] = chunk ->
Enum.reduce(chunk, {first.type, first.starting_address, []}, fn mapping, acc ->
{type, starting_address, encoded_values} = acc
{type, starting_address, encoded_values ++ List.wrap(mapping.encoded_value)}
end)
end)
|> Enum.reduce_while(:ok, fn {type, starting_address, batch_values}, :ok ->
value_or_values =
case batch_values do
[single_value] -> single_value
[_ | _] = multiple_values -> multiple_values
end
case write_func.(type, starting_address, value_or_values) do
:ok -> {:cont, :ok}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
@spec chunk_mappings([Mapping.t()], module(), :read | :write) ::
[{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.address_count <= max_chunk ->
{:cont, {[mapping], mapping.address_count}}
{[prior | _] = mappings, 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.address_count <= max_chunk ->
{:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}}
{_, _} when mapping.address_count > max_chunk ->
raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects."
end
end
after_fun = fn {mappings, _count} -> {:cont, Enum.reverse(mappings), :ignored} end
mappings
|> Enum.group_by(& &1.type)
|> Enum.flat_map(fn {_type, mappings_for_type} ->
mappings_for_type
|> Enum.sort_by(& &1.starting_address)
|> Enum.chunk_while({[], 0}, chunk_fun, after_fun)
end)
end
defp encode(mappings) do
Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} ->
case encode_value(mapping) do
{:ok, encoded_value} ->
updated_mapping = %{mapping | encoded_value: encoded_value}
{:cont, {:ok, [updated_mapping | acc]}}
{:error, error} ->
# error = if is_binary(error), do: error, else: inspect(error)
message = "Failed to encode #{inspect(mapping.name)}. #{error}"
{:halt, {:error, message}}
end
end)
end
defp encode_value(%Mapping{} = mapping) do
with {module, function, args} <- get_encode_mfa(mapping),
{:ok, encoded} <- apply(module, function, args),
:ok <- verify_value_count(mapping, encoded) do
{:ok, encoded}
end
end
defp get_encode_mfa(%Mapping{as: {module, as}} = mapping) do
function = String.to_atom("encode_" <> "#{as}")
# In an effort to keep the API simple, when we call a user-defined encode function,
# 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 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 objects.
arguments =
case module do
ModBoss.Encoding -> [mapping.value, mapping]
_other -> [mapping.value]
end
if exists?(module, function, length(arguments)) do
{module, function, arguments}
else
{:error,
"Modbus mapping #{inspect(mapping.name)} expected #{inspect(module)} to define #{inspect(function)}, but it did not."}
end
end
@spec decode([Mapping.t()]) :: {:ok, [Mapping.t()]}
defp decode(mappings) do
Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} ->
case decode_value(mapping) do
{:ok, decoded_value} ->
updated_mapping = %{mapping | value: decoded_value}
{:cont, {:ok, [updated_mapping | acc]}}
end
end)
end
defp decode_value(%Mapping{} = mapping) do
with {module, function, args} <- get_decode_mfa(mapping) do
apply(module, function, args)
end
end
defp get_decode_mfa(%Mapping{as: {module, as}} = mapping) do
function = String.to_atom("decode_" <> "#{as}")
arguments = [mapping.encoded_value]
if exists?(module, function, length(arguments)) do
{module, function, arguments}
else
{:error,
"Modbus mapping #{inspect(mapping.name)} expected #{inspect(module)} to define #{inspect(function)}, but it did not."}
end
end
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 mapped addresses."}
end
end
defp exists?(module, function, arity) do
module
|> ensure_module_loaded!()
|> function_exported?(function, arity)
end
defp ensure_module_loaded!(module) do
case Code.ensure_loaded(module) do
{:module, ^module} -> module
{:error, reason} -> raise("Unable to load #{inspect(module)}: #{inspect(reason)}")
end
end
end