Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions lib/modboss.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions lib/modboss/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,13 +117,15 @@ 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]

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
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/modboss_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down