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
@type register_type :: :holding_register | :input_register | :coil | :discrete_input
@type read_func :: (register_type(), starting_address :: integer(), count :: integer() ->
{:ok, any()} | {:error, any()})
@type write_func :: (register_type(), starting_address :: integer(), value_or_values :: any() ->
:ok | {:error, any()})
@type values_to_write :: [{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`,
`: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 register_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
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}
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} <- decode(mappings) do
collect_results(mappings, plurality, opts)
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 """
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.
For each batch, `write_func` will be called with 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}`.
> #### 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 register_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_to_write()) :: :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_registers(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, register_names) when is_list(register_names) do
schema = module.__modbus_schema__()
{mappings, unknown_names} =
register_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 register(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, "Register(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."}
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_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
Enum.map(mappings, fn
%Mapping{register_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)
values =
all_values
|> Map.take(registers)
|> 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_registers(module(), [Mapping.t()], fun) :: {:ok, map()}
defp do_read_registers(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}
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, register_count}) do
with {:ok, value_or_values} <- read_func.(type, starting_address, register_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."
end
batch_results =
values
|> Enum.with_index(starting_address)
|> Enum.into(%{}, fn {value, address} -> {address, value} end)
{:ok, batch_results}
end
end
defp write_registers(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) ::
[{register_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}}
{[prior | _] = mappings, count}
when prior.addresses.last + 1 == address and count + mapping.register_count <= max_chunk ->
{:cont, {[mapping | mappings], count + mapping.register_count}}
{mappings, _count} when mapping.register_count <= max_chunk ->
{:cont, Enum.reverse(mappings), {[mapping], mapping.register_count}}
{_, _} when mapping.register_count > max_chunk ->
raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} registers."
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_register_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 registers
# 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.
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_register_count(mapping, encoded) do
expected_count = mapping.register_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."}
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