From 80fcc6b4d76dae25716143456f058187c1e8d104 Mon Sep 17 00:00:00 2001 From: Jacson Junior Date: Fri, 21 Nov 2025 09:22:40 -0300 Subject: [PATCH 1/3] Add gap tolerance to batching --- lib/modboss.ex | 31 +++++++++++++++-------- lib/modboss/schema.ex | 32 +++++++++++++++++++++++ test/modboss_test.exs | 59 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/lib/modboss.ex b/lib/modboss.ex index 13cb11f..6157089 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,20 +316,28 @@ 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 case acc do {[], 0} when mapping.address_count <= max_chunk -> {: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}} + {[prior | _] = mappings, count} -> + gap = address - (prior.starting_address + prior.address_count) + total_with_gap = count + gap + mapping.address_count + + cond do + gap >= 0 and gap <= max_gap and total_with_gap <= max_chunk -> + {:cont, {[mapping | mappings], total_with_gap}} + + mapping.address_count <= max_chunk -> + {:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}} - {mappings, _count} when mapping.address_count <= max_chunk -> - {:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}} + true -> + raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects." + end - {_, _} when mapping.address_count > max_chunk -> + {_mappings, _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 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..bffcd3c 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 12..23, :second_group + holding_register 36..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-11 and 24-35) 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 6) + # 2. Addresses 36-37 (gap of 12 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] == [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] + assert result[:third_group] == [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) From 9666bb12a7edba5ed03e6b6cb27e61084b8bf733 Mon Sep 17 00:00:00 2001 From: Jacson Junior Date: Sat, 29 Nov 2025 15:20:48 -0300 Subject: [PATCH 2/3] Adjust holding_register ranges and expected values --- test/modboss_test.exs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/modboss_test.exs b/test/modboss_test.exs index bffcd3c..e9489dd 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -34,8 +34,8 @@ defmodule ModBossTest do schema do holding_register 0..5, :first_group - holding_register 12..23, :second_group - holding_register 36..37, :third_group + holding_register 16..23, :second_group + holding_register 35..37, :third_group end end @@ -770,7 +770,7 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) # Set up values for addresses 0-37 (including gaps) - # The gaps (6-11 and 24-35) will be read but discarded + # 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) @@ -784,14 +784,14 @@ defmodule ModBossTest do ]) # Should make 2 requests: - # 1. Addresses 0-23 (combines first_group and second_group with gap of 6) - # 2. Addresses 36-37 (gap of 12 is too large to combine with previous) + # 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] == [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] - assert result[:third_group] == [36, 37] + 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 From fbe763f3438ec8681370779cad24229604d8fcd5 Mon Sep 17 00:00:00 2001 From: Jacson Junior Date: Sat, 29 Nov 2025 15:46:17 -0300 Subject: [PATCH 3/3] Precheck oversized mapping and simplify grouping --- lib/modboss.ex | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/modboss.ex b/lib/modboss.ex index 6157089..5ea8865 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -318,27 +318,24 @@ defmodule ModBoss do 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} -> 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 - cond do - gap >= 0 and gap <= max_gap and total_with_gap <= max_chunk -> - {:cont, {[mapping | mappings], total_with_gap}} - - mapping.address_count <= max_chunk -> - {:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}} - - true -> - 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 - - {_mappings, _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