diff --git a/lib/modboss.ex b/lib/modboss.ex index 13cb11f..5ea8865 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -254,11 +254,12 @@ defmodule ModBoss do mappings |> chunk_mappings(module, :read) |> Enum.map(fn [first | _rest] = chunk -> - initial_acc = {first.type, first.starting_address, 0} + last = List.last(chunk) + starting_address = first.starting_address + ending_address = last.starting_address + last.address_count - 1 + address_count = ending_address - starting_address + 1 - Enum.reduce(chunk, initial_acc, fn mapping, {type, starting_address, address_count} -> - {type, starting_address, address_count + mapping.address_count} - end) + {first.type, starting_address, address_count} end) |> Enum.reduce_while({:ok, %{}}, fn batch, {:ok, acc} -> case read_batch(read_func, batch) do @@ -315,21 +316,26 @@ defmodule ModBoss do defp chunk_mappings(mappings, module, mode) do chunk_fun = fn %Mapping{type: type, starting_address: address} = mapping, acc -> max_chunk = module.__max_batch__(mode, type) + max_gap = if mode == :read, do: module.__max_gap__(type), else: 0 + + 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 - {[], 0} when mapping.address_count <= max_chunk -> + {[], 0} -> {:cont, {[mapping], mapping.address_count}} - {[prior | _] = mappings, count} - when prior.starting_address + prior.address_count == 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}} + {[prior | _] = mappings, count} -> + gap = address - (prior.starting_address + prior.address_count) + total_with_gap = count + gap + mapping.address_count + fits_in_current_chunk = gap <= max_gap and total_with_gap <= max_chunk - {_, _} when mapping.address_count > max_chunk -> - raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects." + if fits_in_current_chunk do + {:cont, {[mapping | mappings], total_with_gap}} + else + {:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}} + end end end diff --git a/lib/modboss/schema.ex b/lib/modboss/schema.ex index f215f4a..004ad42 100644 --- a/lib/modboss/schema.ex +++ b/lib/modboss/schema.ex @@ -30,6 +30,25 @@ defmodule ModBoss.Schema do All ModBoss mappings are read-only by default. Use `mode: :rw` to allow both reads & writes. Or use `mode: :w` to configure a mapping as write-only. + ## Gap Tolerance + + By default, ModBoss only batches mappings that are perfectly contiguous. You can + configure gap tolerance to allow ModBoss to batch mappings with small gaps between + them, reducing the number of Modbus requests. Unmapped addresses within the gap + will be read but discarded. + + Configure gap tolerance per object type using the `max_gaps` option: + + use ModBoss.Schema, + max_gaps: [ + holding_registers: 10, + input_registers: 10 + ] + + With a gap tolerance of 10, if you have mappings at registers 0-5 and 12-15, + ModBoss will read registers 0-15 in a single request (including the gap of 6 + registers from 6-11), rather than making two separate requests. + ## Automatic encoding/decoding Depending on whether a mapping is flagged as readable/writable, it is expected @@ -98,6 +117,7 @@ defmodule ModBoss.Schema do defmacro __using__(opts) do max_reads = Keyword.get(opts, :max_batch_reads, []) max_writes = Keyword.get(opts, :max_batch_writes, []) + max_gaps = Keyword.get(opts, :max_gaps, []) quote do import unquote(__MODULE__), only: [schema: 1] @@ -105,6 +125,7 @@ defmodule ModBoss.Schema do Module.register_attribute(__MODULE__, :modboss_mappings, accumulate: true) Module.put_attribute(__MODULE__, :max_reads_per_batch, unquote(max_reads)) Module.put_attribute(__MODULE__, :max_writes_per_batch, unquote(max_writes)) + Module.put_attribute(__MODULE__, :max_gaps_per_batch, unquote(max_gaps)) @before_compile unquote(__MODULE__) end @@ -224,6 +245,7 @@ defmodule ModBoss.Schema do defmacro __before_compile__(env) do max_reads = Module.get_attribute(env.module, :max_reads_per_batch) max_writes = Module.get_attribute(env.module, :max_writes_per_batch) + max_gaps = Module.get_attribute(env.module, :max_gaps_per_batch) max_holding_register_reads = max_reads[:holding_registers] || 125 max_input_register_reads = max_reads[:input_registers] || 125 @@ -232,6 +254,11 @@ defmodule ModBoss.Schema do max_holding_register_writes = max_writes[:holding_registers] || 123 max_coil_writes = max_writes[:coils] || 1968 + + max_holding_register_gap = max_gaps[:holding_registers] || 0 + max_input_register_gap = max_gaps[:input_registers] || 0 + max_coil_gap = max_gaps[:coils] || 0 + max_discrete_input_gap = max_gaps[:discrete_inputs] || 0 mappings = Module.get_attribute(env.module, :modboss_mappings) duplicate_names = @@ -287,6 +314,11 @@ defmodule ModBoss.Schema do def __max_batch__(:write, :holding_register), do: unquote(max_holding_register_writes) def __max_batch__(:write, :coil), do: unquote(max_coil_writes) + def __max_gap__(:holding_register), do: unquote(max_holding_register_gap) + def __max_gap__(:input_register), do: unquote(max_input_register_gap) + def __max_gap__(:coil), do: unquote(max_coil_gap) + def __max_gap__(:discrete_input), do: unquote(max_discrete_input_gap) + def __modboss_schema__, do: unquote(mappings) end end diff --git a/test/modboss_test.exs b/test/modboss_test.exs index 71214d2..e9489dd 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -26,6 +26,19 @@ defmodule ModBossTest do end end + defmodule SchemaWithGapTolerance do + use ModBoss.Schema, + max_gaps: [ + holding_registers: 10 + ] + + schema do + holding_register 0..5, :first_group + holding_register 16..23, :second_group + holding_register 35..37, :third_group + end + end + @initial_state %{ reads: 0, writes: 0, @@ -752,6 +765,52 @@ defmodule ModBossTest do end end + describe "gap tolerance" do + test "batches mappings with gaps when max_gap is configured" do + device = start_supervised!({Agent, fn -> @initial_state end}) + + # Set up values for addresses 0-37 (including gaps) + # The gaps (6-15 and 24-34) will be read but discarded + values = Enum.into(0..37, %{}, fn i -> {i, i} end) + + set_objects(device, values) + + # Read all mappings + {:ok, result} = + ModBoss.read(SchemaWithGapTolerance, read_func(device), [ + :first_group, + :second_group, + :third_group + ]) + + # Should make 2 requests: + # 1. Addresses 0-23 (combines first_group and second_group with gap of exactly 10) + # 2. Addresses 35-37 (gap of 11 is too large to combine with previous) + assert 2 = get_read_count(device) + + # Verify correct values were read + assert result[:first_group] == [0, 1, 2, 3, 4, 5] + assert result[:second_group] == [16, 17, 18, 19, 20, 21, 22, 23] + assert result[:third_group] == [35, 36, 37] + end + + test "without gap tolerance, makes separate requests for each non-contiguous mapping" do + device = start_supervised!({Agent, fn -> @initial_state end}) + + values = Enum.into(1..15, %{}, fn i -> {i, i} end) + + set_objects(device, values) + + # Using FakeSchema which has no gap tolerance + # :foo is at address 1, :qux is at addresses 10-12 (gap of 8 registers) + {:ok, _result} = + ModBoss.read(FakeSchema, read_func(device), [:foo, :qux]) + + # Should make 2 separate requests since they're not contiguous + assert 2 = get_read_count(device) + end + end + defp set_objects(device, %{} = values) when is_pid(device) do keys = Map.keys(values)