diff --git a/lib/modboss.ex b/lib/modboss.ex index 9abbc70..3596d9b 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -13,6 +13,7 @@ defmodule ModBoss do require Logger alias ModBoss.Mapping + import Mapping, only: [is_adjacent: 2] @typep mode :: :readable | :writable | :any @@ -28,7 +29,7 @@ defmodule ModBoss do @type values :: [{atom(), any()}] | %{atom() => any()} - @object_types [:holding_registers, :input_registers, :coils, :discrete_inputs] + @object_types [:holding_register, :input_register, :coil, :discrete_input] @doc """ Read from modbus using named mappings. @@ -128,7 +129,7 @@ defmodule ModBoss do {:ok, %{foo: 75, bar: "ABC"}} # …or allow reading across different gap sizes per type - ModBoss.read(SchemaModule, [:foo, :bar], read_func, max_gap: %{holding_registers: 10}) + 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`) @@ -141,10 +142,18 @@ defmodule ModBoss do {names, opts} = evaluate_read_opts(module, name_or_names, opts) with {:ok, mappings} <- get_mappings(:readable, module, names) do - do_reads(module, mappings, read_func, opts, names) + do_reads(module, names, mappings, read_func, opts) end end + @default_read_opts %{ + max_gap: 0, + max_attempts: 1, + decode: true, + debug: false, + telemetry_label: nil + } + defp evaluate_read_opts(module, name_or_names, opts) do {names, plurality} = case name_or_names do @@ -153,15 +162,21 @@ defmodule ModBoss do names when is_list(names) -> {names, :plural} end - opts = - case Keyword.split(opts, [:max_gap, :max_attempts, :debug, :decode, :telemetry_label]) do - {opts, []} -> Keyword.put(opts, :plurality, plurality) - {_opts, unsupported_opts} -> raise "Unrecognized opts: #{inspect(unsupported_opts)}" - end + {supported_opts, unsupported_opts} = + Keyword.split(opts, [:max_gap, :max_attempts, :debug, :decode, :telemetry_label]) + + if unsupported_opts != [] do + raise "Unrecognized opts: #{inspect(unsupported_opts)}" + end - validate_max_attempts!(Keyword.get(opts, :max_attempts, 1)) + 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) - {names, opts} + {names, validated_opts} end defp readable_mappings(module) do @@ -171,12 +186,11 @@ defmodule ModBoss do end if Code.ensure_loaded?(:telemetry) do - defp do_reads(module, mappings, read_func, opts, names) do - label = Keyword.get(opts, :telemetry_label) - start_metadata = %{schema: module, names: names} |> maybe_put_label(label) + defp do_reads(module, names, mappings, read_func, opts) do + start_metadata = %{schema: module, names: names} |> maybe_put_label(opts.telemetry_label) :telemetry.span([:modboss, :read], start_metadata, fn -> - {result, stats} = read_mappings(module, mappings, read_func, opts, label) + {result, stats} = read_mappings(module, mappings, read_func, opts) stop_measurements = %{ objects_requested: stats.objects, @@ -195,36 +209,29 @@ defmodule ModBoss do defp maybe_put_label(metadata, nil), do: metadata defp maybe_put_label(metadata, label), do: Map.put(metadata, :label, label) else - defp do_reads(module, mappings, read_func, opts, _names) do - {result, _} = read_mappings(module, mappings, read_func, opts, _label = nil) + 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, label) do - max_gaps = Keyword.get(opts, :max_gap, %{}) - max_attempts = Keyword.get(opts, :max_attempts, 1) - debug = Keyword.get(opts, :debug, false) - decode = Keyword.get(opts, :decode, true) - plurality = Keyword.fetch!(opts, :plurality) - field_to_return = if decode, do: :value, else: :encoded_value - + defp read_mappings(module, mappings, read_func, opts) do {read_result, stats} = mappings - |> chunk_mappings(module, :read, max_gaps) - |> read_chunks(module, read_func, max_attempts, label) + |> 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, decode) do - result = collect_results(mappings, plurality, field_to_return, debug) + {: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, max_attempts, label) do + defp read_chunks(chunks, module, read_func, opts) do initial_stats = %{ objects: 0, requests: 0, @@ -249,7 +256,7 @@ defmodule ModBoss do {result, callback_attempts} = read_func - |> wrap_read_callback(module, names, gap_addresses, largest_gap, max_attempts, label) + |> wrap_read_callback(module, names, gap_addresses, largest_gap, opts) |> read_batch(first.type, starting_address, address_count) updated_stats = %{ @@ -269,15 +276,9 @@ defmodule ModBoss do end if Code.ensure_loaded?(:telemetry) do - defp wrap_read_callback( - read_func, - module, - names, - gap_addresses, - largest_gap, - max_attempts, - label - ) 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 = %{ @@ -287,7 +288,7 @@ defmodule ModBoss do starting_address: starting_address, address_count: address_count } - |> maybe_put_label(label) + |> maybe_put_label(opts.telemetry_label) retry(max_attempts, fn attempt -> start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts}) @@ -303,9 +304,9 @@ defmodule ModBoss do end end else - defp wrap_read_callback(read_func, _, _, _, _, max_attempts, _) do + defp wrap_read_callback(read_func, _, _, _, _, opts) do fn type, starting_address, address_count -> - retry(max_attempts, fn _attempt -> + retry(opts.max_attempts, fn _attempt -> read_func.(type, starting_address, address_count) end) end @@ -344,20 +345,16 @@ defmodule ModBoss do %{mapping | encoded_value: encoded_value} %Mapping{address_count: _plural} = mapping -> - addresses = Mapping.address_range(mapping) |> Enum.to_list() - - encoded_values = - values - |> Map.take(addresses) - |> Enum.sort_by(fn {address, _value} -> address end) - |> Enum.map(fn {_address, value} -> value end) + 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, plurality, field_to_return, _debug = true) do + 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 = @@ -368,13 +365,15 @@ defmodule ModBoss do {mapping.name, debug_map} end) - |> handle_plurality(plurality) + |> handle_plurality(opts.plurality) end - defp collect_results(mappings, plurality, field_to_return, _debug = false) do + 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(plurality) + |> handle_plurality(opts.plurality) end # If we're not decoding, don't include the `:value` field in the debug output @@ -477,24 +476,25 @@ defmodule ModBoss do with {:ok, mappings} <- get_mappings(:writable, module, names), mappings <- put_values(mappings, values), {:ok, mappings} <- encode(mappings) do - do_writes(module, mappings, write_func, names, opts) + 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, telemetry_label: nil} + defp evaluate_write_opts(opts) do - {label, remaining_opts} = Keyword.pop(opts, :telemetry_label) - {max_attempts, remaining_opts} = Keyword.pop(remaining_opts, :max_attempts, 1) + {opts, remaining_opts} = Keyword.split(opts, [:max_attempts, :telemetry_label]) if remaining_opts != [] do raise "Unrecognized opts: #{inspect(remaining_opts)}" end - validate_max_attempts!(max_attempts) - - [telemetry_label: label, max_attempts: max_attempts] + opts + |> Enum.into(@default_write_opts) + |> validate!(:max_attempts) end defp put_values(mappings, params) do @@ -534,13 +534,11 @@ defmodule ModBoss do defp unwritable(mappings), do: Enum.reject(mappings, &Mapping.writable?/1) if Code.ensure_loaded?(:telemetry) do - defp do_writes(module, mappings, write_func, names, opts) do - max_attempts = Keyword.get(opts, :max_attempts, 1) - label = Keyword.get(opts, :telemetry_label) - start_metadata = %{schema: module, names: names} |> maybe_put_label(label) + defp do_writes(module, names, mappings, write_func, opts) do + start_metadata = %{schema: module, names: names} |> maybe_put_label(opts.telemetry_label) :telemetry.span([:modboss, :write], start_metadata, fn -> - {result, stats} = write_mappings(module, mappings, write_func, max_attempts, label) + {result, stats} = write_mappings(module, mappings, write_func, opts) stop_measurements = %{ objects_requested: stats.objects, @@ -553,18 +551,17 @@ defmodule ModBoss do end) end else - defp do_writes(module, mappings, write_func, _names, opts) do - max_attempts = Keyword.get(opts, :max_attempts, 1) - {result, _} = write_mappings(module, mappings, write_func, max_attempts, _label = nil) + 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, max_attempts, label) do + defp write_mappings(module, mappings, write_func, opts) do initial_stats = %{objects: 0, requests: 0, total_attempts: 0} mappings - |> chunk_mappings(module, :write, 0) + |> 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 @@ -578,10 +575,7 @@ defmodule ModBoss do end names = Enum.map(batched_mappings, & &1.name) - - wrapped_write = - wrap_write_callback(write_func, module, names, address_count, max_attempts, label) - + 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 = %{ @@ -598,7 +592,9 @@ defmodule ModBoss do end if Code.ensure_loaded?(:telemetry) do - defp wrap_write_callback(write_func, module, names, address_count, max_attempts, label) 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 = %{ @@ -608,7 +604,7 @@ defmodule ModBoss do starting_address: starting_address, address_count: address_count } - |> maybe_put_label(label) + |> maybe_put_label(opts.telemetry_label) retry(max_attempts, fn attempt -> start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts}) @@ -623,9 +619,9 @@ defmodule ModBoss do end end else - defp wrap_write_callback(write_func, _, _, _, max_attempts, _) do + defp wrap_write_callback(write_func, _, _, _, opts) do fn type, starting_address, value_or_values -> - retry(max_attempts, fn _attempt -> + retry(opts.max_attempts, fn _attempt -> write_func.(type, starting_address, value_or_values) end) end @@ -648,33 +644,21 @@ defmodule ModBoss do end) end - defp validate_max_attempts!(max_attempts) do - unless is_integer(max_attempts) and max_attempts >= 1 do - raise "Invalid max_attempts: #{inspect(max_attempts)}. Must be a positive integer." - end - end + defp validate!(%{max_attempts: a} = opts, :max_attempts) when is_integer(a) and a >= 1, do: opts + defp validate!(opts, opt), do: raise("Invalid option #{inspect([{opt, opts[opt]}])}.") - @spec chunk_mappings([Mapping.t()], module(), :read | :write, integer()) :: + @spec chunk_mappings([Mapping.t()], module(), :read | :write, map()) :: [{Mapping.object_type(), integer(), [any()]}] - defp chunk_mappings(mappings, module, mode, max_gaps) do - max_gaps = normalize_max_gap(max_gaps) + defp chunk_mappings(mappings, module, mode, opts) do + initial_acc = {_mappings = [], _address_count = 0, _gap_address_count = 0, _largest_gap = 0} - # Build a set of {type, address} pairs for all gap-safe objects. - # During reads, gap tolerance must only bridge gaps where every address - # in the gap belongs to a known gap-safe mapping. gap_safe_addresses = - if mode == :read 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() + if mode == :read and Enum.any?(opts.max_gap, fn {_, size} -> size > 0 end) do + gap_safe_addresses(module) + else + MapSet.new() end - initial_acc = {_mappings = [], _address_count = 0, _gap_address_count = 0, _largest_gap = 0} - chunk_fun = fn %Mapping{} = mapping, acc -> max_chunk = module.__max_batch__(mode, mapping.type) @@ -687,11 +671,11 @@ defmodule ModBoss do {:cont, {[mapping], mapping.address_count, 0, 0}} {[prior_mapping | _] = mappings, running_count, running_gap_count, largest_gap} -> - gap = compute_gap(prior_mapping, mapping) - max_gap = if mode == :write, do: 0, else: Map.fetch!(max_gaps, mapping.type) + gap = Mapping.gap(prior_mapping, mapping) total_addresses = running_count + gap.size + mapping.address_count - if total_addresses <= max_chunk and allow_gap?(gap, mode, max_gap, gap_safe_addresses) do + 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) @@ -717,55 +701,60 @@ defmodule ModBoss do end) end - defp compute_gap(%{type: type} = prior, %{type: type} = current) do - gap_start = prior.starting_address + prior.address_count - gap_end = current.starting_address - 1 - - %{ - size: current.starting_address - gap_start, - addresses: MapSet.new(gap_start..gap_end//1, &{current.type, &1}) - } + defp eligible_to_batch?(:write, prior_mapping, current_mapping, _gap, _safe_addresses, _opts) do + is_adjacent(prior_mapping, current_mapping) end - defp allow_gap?(gap, mode, max_gap, gap_safe_addresses) when mode in [:read, :write] do - cond do - gap.size == 0 -> true - mode == :write -> false - mode == :read -> gap.size <= max_gap and MapSet.subset?(gap.addresses, gap_safe_addresses) - 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 normalize_max_gap(size) when is_integer(size) do - normalize_max_gap(%{}, size) + 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 normalize_max_gap(sizes) when is_list(sizes) do - sizes - |> Enum.into(%{}) - |> normalize_max_gap() + 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 - defp normalize_max_gap(sizes, default_size \\ 0) when is_map(sizes) do - {sizes, others} = Map.split(sizes, @object_types) + @default_max_gap 0 - Enum.each(others, fn {key, _value} -> - Logger.warning("Invalid #{inspect(key)} gap size specified") - end) + 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: Map.get(sizes, :holding_registers, default_size), - input_register: Map.get(sizes, :input_registers, default_size), - coil: Map.get(sizes, :coils, default_size), - discrete_input: Map.get(sizes, :discrete_inputs, default_size) + holding_register: size, + input_register: size, + coil: size, + discrete_input: size } - |> Enum.into(%{}, fn - {type, size} when is_integer(size) and size >= 0 -> - {type, size} - - {type, value} -> - Logger.warning("Invalid max gap size #{inspect(value)} for #{inspect(type)}") - {type, default_size} - end) end defp encode(mappings) do @@ -775,10 +764,15 @@ defmodule ModBoss do updated_mapping = %{mapping | encoded_value: encoded_value} {:cont, {:ok, [updated_mapping | acc]}} - {:error, error} -> - # error = if is_binary(error), do: error, else: inspect(error) + {: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 @@ -792,7 +786,7 @@ defmodule ModBoss do end defp get_encode_mfa(%Mapping{as: {module, as}} = mapping) do - function = String.to_atom("encode_" <> "#{as}") + function = String.to_atom("encode_#{as}") if exists?(module, function, 2) do metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) @@ -803,19 +797,25 @@ defmodule ModBoss do end end - @spec maybe_decode([Mapping.t()], boolean()) :: {:ok, [Mapping.t()]} | {:error, String.t()} - defp maybe_decode(mappings, false), do: {:ok, mappings} + @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, true) do + defp maybe_decode(mappings, %{decode: true}) 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]}} - {:error, error} -> + {: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 @@ -827,7 +827,7 @@ defmodule ModBoss do end defp get_decode_mfa(%Mapping{as: {module, as}} = mapping) do - function = String.to_atom("decode_" <> "#{as}") + function = String.to_atom("decode_#{as}") if exists?(module, function, 1) do {module, function, [mapping.encoded_value]} diff --git a/lib/modboss/mapping.ex b/lib/modboss/mapping.ex index 77660c8..2c43d68 100644 --- a/lib/modboss/mapping.ex +++ b/lib/modboss/mapping.ex @@ -125,6 +125,34 @@ defmodule ModBoss.Mapping do raise "Invalid ModBoss option #{inspect([{field, value}])} for #{inspect(mapping.name)}." end + @doc """ + Guard that checks whether two mappings are of the same type and occupy adjacent addresses. + """ + defguard is_adjacent(a, b) + when is_struct(a, __MODULE__) and is_struct(b, __MODULE__) and + a.type == b.type and + a.starting_address + a.address_count == b.starting_address + + @doc """ + Returns a map describing the gap between two mappings of the same type. + + Returns `%{size: integer(), addresses: MapSet.t()}` where `addresses` contains + `{type, address}` pairs for each address in the gap. + """ + def gap(%__MODULE__{} = a, %__MODULE__{} = b) when is_adjacent(a, b) do + %{size: 0, addresses: MapSet.new()} + end + + def gap(%__MODULE__{type: type} = a, %__MODULE__{type: type} = b) + when a.starting_address < b.starting_address do + gap_start = a.starting_address + a.address_count + + %{ + size: b.starting_address - gap_start, + addresses: MapSet.new(gap_start..(b.starting_address - 1)//1, &{type, &1}) + } + end + @read_modes [:r, :rw] @doc false def readable?(%__MODULE__{} = mapping), do: mapping.mode in @read_modes diff --git a/test/modboss/mapping_test.exs b/test/modboss/mapping_test.exs index 0654475..0a1a60e 100644 --- a/test/modboss/mapping_test.exs +++ b/test/modboss/mapping_test.exs @@ -1,6 +1,7 @@ defmodule ModBoss.MappingTest do use ExUnit.Case, async: true alias ModBoss.Mapping + require Mapping describe "new/4" do test "creates a valid mapping with default options" do @@ -91,6 +92,78 @@ defmodule ModBoss.MappingTest do assert 27..31 = Mapping.address_range(mapping) end + describe "is_adjacent/2" do + test "true when same type and addresses are contiguous" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :holding_register, 2) + assert Mapping.is_adjacent(a, b) + end + + test "true when mappings of the same type span multiple contiguous addresses" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1..3) + b = Mapping.new(__MODULE__, :b, :holding_register, 4..5) + assert Mapping.is_adjacent(a, b) + end + + test "false when there is a gap between addresses" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :holding_register, 3) + refute Mapping.is_adjacent(a, b) + end + + test "false when types differ" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :input_register, 2) + refute Mapping.is_adjacent(a, b) + end + + test "false when order is reversed" do + a = Mapping.new(__MODULE__, :a, :holding_register, 2) + b = Mapping.new(__MODULE__, :b, :holding_register, 1) + refute Mapping.is_adjacent(a, b) + end + end + + describe "gap/2" do + test "returns size 0 and empty addresses for adjacent mappings" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :holding_register, 2) + assert %{size: 0, addresses: addresses} = Mapping.gap(a, b) + assert MapSet.size(addresses) == 0 + end + + test "returns the correct size and addresses for a gap" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :holding_register, 5) + assert %{size: 3, addresses: addresses} = Mapping.gap(a, b) + + assert addresses == + MapSet.new([ + {:holding_register, 2}, + {:holding_register, 3}, + {:holding_register, 4} + ]) + end + + test "raises when types differ" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1) + b = Mapping.new(__MODULE__, :b, :input_register, 2) + + assert_raise FunctionClauseError, fn -> + Mapping.gap(a, b) + end + end + + test "works with multi-address mappings" do + a = Mapping.new(__MODULE__, :a, :holding_register, 1..3) + b = Mapping.new(__MODULE__, :b, :holding_register, 6) + assert %{size: 2, addresses: addresses} = Mapping.gap(a, b) + + assert addresses == + MapSet.new([{:holding_register, 4}, {:holding_register, 5}]) + end + end + describe "readable?/1" do test "returns true for readable mappings" do for mode <- [:r, :rw] do diff --git a/test/modboss_test.exs b/test/modboss_test.exs index ad781f3..bc11d38 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -1,6 +1,5 @@ defmodule ModBossTest do use ExUnit.Case, async: true - import ExUnit.CaptureLog import ModBoss.CallbackHelpers defmodule FakeSchema do @@ -604,7 +603,7 @@ defmodule ModBossTest do {:ok, result} = ModBoss.read(schema, [:first_group, :second_group, :third_group], read_func(device), - max_gap: [holding_registers: 10] + max_gap: [holding_register: 10] ) assert 2 = get_read_count(device) @@ -641,7 +640,7 @@ defmodule ModBossTest do {:ok, result} = ModBoss.read(schema, [:first_group, :second_group, :third_group], read_func(device), - max_gap: %{holding_registers: 10} + max_gap: %{holding_register: 10} ) assert 2 = get_read_count(device) @@ -744,48 +743,7 @@ defmodule ModBossTest do assert 8 = get_read_count(device) end - test "logs a warning (but doesn't fail) if unsupported keys are used with `max_gap`" do - schema = unique_module() - - Code.compile_string(""" - defmodule #{schema} do - use ModBoss.Schema - - schema do - holding_register 0..5, :first_group - holding_register 6..15, :filler_a - holding_register 16..23, :second_group - holding_register 24..34, :filler_b - holding_register 35..37, :third_group - end - end - """) - - device = start_supervised!({Agent, fn -> @initial_state end}) - values = Enum.into(0..37, %{}, fn i -> {{:holding_register, i}, i} end) - - set_objects(device, values) - - # max_gap as a map - assert capture_log(fn -> - ModBoss.read(schema, [:first_group, :second_group], read_func(device), - max_gap: %{foo: 1} - ) - end) =~ "Invalid :foo gap size specified" - - assert 2 = get_read_count(device) - - # max_gap as a keyword list - assert capture_log(fn -> - ModBoss.read(schema, [:first_group, :second_group], read_func(device), - max_gap: [bar: 10] - ) - end) =~ "Invalid :bar gap size specified" - - assert 2 = get_read_count(device) - end - - test "logs a warning (but doesn't fail) when values for `:max_gap` are not positive integers" do + test "raises when values for `:max_gap` are not positive integers" do schema = unique_module() Code.compile_string(""" @@ -805,45 +763,30 @@ defmodule ModBossTest do set_objects(device, values) + invalid_gaps = [ + holding_register: "nope", + input_register: 3.14159, + coil: -1, + discrete_input: nil + ] + # max_gap as a map - {_result, log} = - with_log(fn -> + for {key, value} <- invalid_gaps do + assert_raise RuntimeError, ~r"Invalid max_gap size", fn -> ModBoss.read(schema, [:first_group, :second_group], read_func(device), - max_gap: %{ - holding_registers: "nope", - input_registers: 3.14159, - coils: -1, - discrete_inputs: nil - } + max_gap: %{key => value} ) - end) - - assert log =~ ~S[Invalid max gap size "nope"] - assert log =~ ~S[Invalid max gap size 3.14159] - assert log =~ ~S[Invalid max gap size -1] - assert log =~ ~S[Invalid max gap size nil] - - assert 2 = get_read_count(device) + end + end # max_gap as a keyword list - {_result, log} = - with_log(fn -> + for {key, value} <- invalid_gaps do + assert_raise RuntimeError, ~r"Invalid max_gap size", fn -> ModBoss.read(schema, [:first_group, :second_group], read_func(device), - max_gap: [ - holding_registers: {:yikes, 1}, - input_registers: %{}, - coils: :blurgh, - discrete_inputs: [:thing] - ] + max_gap: [{key, value}] ) - end) - - assert log =~ ~S[Invalid max gap size {:yikes, 1}] - assert log =~ ~S[Invalid max gap size %{}] - assert log =~ ~S[Invalid max gap size :blurgh] - assert log =~ ~S{Invalid max gap size [:thing]} - - assert 2 = get_read_count(device) + end + end end test "`gap_safe: false` prevents a mapping's addresses from being included in a gap read" do @@ -926,6 +869,35 @@ defmodule ModBossTest do assert %{group_1: [0, 1], group_2: [4, 5]} = result end + test "gap that would exceed max_batch_reads forces a new chunk" do + schema = unique_module() + + # max_batch_reads: 6 means we can read at most 6 addresses in one request. + # group_1 (2 addrs) + gap (3 addrs) + group_2 (2 addrs) = 7, which exceeds 6. + # So they must be split into two reads, even though the gap is allowed. + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema, max_batch_reads: [holding_registers: 6] + + schema do + holding_register 0..1, :group_1 + holding_register 2..4, :filler + holding_register 5..6, :group_2 + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + values = Enum.into(0..6, %{}, fn i -> {{:holding_register, i}, i} end) + set_objects(device, values) + + {:ok, result} = + ModBoss.read(schema, [:group_1, :group_2], read_func(device), max_gap: 5) + + assert 2 = get_read_count(device) + assert %{group_1: [0, 1], group_2: [5, 6]} = result + end + test "`gap_safe: false` mappings can still be read when explicitly requested" do schema = unique_module()