Packages

Modbus schema mapping with automatic encoding/decoding.

Current section

Files

Jump to
modboss lib modboss.ex
Raw

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.
## Telemetry
ModBoss optionally emits telemetry events for reads and writes via the
[`:telemetry`](https://hex.pm/packages/telemetry) library. See
`ModBoss.Telemetry` for all event names, measurements, and metadata.
"""
require Logger
alias ModBoss.Mapping
import Mapping, only: [is_adjacent: 2]
@typep mode :: :readable | :writable | :any
@type read_func :: (Mapping.object_type(),
starting_address :: Mapping.address(),
count :: Mapping.count() ->
{:ok, any()} | {:error, any()})
@type write_func :: (Mapping.object_type(),
starting_address :: Mapping.address(),
value_or_values :: any() ->
:ok | {:error, any()})
@type values :: [{atom(), any()}] | %{atom() => any()}
@object_types [:holding_register, :input_register, :coil, :discrete_input]
@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 single 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
* `:max_gap` — Allows reading across unrequested gaps to reduce the overall
number of requests that need to be made. Results from gaps will be
retrieved and discarded. This value can be specified as a single integer
(which applies to all types) or per object type. Only readable ModBoss
mappings will be included in gap reads. To opt a readable mapping out of
gap reads, specify `gap_safe: false` on that mapping. See the `:gap_safe`
option in `ModBoss.Schema` for details.
* `:debug` — if `true`, returns a map of mapping details instead of just the value;
defaults to `false`
* `:decode` — if `false`, ModBoss doesn't attempt to decode the retrieved values;
defaults to `true`. This option can be especially useful if you need insight
into a particular value that is failing to decode as expected.
* `:max_attempts` — maximum number of times each `read_func` callback will
be attempted before giving up. Defaults to `1` (no retries). Only
`{:error, _}` triggers a retry; exceptions are not retried.
* `:context` — a map of arbitrary data that will be included in the
`ModBoss.Encoding.Metadata` struct passed to decode functions (when using
2-arity decoders) and in telemetry event metadata. Defaults to `%{}`.
Useful for conditionally decoding values based on runtime information
like firmware version or hardware revision, and for identifying which
device or connection a request belongs to in telemetry handlers.
> #### Gaps {: .info}
>
> Every Modbus request incurs network round-trip overhead, so fewer, larger reads
> are often faster than many small ones—even if some of the addresses in between
> aren't needed.
>
> The `:max_gap` option allows you to specify how many unrequested addresses
> you're willing to include in a single read in order to bridge the gap between
> requested mappings and reduce the total number of requests.
> If enabled, **a single request may bridge multiple gaps, each up to that size.**
>
> A gap will only be bridged if **every** address within it belongs to a
> known, gap-safe mapping (`gap_safe: true`, the default for readable mappings).
> Unmapped addresses and mappings with `gap_safe: false` both prevent a gap
> from being bridged.
>
> For example, given this schema:
>
> schema do
> holding_register 1, :temp, as: {ModBoss.Encoding, :signed_int}
> holding_register 2, :status, as: {ModBoss.Encoding, :unsigned_int}
> holding_register 3, :error_count, as: {ModBoss.Encoding, :unsigned_int}, gap_safe: false
> holding_register 4, :mode, as: {ModBoss.Encoding, :unsigned_int}
> holding_register 5, :humidity, as: {ModBoss.Encoding, :unsigned_int}
> end
>
> Reading `:temp` and `:humidity` with `max_gap: 5` will **not** batch them
> into a single request because address 3 (`:error_count`) is not gap-safe.
> Removing `:error_count` from the schema wouldn't help either—the address
> would then be unmapped, which also prevents bridging.
## Examples
read_func = fn object_type, starting_address, count ->
result = custom_read_logic(…)
{:ok, result}
end
# Read one mapping
ModBoss.read(SchemaModule, :foo, read_func)
{:ok, 75}
# Read multiple mappings
ModBoss.read(SchemaModule, [:foo, :bar, :baz], read_func)
{:ok, %{foo: 75, bar: "ABC", baz: true}}
# Read *all* readable mappings
ModBoss.read(SchemaModule, :all, read_func)
{:ok, %{foo: 75, bar: "ABC", baz: true, qux: 1024}}
# Enable reading extra registers to reduce the number of requests
ModBoss.read(SchemaModule, [:foo, :bar], read_func, max_gap: 10)
{:ok, %{foo: 75, bar: "ABC"}}
# …or allow reading across different gap sizes per type
ModBoss.read(SchemaModule, [:foo, :bar], read_func, max_gap: %{holding_register: 10})
{:ok, %{foo: 75, bar: "ABC"}}
# Get "raw" Modbus values (as returned by `read_func`)
ModBoss.read(SchemaModule, [:foo, :bar], read_func, decode: false)
{:ok, %{foo: [75], bar: [16706, 17152]}}
"""
@spec read(module(), atom() | [atom()], read_func(), keyword()) ::
{:ok, any()} | {:error, any()}
def read(module, name_or_names, read_func, opts \\ []) do
{names, opts} = evaluate_read_opts(module, name_or_names, opts)
with {:ok, mappings} <- get_mappings(:readable, module, names) do
do_reads(module, names, mappings, read_func, opts)
end
end
@default_read_opts %{
max_gap: 0,
max_attempts: 1,
decode: true,
debug: false,
context: %{}
}
defp evaluate_read_opts(module, 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
{supported_opts, unsupported_opts} =
Keyword.split(opts, [:max_gap, :max_attempts, :debug, :decode, :context])
if unsupported_opts != [] do
raise "Unrecognized opts: #{inspect(unsupported_opts)}"
end
validated_opts =
supported_opts
|> Enum.into(@default_read_opts)
|> Map.put(:plurality, plurality)
|> Map.put(:max_gap, normalize_max_gap(supported_opts[:max_gap]))
|> validate!(:max_attempts)
|> validate!(:context)
{names, validated_opts}
end
defp readable_mappings(module) do
module.__modboss_schema__()
|> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end)
|> Enum.map(fn {name, _mapping} -> name end)
end
if Code.ensure_loaded?(:telemetry) do
defp do_reads(module, names, mappings, read_func, opts) do
start_metadata = %{schema: module, names: names, context: opts.context}
:telemetry.span([:modboss, :read], start_metadata, fn ->
{result, stats} = read_mappings(module, mappings, read_func, opts)
stop_measurements = %{
objects_requested: stats.objects,
batches: stats.batches,
addresses_read: stats.addresses,
gap_addresses_read: stats.gap_addresses,
largest_gap: stats.largest_gap,
total_attempts: stats.total_attempts
}
stop_metadata = Map.put(start_metadata, :result, result)
{result, stop_measurements, stop_metadata}
end)
end
else
defp do_reads(module, _names, mappings, read_func, opts) do
{result, _} = read_mappings(module, mappings, read_func, opts)
result
end
end
defp read_mappings(module, mappings, read_func, opts) do
{read_result, stats} =
mappings
|> chunk_mappings(module, :read, opts)
|> read_chunks(module, read_func, opts)
with {:ok, values} <- read_result,
{:ok, mappings} <- hydrate_values(mappings, values),
{:ok, mappings} <- maybe_decode(mappings, opts) do
result = collect_results(mappings, opts)
{result, stats}
else
{:error, _error} = result -> {result, stats}
end
end
defp read_chunks(chunks, module, read_func, opts) do
initial_stats = %{
objects: 0,
batches: 0,
addresses: 0,
gap_addresses: 0,
largest_gap: 0,
total_attempts: 0
}
initial = {{:ok, %{}}, initial_stats}
Enum.reduce_while(chunks, initial, fn {mappings, gap_addresses, largest_gap}, acc ->
{{:ok, values}, stats} = acc
[first | _rest] = mappings
last = List.last(mappings)
names = Enum.map(mappings, & &1.name)
starting_address = first.starting_address
ending_address = last.starting_address + last.address_count - 1
address_count = ending_address - starting_address + 1
object_count = Enum.sum_by(mappings, & &1.address_count)
{result, callback_attempts} =
read_func
|> wrap_read_callback(module, names, gap_addresses, largest_gap, opts)
|> read_batch(first.type, starting_address, address_count)
updated_stats = %{
objects: stats.objects + object_count,
batches: stats.batches + 1,
addresses: stats.addresses + address_count,
gap_addresses: stats.gap_addresses + gap_addresses,
largest_gap: max(stats.largest_gap, largest_gap),
total_attempts: stats.total_attempts + callback_attempts
}
case result do
{:ok, batch_values} -> {:cont, {{:ok, Map.merge(values, batch_values)}, updated_stats}}
{:error, error} -> {:halt, {{:error, error}, updated_stats}}
end
end)
end
if Code.ensure_loaded?(:telemetry) do
defp wrap_read_callback(read_func, module, names, gap_addresses, largest_gap, opts) do
max_attempts = opts.max_attempts
fn type, starting_address, address_count ->
metadata = %{
schema: module,
names: names,
object_type: type,
starting_address: starting_address,
address_count: address_count,
context: opts.context
}
retry(max_attempts, fn attempt ->
start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts})
:telemetry.span([:modboss, :read_callback], start_metadata, fn ->
result = read_func.(type, starting_address, address_count)
stop_measurements = %{gap_addresses_read: gap_addresses, largest_gap: largest_gap}
stop_metadata = Map.put(start_metadata, :result, result)
{result, stop_measurements, stop_metadata}
end)
end)
end
end
else
defp wrap_read_callback(read_func, _, _, _, _, opts) do
fn type, starting_address, address_count ->
retry(opts.max_attempts, fn _attempt ->
read_func.(type, starting_address, address_count)
end)
end
end
end
@spec read_batch(fun(), atom(), non_neg_integer(), pos_integer()) ::
{{:ok, map()} | {:error, any()}, pos_integer()}
defp read_batch(read_func, type, starting_address, address_count) do
{raw_result, attempts} = read_func.(type, starting_address, address_count)
result =
with {:ok, value_or_values} <- raw_result do
values = List.wrap(value_or_values)
value_count = length(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
{result, attempts}
end
defp hydrate_values(mappings, values) do
Enum.map(mappings, fn
%Mapping{address_count: 1} = mapping ->
encoded_value = Map.fetch!(values, mapping.starting_address)
%{mapping | encoded_value: encoded_value}
%Mapping{address_count: _plural} = mapping ->
encoded_values = for addr <- Mapping.address_range(mapping), do: Map.fetch!(values, addr)
%{mapping | encoded_value: encoded_values}
end)
|> then(&{:ok, &1})
end
defp collect_results(mappings, %{debug: true} = opts) do
field_to_return = if opts.decode, do: :value, else: :encoded_value
mappings
|> Enum.map(fn %Mapping{} = mapping ->
debug_map =
mapping
|> Map.take([:type, :as, :encoded_value, :mode, :gap_safe])
|> Map.put(:address_range, Mapping.address_range(mapping))
|> maybe_put_value(mapping, field_to_return)
{mapping.name, debug_map}
end)
|> handle_plurality(opts.plurality)
end
defp collect_results(mappings, %{debug: false} = opts) do
field_to_return = if opts.decode, do: :value, else: :encoded_value
mappings
|> Enum.map(&{&1.name, Map.fetch!(&1, field_to_return)})
|> handle_plurality(opts.plurality)
end
# If we're not decoding, don't include the `:value` field in the debug output
defp maybe_put_value(debug_map, mapping, :value), do: Map.put(debug_map, :value, mapping.value)
defp maybe_put_value(debug_map, _mapping, :encoded_value), do: debug_map
defp handle_plurality([{_name, value}], :singular), do: {:ok, value}
defp handle_plurality(values, :plural), do: {:ok, Enum.into(values, %{})}
@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.
## Opts
* `:context` — a map of arbitrary data that will be included in the
`ModBoss.Encoding.Metadata` struct passed to encode functions (when using
2-arity encoders). Defaults to `%{}`.
## Example
iex> ModBoss.encode(MyDevice.Schema, foo: "Yay")
{:ok, %{{:holding_register, 15} => 22881, {:holding_register, 16} => 30976}}
"""
@spec encode(module(), values(), keyword()) :: {:ok, map()} | {:error, String.t()}
def encode(module, values, opts \\ []) when is_atom(module) do
opts = evaluate_encode_opts(opts)
with {:ok, mappings} <- get_mappings(:any, module, get_keys(values)),
mappings <- put_values(mappings, values),
{:ok, mappings} <- encode_mappings(mappings, opts.context) do
{:ok, flatten_encoded_values(mappings)}
end
end
@default_encode_opts %{context: %{}}
defp evaluate_encode_opts(opts) do
{opts, remaining_opts} = Keyword.split(opts, [:context])
if remaining_opts != [] do
raise "Unrecognized opts: #{inspect(remaining_opts)}"
end
opts
|> Enum.into(@default_encode_opts)
|> validate!(:context)
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/4` 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/4` have already succeeded.
>
> Within `ModBoss.write/4`, if any call to `write_func` returns an error tuple,
> the function will immediately abort, and any subsequent writes will be skipped.
## Opts
* `:max_attempts` — maximum number of times each `write_func` callback will
be attempted before giving up. Defaults to `1` (no retries). Only
`{:error, _}` triggers a retry; exceptions are not retried.
* `:context` — a map of arbitrary data that will be included in the
`ModBoss.Encoding.Metadata` struct passed to encode functions (when using
2-arity encoders) and in telemetry event metadata. Defaults to `%{}`.
Useful for conditionally encoding values based on runtime information
like firmware version or hardware revision, and for identifying which
device or connection a request belongs to in telemetry handlers.
## Example
write_func = fn object_type, starting_address, value_or_values ->
custom_write_logic(…)
:ok
end
iex> ModBoss.write(MyDevice.Schema, [foo: 75, bar: "ABC"], write_func)
:ok
"""
@spec write(module(), values(), write_func(), keyword()) :: :ok | {:error, any()}
def write(module, values, write_func, opts \\ [])
when is_atom(module) and is_function(write_func) do
names = get_keys(values)
opts = evaluate_write_opts(opts)
with {:ok, mappings} <- get_mappings(:writable, module, names),
mappings <- put_values(mappings, values),
{:ok, mappings} <- encode_mappings(mappings, opts.context) do
do_writes(module, names, mappings, write_func, opts)
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)
@default_write_opts %{max_attempts: 1, context: %{}}
defp evaluate_write_opts(opts) do
{opts, remaining_opts} = Keyword.split(opts, [:max_attempts, :context])
if remaining_opts != [] do
raise "Unrecognized opts: #{inspect(remaining_opts)}"
end
opts
|> Enum.into(@default_write_opts)
|> validate!(:max_attempts)
|> validate!(:context)
end
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.__modboss_schema__()
{mappings, unknown_names} =
mapping_names
|> Enum.map(&Map.get(schema, &1, &1))
|> Enum.split_with(&match?(%Mapping{}, &1))
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)
if Code.ensure_loaded?(:telemetry) do
defp do_writes(module, names, mappings, write_func, opts) do
start_metadata = %{schema: module, names: names, context: opts.context}
:telemetry.span([:modboss, :write], start_metadata, fn ->
{result, stats} = write_mappings(module, mappings, write_func, opts)
stop_measurements = %{
objects_requested: stats.objects,
batches: stats.batches,
total_attempts: stats.total_attempts
}
stop_metadata = Map.put(start_metadata, :result, result)
{result, stop_measurements, stop_metadata}
end)
end
else
defp do_writes(module, _names, mappings, write_func, opts) do
{result, _} = write_mappings(module, mappings, write_func, opts)
result
end
end
defp write_mappings(module, mappings, write_func, opts) do
initial_stats = %{objects: 0, batches: 0, total_attempts: 0}
mappings
|> chunk_mappings(module, :write, opts)
|> Enum.reduce_while({:ok, initial_stats}, fn chunk, {:ok, stats} ->
{batched_mappings, _gap_addresses = 0, _largest_gap = 0} = chunk
[first | _rest] = batched_mappings
values = Enum.flat_map(batched_mappings, &List.wrap(&1.encoded_value))
address_count = length(values)
value_or_values =
case values do
[single_value] -> single_value
[_ | _] = multiple_values -> multiple_values
end
names = Enum.map(batched_mappings, & &1.name)
wrapped_write = wrap_write_callback(write_func, module, names, address_count, opts)
{result, attempts} = wrapped_write.(first.type, first.starting_address, value_or_values)
updated_stats = %{
objects: stats.objects + address_count,
batches: stats.batches + 1,
total_attempts: stats.total_attempts + attempts
}
case result do
:ok -> {:cont, {:ok, updated_stats}}
{:error, error} -> {:halt, {{:error, error}, updated_stats}}
end
end)
end
if Code.ensure_loaded?(:telemetry) do
defp wrap_write_callback(write_func, module, names, address_count, opts) do
max_attempts = opts.max_attempts
fn type, starting_address, value_or_values ->
metadata = %{
schema: module,
names: names,
object_type: type,
starting_address: starting_address,
address_count: address_count,
context: opts.context
}
retry(max_attempts, fn attempt ->
start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts})
:telemetry.span([:modboss, :write_callback], start_metadata, fn ->
result = write_func.(type, starting_address, value_or_values)
stop_measurements = %{}
stop_metadata = Map.put(start_metadata, :result, result)
{result, stop_measurements, stop_metadata}
end)
end)
end
end
else
defp wrap_write_callback(write_func, _, _, _, opts) do
fn type, starting_address, value_or_values ->
retry(opts.max_attempts, fn _attempt ->
write_func.(type, starting_address, value_or_values)
end)
end
end
end
defp retry(max_attempts, func) do
Enum.reduce_while(1..max_attempts, nil, fn attempt, _prev ->
case func.(attempt) do
{:error, _} = error when attempt < max_attempts -> {:cont, error}
result -> {:halt, {result, attempt}}
end
end)
end
defp validate!(%{max_attempts: a} = opts, :max_attempts) when is_integer(a) and a >= 1, do: opts
defp validate!(%{context: c} = opts, :context) when is_map(c), do: opts
defp validate!(opts, opt), do: raise("Invalid option #{inspect([{opt, opts[opt]}])}.")
@spec chunk_mappings([Mapping.t()], module(), :read | :write, map()) ::
[{Mapping.object_type(), integer(), [any()]}]
defp chunk_mappings(mappings, module, mode, opts) do
initial_acc = {_mappings = [], _address_count = 0, _gap_address_count = 0, _largest_gap = 0}
gap_safe_addresses =
if mode == :read and Enum.any?(opts.max_gap, fn {_, size} -> size > 0 end) do
gap_safe_addresses(module)
else
MapSet.new()
end
chunk_fun = fn %Mapping{} = mapping, acc ->
max_chunk = module.__max_batch__(mode, mapping.type)
if mapping.address_count > max_chunk do
raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects."
end
case acc do
{_mappings = [], _address_count = 0, _gap_address_count = 0, _largest_gap = 0} ->
{:cont, {[mapping], mapping.address_count, 0, 0}}
{[prior_mapping | _] = mappings, running_count, running_gap_count, largest_gap} ->
gap = Mapping.gap(prior_mapping, mapping)
total_addresses = running_count + gap.size + mapping.address_count
if total_addresses <= max_chunk and
eligible_to_batch?(mode, prior_mapping, mapping, gap, gap_safe_addresses, opts) do
running_gap_count = running_gap_count + gap.size
largest_gap = max(largest_gap, gap.size)
{:cont, {[mapping | mappings], total_addresses, running_gap_count, largest_gap}}
else
chunk_to_emit = {Enum.reverse(mappings), running_gap_count, largest_gap}
new_chunk = {[mapping], mapping.address_count, 0, 0}
{:cont, chunk_to_emit, new_chunk}
end
end
end
after_fun = fn {mappings, _address_count, gap_address_count, largest_gap} ->
{:cont, {Enum.reverse(mappings), gap_address_count, largest_gap}, :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(initial_acc, chunk_fun, after_fun)
end)
end
defp eligible_to_batch?(:write, prior_mapping, current_mapping, _gap, _safe_addresses, _opts) do
is_adjacent(prior_mapping, current_mapping)
end
defp eligible_to_batch?(:read, prior_mapping, current_mapping, gap, gap_safe_addresses, opts) do
is_adjacent(prior_mapping, current_mapping) or
allow_gap?(gap, current_mapping, gap_safe_addresses, opts)
end
defp gap_safe_addresses(module) do
module.__modboss_schema__()
|> Map.values()
|> Enum.filter(& &1.gap_safe)
|> Enum.flat_map(fn mapping ->
mapping |> Mapping.address_range() |> Enum.map(&{mapping.type, &1})
end)
|> MapSet.new()
end
defp allow_gap?(gap, current_mapping, gap_safe_addresses, opts) do
max_gap = Map.fetch!(opts.max_gap, current_mapping.type)
gap.size <= max_gap and MapSet.subset?(gap.addresses, gap_safe_addresses)
end
@default_max_gap 0
defp normalize_max_gap(nil), do: new_max_gaps(@default_max_gap)
defp normalize_max_gap(size) when is_integer(size) and size >= 0, do: new_max_gaps(size)
defp normalize_max_gap(sizes) when is_list(sizes) or is_map(sizes) do
max_gaps =
Enum.into(sizes, new_max_gaps(@default_max_gap), fn
{type, size} when is_integer(size) and size >= 0 -> {type, size}
{type, value} -> raise "Invalid max_gap size #{inspect(value)} for #{inspect(type)}"
end)
{_sizes, unrecognized_type} = Map.split(max_gaps, @object_types)
if Map.keys(unrecognized_type) != [] do
raise "Invalid max_gap values: #{inspect(unrecognized_type)}"
end
max_gaps
end
defp normalize_max_gap(value), do: raise("Invalid max_gap value: #{inspect(value)}")
defp new_max_gaps(size) do
%{
holding_register: size,
input_register: size,
coil: size,
discrete_input: size
}
end
defp encode_mappings(mappings, context) do
Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} ->
case encode_value(mapping, context) do
{:ok, encoded_value} ->
updated_mapping = %{mapping | encoded_value: encoded_value}
{:cont, {:ok, [updated_mapping | acc]}}
{:error, error} when is_binary(error) ->
message = "Failed to encode #{inspect(mapping.name)}. #{error}"
{:halt, {:error, message}}
other ->
"Encoding #{inspect(mapping.name)} should return {:ok, term()} or {:error, string()}"
|> Logger.warning()
{:halt, {:error, "Failed to encode #{inspect(mapping.name)}. #{inspect(other)}"}}
end
end)
end
defp encode_value(%Mapping{} = mapping, context) do
with {module, function, args} <- get_encode_mfa(mapping, context),
{: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, context) do
function = String.to_atom("encode_#{as}")
has_arity_1 = exists?(module, function, 1)
has_arity_2 = exists?(module, function, 2)
cond do
has_arity_1 and has_arity_2 ->
{:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."}
has_arity_2 ->
metadata = ModBoss.Encoding.Metadata.from_mapping(mapping, context)
{module, function, [mapping.value, metadata]}
has_arity_1 ->
{module, function, [mapping.value]}
true ->
{:error,
"Expected #{inspect(module)}.#{function}/1 or #{function}/2 to be defined for ModBoss mapping #{inspect(mapping.name)}."}
end
end
@spec maybe_decode([Mapping.t()], map()) :: {:ok, [Mapping.t()]} | {:error, String.t()}
defp maybe_decode(mappings, %{decode: false}), do: {:ok, mappings}
defp maybe_decode(mappings, %{decode: true} = opts) do
Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} ->
case decode_value(mapping, opts.context) do
{:ok, decoded_value} ->
updated_mapping = %{mapping | value: decoded_value}
{:cont, {:ok, [updated_mapping | acc]}}
{:error, error} when is_binary(error) ->
message = "Failed to decode #{inspect(mapping.name)}. #{error}"
{:halt, {:error, message}}
other ->
"Decoding #{inspect(mapping.name)} should return {:ok, term()} or {:error, string()}"
|> Logger.warning()
{:halt, {:error, "Failed to decode #{inspect(mapping.name)}. #{inspect(other)}"}}
end
end)
end
defp decode_value(%Mapping{} = mapping, context) do
with {module, function, args} <- get_decode_mfa(mapping, context) do
apply(module, function, args)
end
end
defp get_decode_mfa(%Mapping{as: {module, as}} = mapping, context) do
function = String.to_atom("decode_#{as}")
has_arity_1 = exists?(module, function, 1)
has_arity_2 = exists?(module, function, 2)
cond do
has_arity_1 and has_arity_2 ->
{:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."}
has_arity_2 ->
metadata = ModBoss.Encoding.Metadata.from_mapping(mapping, context)
{module, function, [mapping.encoded_value, metadata]}
has_arity_1 ->
{module, function, [mapping.encoded_value]}
true ->
{:error,
"Expected #{inspect(module)}.#{function}/1 or #{function}/2 to be defined for ModBoss mapping #{inspect(mapping.name)}."}
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