From a4a4243b7a98f46e49bc20f811938b828d55faa1 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Sat, 21 Jun 2025 12:02:14 -0700 Subject: [PATCH 1/8] Update mix.exs with source info --- mix.exs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 3f8af57..78ac38c 100644 --- a/mix.exs +++ b/mix.exs @@ -1,10 +1,13 @@ defmodule ModBoss.MixProject do use Mix.Project + @source_url "https://github.com/goodpixel/modboss" + @version "0.1.0" + def project do [ app: :modboss, - version: "0.1.0", + version: @version, elixir: "~> 1.16", dialyzer: [ plt_add_apps: [:mix, :ex_unit], @@ -45,7 +48,9 @@ defmodule ModBoss.MixProject do main: "readme", logo: "assets/boss-t.png", extras: ["README.md": [title: "Overview"]], - assets: %{"assets" => "assets"} + assets: %{"assets" => "assets"}, + source_url: @source_url, + source_ref: "v#{@version}" ] end From 5bec395331cb70269143d07dd74562911b087ef5 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 16:19:30 -0700 Subject: [PATCH 2/8] Add CHANGELOG --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac6f756 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [v0.1.1] + +### Added + +- Add `ModBoss.encode/2` for encoding mappings to objects without actually writing via Modbus. + +### Fixed + +- Allow address reuse across object types per the Modbus spec. + +## [v0.1.0] + +### Initial Release From e91abdb24c89d0c681345f91351e64bb3d5c13b6 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 16:09:30 -0700 Subject: [PATCH 3/8] =?UTF-8?q?Don=E2=80=99t=20allow=20the=20name=20`:all`?= =?UTF-8?q?=20for=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’re reserving this as a special keyword that indicates we should retrieve all readable objects when reading values. --- CHANGELOG.md | 5 +++++ lib/modboss/schema.ex | 20 ++++++++++++++++---- test/modboss/schema_test.exs | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6f756..4064f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `ModBoss.encode/2` for encoding mappings to objects without actually writing via Modbus. +### Changed + +- Don't allow `:all` as an object name in a schema; reserve it as a special keyword for requesting + all readable registers. + ### Fixed - Allow address reuse across object types per the Modbus spec. diff --git a/lib/modboss/schema.ex b/lib/modboss/schema.ex index 29b3fed..b0a3d07 100644 --- a/lib/modboss/schema.ex +++ b/lib/modboss/schema.ex @@ -136,6 +136,7 @@ defmodule ModBoss.Schema do See explanation of [automatic encoding/decoding](ModBoss.Schema.html#module-automatic-encoding-decoding). """ defmacro holding_register(addresses, name, opts \\ []) do + ModBoss.Schema.validate_name!(__CALLER__, name) module = __CALLER__.module quote bind_quoted: binding() do @@ -151,6 +152,7 @@ defmodule ModBoss.Schema do See explanation of [automatic encoding/decoding](ModBoss.Schema.html#module-automatic-encoding-decoding). """ defmacro input_register(addresses, name, opts \\ []) do + ModBoss.Schema.validate_name!(__CALLER__, name) module = __CALLER__.module quote bind_quoted: binding() do @@ -167,6 +169,7 @@ defmodule ModBoss.Schema do See explanation of [automatic encoding/decoding](ModBoss.Schema.html#module-automatic-encoding-decoding). """ defmacro coil(addresses, name, opts \\ []) do + ModBoss.Schema.validate_name!(__CALLER__, name) module = __CALLER__.module quote bind_quoted: binding() do @@ -182,6 +185,7 @@ defmodule ModBoss.Schema do See explanation of [automatic encoding/decoding](ModBoss.Schema.html#module-automatic-encoding-decoding). """ defmacro discrete_input(addresses, name, opts \\ []) do + ModBoss.Schema.validate_name!(__CALLER__, name) module = __CALLER__.module quote bind_quoted: binding() do @@ -189,7 +193,15 @@ defmodule ModBoss.Schema do end end - @doc false + def validate_name!(%Macro.Env{file: file, line: line}, :all) do + raise CompileError, + file: file, + line: line, + description: "The name `:all` is reserved by ModBoss and cannot be used for a mapping." + end + + def validate_name!(_env, _name), do: :ok + def create_register_mapping(module, register_type, address_or_range, name, opts) do if not Module.has_attribute?(module, :register_mappings) do raise """ @@ -220,14 +232,14 @@ defmodule ModBoss.Schema do mappings |> Enum.frequencies_by(& &1.name) |> Enum.filter(fn {_mapping, count} -> count > 1 end) - |> Enum.map(fn {name, _count} -> name end) + |> Enum.map(fn {name, _count} -> inspect(name) end) if Enum.any?(duplicate_names) do raise CompileError, file: env.file, line: env.line, description: - "The following names were used to identify more than one register in #{env.module}: #{Enum.join(duplicate_names, ", ")}." + "The following names were used to identify more than one register: [#{Enum.join(duplicate_names, ", ")}]." end duplicate_addresses = @@ -247,7 +259,7 @@ defmodule ModBoss.Schema do file: env.file, line: env.line, description: """ - Each address can only be registered once per object type, but the following were mapped more than once in #{env.module}: + Each address can only be registered once per object type, but the following were mapped more than once: #{Enum.map_join(duplicate_addresses, "\n", fn dup -> " * #{inspect(dup)}" end)} """ diff --git a/test/modboss/schema_test.exs b/test/modboss/schema_test.exs index 75f3aad..d35c69f 100644 --- a/test/modboss/schema_test.exs +++ b/test/modboss/schema_test.exs @@ -85,6 +85,22 @@ defmodule ModBoss.SchemaTest do """) end end + + test "raises an exception if any mapping uses the reserved name `:all`" do + Enum.each([:holding_register, :input_register, :coil, :discrete_input], fn object_type -> + assert_raise CompileError, ~r/reserved by ModBoss/, fn -> + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + modbus_schema do + #{object_type} 1, :all + end + end + """) + end + end) + end end describe "holding_register/3" do From 10c94be12e98e490a141a0314bd48d7cf64e8480 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 16:09:47 -0700 Subject: [PATCH 4/8] Add examples for encoding booleans --- lib/modboss/encoding.ex | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/modboss/encoding.ex b/lib/modboss/encoding.ex index 0ead505..7ac8f22 100644 --- a/lib/modboss/encoding.ex +++ b/lib/modboss/encoding.ex @@ -29,6 +29,14 @@ defmodule ModBoss.Encoding do @doc """ Encode `true` as `1` and `false` as `0` + + ## Examples + + iex> encode_boolean(true, %{}) + {:ok, 1} + + iex> encode_boolean(false, %{}) + {:ok, 0} """ @spec encode_boolean(boolean(), Mapping.t()) :: {:ok, integer()} | {:error, binary()} def encode_boolean(true, _mapping), do: {:ok, 1} @@ -36,6 +44,14 @@ defmodule ModBoss.Encoding do @doc """ Interpret `1` as `true` and `0` as `false` + + ## Examples + + iex> decode_boolean(0) + {:ok, false} + + iex> decode_boolean(1) + {:ok, true} """ @spec decode_boolean(integer()) :: {:ok, boolean()} | {:error, binary()} def decode_boolean(1), do: {:ok, true} From 2299bf2cfacee35bc27e15e2f31c345b39d47931 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 16:17:01 -0700 Subject: [PATCH 5/8] Remove undocumented function --- CHANGELOG.md | 5 +++++ lib/modboss.ex | 10 ---------- test/modboss_test.exs | 39 +-------------------------------------- 3 files changed, 6 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4064f6c..0f0308e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Don't allow `:all` as an object name in a schema; reserve it as a special keyword for requesting all readable registers. +### Removed + +- Remove undocumented `ModBoss.read_all/2`. In practice, it seems simpler to use a reserved `:all` + keyword to read all objects configured as readable. + ### Fixed - Allow address reuse across object types per the Modbus spec. diff --git a/lib/modboss.ex b/lib/modboss.ex index 927fc61..4f80148 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -15,16 +15,6 @@ defmodule ModBoss do :ok | {:error, any()}) @type values :: [{atom(), any()}] | %{atom() => any()} - @doc false - def read_all(module, read_func, opts \\ []) do - readable_mappings = - module.__modbus_schema__() - |> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end) - |> Enum.map(fn {name, _mapping} -> name end) - - read(module, read_func, readable_mappings, opts) - end - @doc """ Read from modbus using named mappings. diff --git a/test/modboss_test.exs b/test/modboss_test.exs index c870cac..1427e5b 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -366,7 +366,7 @@ defmodule ModBossTest do ModBoss.read(schema, read_func(device), [:yep, :nope, :text], decode: false) end - test "fetches all readable registers if told to read the magic mapping `:all`" do + test "fetches all readable registers if told to read `:all`" do schema = unique_module() Code.compile_string(""" @@ -403,43 +403,6 @@ defmodule ModBossTest do end end - describe "ModBoss.read_all/2" do - test "fetches all readable registers" do - schema = unique_module() - - Code.compile_string(""" - defmodule #{schema} do - use ModBoss.Schema - - modbus_schema do - holding_register 1..2, :foo - input_register 300, :bar - coil 400, :baz, mode: :w - discrete_input 500, :qux - end - end - """) - - device = start_supervised!({Agent, fn -> @initial_state end}) - - set_registers(device, %{ - 1 => 10, - 2 => 20, - 300 => 30, - 400 => 0, - 500 => 1 - }) - - assert {:ok, result} = ModBoss.read_all(schema, read_func(device)) - - assert %{ - foo: [10, 20], - bar: 30, - qux: 1 - } == result - end - end - describe "ModBoss.write/3" do test "writes registers referenced by human-readable names from map" do device = start_supervised!({Agent, fn -> @initial_state end}) From a8078e56510225e8281a2b871928f44c9a34e32a Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 16:51:38 -0700 Subject: [PATCH 6/8] =?UTF-8?q?Use=20generic=20term=20=E2=80=9Cobject?= =?UTF-8?q?=E2=80=9D=20instead=20of=20=E2=80=9Cregister=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coils and Discrete Inputs aren’t technically “registers.” --- README.md | 30 +++++------ lib/modboss.ex | 94 +++++++++++++++++----------------- lib/modboss/encoding.ex | 8 +-- lib/modboss/mapping.ex | 46 ++++++++--------- lib/modboss/schema.ex | 66 ++++++++++++------------ test/modboss/encoding_test.exs | 14 ++--- test/modboss/mapping_test.exs | 4 +- test/modboss/schema_test.exs | 4 +- test/modboss_test.exs | 79 ++++++++++++++-------------- 9 files changed, 171 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index ab72b9d..f6484c2 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ ## Show that Bus who's Boss! -ModBoss is an Elixir library that maps Modbus registers to human-friendly names and provides +ModBoss is an Elixir library that maps Modbus objects to human-friendly names and provides automatic encoding/decoding of values—making your application logic simpler and more readable, and making testing of modbus concerns easier. -Note that ModBoss doesn't handle the actual reading/writing of modbus registers—it simply assists -in providing friendlier access to register values. You'll likely be wrapping another library such +Note that ModBoss doesn't handle the actual reading/writing of modbus objects—it simply assists +in providing friendlier access to object values. You'll likely be wrapping another library such as [Modbux](https://hexdocs.pm/modbux/readme.html) for the actual reads/writes. ## Installation @@ -31,12 +31,12 @@ end ### 1. Map your schema -Starting with the type of register, you'll define the addresses to include and a friendly name +Starting with the type of object, you'll define the addresses to include and a friendly name for the mapping. -The `:as` option dictates how values will be encoded before being written to Modbus or decoded -after being read from Modbus. You can use translation functions from another module—like those -found in `ModBoss.Encoding`—or provide your own as shown here with `as: :fw_version`. +The `:as` option dictates how values will be translated. You can use translation functions +from another module—like those found in `ModBoss.Encoding`—or provide your own as shown here +with `as: :fw_version`. When providing your own translation functions, ModBoss expects that you'll provide functions corresponding to the `:as` option but with `encode_` / `decode_` prefixes added as applicable. @@ -81,23 +81,23 @@ interacting on the Modbus. In practice, these functions will likely build on a l [Modbux](https://hexdocs.pm/modbux/readme.html) along with state stored in a GenServer (e.g. a `modbux_pid`, IP Address, etc.) to perform the read/write operations. -For each batch, the read_func will be provided the type of register +For each batch, the read_func will be provided the object type (`:holding_register`, `:input_register`, `:coil`, or `:discrete_input`), the starting address, and the number of addresses to read. It must return either `{:ok, result}` or `{:error, message}`. ```elixir -read_func = fn register_type, starting_address, count -> +read_func = fn object_type, starting_address, count -> result = custom_read_logic(…) {:ok, result} end ``` -For each batch, the `write_func` will be provided the type of register (`:holding_register` or +For each batch, the `write_func` will be provided 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}`. ```elixir -write_func = fn register_type, starting_address, value_or_values -> +write_func = fn object_type, starting_address, value_or_values -> result = custom_write_logic(…) {:ok, result} end @@ -129,8 +129,8 @@ iex> ModBoss.write(MyDevice.Schema, write_func, version: "0.2") Extracting your Modbus schema allows you to **isolate the encode/decode logic** making it much **more testable**. Your primary application logic becomes **simpler and more -readable** since it references registers by name and doesn't need to worry about encoding/decoding +readable** since it references mappings by name and doesn't need to worry about encoding/decoding of values. It also becomes fairly straightforward to set up **virtual devices** with the exact -same register mappings as your physical devices (e.g. using an Elixir Agent to hold the state of -the registers in a map). And it makes for **easier troubleshooting** since you don't need to -memorize (or look up) the register mappings when you're at an `iex` prompt. +same object mappings as your physical devices (e.g. using an Elixir Agent to hold the state of +the modbus objects in a map). And it makes for **easier troubleshooting** since you don't need to +memorize (or look up) the object mappings when you're at an `iex` prompt. diff --git a/lib/modboss.ex b/lib/modboss.ex index 4f80148..670b9e9 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -8,10 +8,10 @@ defmodule ModBoss do alias ModBoss.Mapping @typep mode :: :readable | :writable | :any - @type register_type :: :holding_register | :input_register | :coil | :discrete_input - @type read_func :: (register_type(), starting_address :: integer(), count :: integer() -> + @type object_type :: :holding_register | :input_register | :coil | :discrete_input + @type read_func :: (object_type(), starting_address :: integer(), count :: integer() -> {:ok, any()} | {:error, any()}) - @type write_func :: (register_type(), starting_address :: integer(), value_or_values :: any() -> + @type write_func :: (object_type(), starting_address :: integer(), value_or_values :: any() -> :ok | {:error, any()}) @type values :: [{atom(), any()}] | %{atom() => any()} @@ -22,7 +22,7 @@ defmodule ModBoss do 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 register (`:holding_register`, + 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}`. @@ -36,7 +36,7 @@ defmodule ModBoss do ## Examples - read_func = fn register_type, starting_address, count -> + read_func = fn object_type, starting_address, count -> result = custom_read_logic(…) {:ok, result} end @@ -73,7 +73,7 @@ defmodule ModBoss do end with {:ok, mappings} <- get_mappings(:readable, module, names), - {:ok, mappings} <- read_registers(module, mappings, read_func), + {:ok, mappings} <- read_mappings(module, mappings, read_func), {:ok, mappings} <- decode(mappings) do collect_results(mappings, plurality, opts) end @@ -120,8 +120,8 @@ defmodule ModBoss do mapping.encoded_value |> List.wrap() |> Enum.with_index(mapping.starting_address) - |> Enum.map(fn {value_for_register, address} -> - {{mapping.type, address}, value_for_register} + |> Enum.map(fn {value_for_object, address} -> + {{mapping.type, address}, value_for_object} end) end) |> Enum.into(%{}) @@ -131,9 +131,9 @@ defmodule ModBoss do Write to modbus using named mappings. ModBoss automatically encodes your `values`, then batches any encoded values destined for - contiguous registers—creating separate batches per register type. + contiguous objects—creating separate batches per object type. - For each batch, `write_func` will be called with the type of register (`:holding_register` or + 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}`. @@ -153,7 +153,7 @@ defmodule ModBoss do ## Example - write_func = fn register_type, starting_address, value_or_values -> + write_func = fn object_type, starting_address, value_or_values -> result = custom_write_logic(…) {:ok, result} end @@ -166,7 +166,7 @@ defmodule ModBoss do with {:ok, mappings} <- get_mappings(:writable, module, get_keys(values)), mappings <- put_values(mappings, values), {:ok, mappings} <- encode(mappings), - {:ok, _mappings} <- write_registers(module, mappings, write_func) do + {:ok, _mappings} <- write_mappings(module, mappings, write_func) do :ok end end @@ -181,11 +181,11 @@ defmodule ModBoss do end @spec get_mappings(mode(), module(), list()) :: {:ok, [Mapping.t()]} | {:error, String.t()} - defp get_mappings(mode, module, register_names) when is_list(register_names) do + defp get_mappings(mode, module, mapping_names) when is_list(mapping_names) do schema = module.__modbus_schema__() {mappings, unknown_names} = - register_names + mapping_names |> Enum.map(fn name -> case Map.get(schema, name, :unknown) do :unknown -> name @@ -204,11 +204,11 @@ defmodule ModBoss do mode == :readable and Enum.any?(unreadable(mappings)) -> names = unreadable(mappings) |> Enum.map_join(", ", fn %{name: name} -> inspect(name) end) - {:error, "Register(s) #{names} in #{inspect(module)} are not readable."} + {: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, "Register(s) #{names} in #{inspect(module)} are not writable."} + {:error, "ModBoss Mapping(s) #{names} in #{inspect(module)} are not writable."} true -> {:ok, mappings} @@ -218,20 +218,20 @@ defmodule ModBoss do defp unreadable(mappings), do: Enum.reject(mappings, &Mapping.readable?/1) defp unwritable(mappings), do: Enum.reject(mappings, &Mapping.writable?/1) - @spec read_registers(module(), [Mapping.t()], fun) :: {:ok, [Mapping.t()]} | {:error, any()} - defp read_registers(module, mappings, read_func) do - with {:ok, all_values} <- do_read_registers(module, mappings, read_func) do + @spec read_mappings(module(), [Mapping.t()], fun) :: {:ok, [Mapping.t()]} | {:error, any()} + defp read_mappings(module, mappings, read_func) do + with {:ok, all_values} <- do_read_mappings(module, mappings, read_func) do Enum.map(mappings, fn - %Mapping{register_count: 1} = mapping -> + %Mapping{address_count: 1} = mapping -> value = Map.fetch!(all_values, mapping.starting_address) %{mapping | encoded_value: value} - %Mapping{register_count: _plural} = mapping -> - registers = Enum.to_list(mapping.addresses) + %Mapping{address_count: _plural} = mapping -> + addresses = Enum.to_list(mapping.addresses) values = all_values - |> Map.take(registers) + |> Map.take(addresses) |> Enum.sort_by(fn {address, _value} -> address end) |> Enum.map(fn {_address, value} -> value end) @@ -241,15 +241,15 @@ defmodule ModBoss do end end - @spec do_read_registers(module(), [Mapping.t()], fun) :: {:ok, map()} - defp do_read_registers(module, mappings, read_func) do + @spec do_read_mappings(module(), [Mapping.t()], fun) :: {:ok, map()} + defp do_read_mappings(module, mappings, read_func) do mappings |> chunk_mappings(module, :read) |> Enum.map(fn [first | _rest] = chunk -> initial_acc = {first.type, first.starting_address, 0} - Enum.reduce(chunk, initial_acc, fn mapping, {type, starting_address, register_count} -> - {type, starting_address, register_count + mapping.register_count} + Enum.reduce(chunk, initial_acc, fn mapping, {type, starting_address, address_count} -> + {type, starting_address, address_count + mapping.address_count} end) end) |> Enum.reduce_while({:ok, %{}}, fn batch, {:ok, acc} -> @@ -261,13 +261,13 @@ defmodule ModBoss do end @spec read_batch(fun(), {any(), integer(), integer()}) :: {:ok, map()} | {:error, any()} - defp read_batch(read_func, {type, starting_address, register_count}) do - with {:ok, value_or_values} <- read_func.(type, starting_address, register_count) do + defp read_batch(read_func, {type, starting_address, address_count}) do + with {:ok, value_or_values} <- read_func.(type, starting_address, address_count) do values = List.wrap(value_or_values) value_count = Enum.count(values) - if value_count != register_count do - raise "Attempted to read #{register_count} registers starting from address #{starting_address} but received #{value_count} 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 = @@ -279,7 +279,7 @@ defmodule ModBoss do end end - defp write_registers(module, mappings, write_func) do + defp write_mappings(module, mappings, write_func) do mappings |> chunk_mappings(module, :write) |> Enum.map(fn [first | _rest] = chunk -> @@ -303,24 +303,24 @@ defmodule ModBoss do end @spec chunk_mappings([Mapping.t()], module(), :read | :write) :: - [{register_type(), integer(), [any()]}] + [{object_type(), integer(), [any()]}] defp chunk_mappings(mappings, module, mode) do chunk_fun = fn %Mapping{type: type, addresses: %Range{first: address}} = mapping, acc -> max_chunk = module.__max_batch__(mode, type) case acc do - {[], 0} when mapping.register_count <= max_chunk -> - {:cont, {[mapping], mapping.register_count}} + {[], 0} when mapping.address_count <= max_chunk -> + {:cont, {[mapping], mapping.address_count}} {[prior | _] = mappings, count} - when prior.addresses.last + 1 == address and count + mapping.register_count <= max_chunk -> - {:cont, {[mapping | mappings], count + mapping.register_count}} + when prior.addresses.last + 1 == address and count + mapping.address_count <= max_chunk -> + {:cont, {[mapping | mappings], count + mapping.address_count}} - {mappings, _count} when mapping.register_count <= max_chunk -> - {:cont, Enum.reverse(mappings), {[mapping], mapping.register_count}} + {mappings, _count} when mapping.address_count <= max_chunk -> + {:cont, Enum.reverse(mappings), {[mapping], mapping.address_count}} - {_, _} when mapping.register_count > max_chunk -> - raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} registers." + {_, _} when mapping.address_count > max_chunk -> + raise "Modbus mapping #{inspect(mapping.name)} exceeds the max #{mode} batch size of #{max_chunk} objects." end end @@ -353,7 +353,7 @@ defmodule ModBoss do defp encode_value(%Mapping{} = mapping) do with {module, function, args} <- get_encode_mfa(mapping), {:ok, encoded} <- apply(module, function, args), - :ok <- verify_register_count(mapping, encoded) do + :ok <- verify_value_count(mapping, encoded) do {:ok, encoded} end end @@ -365,9 +365,9 @@ defmodule ModBoss do # we only pass the value to be encoded. # # However, when calling built-in encoding functions, we pass both the value to be encoded - # _and_ the mapping. We do this because in some cases we need to know how many registers + # _and_ the mapping. We do this because in some cases we need to know how many objects # we're encoding for in order to provide truly generic encoders. For example, when encoding - # a string to ASCII, we may need to add padding to fill out the mapped registers. + # a string to ASCII, we may need to add padding to fill out the mapped objects. arguments = case module do ModBoss.Encoding -> [mapping.value, mapping] @@ -411,8 +411,8 @@ defmodule ModBoss do end end - defp verify_register_count(mapping, encoded) do - expected_count = mapping.register_count + defp verify_value_count(mapping, encoded) do + expected_count = mapping.address_count case List.wrap(encoded) |> length() do ^expected_count -> @@ -420,7 +420,7 @@ defmodule ModBoss do _ -> {:error, - "Encoded value #{inspect(encoded)} for #{inspect(mapping.name)} does not match the number of registers."} + "Encoded value #{inspect(encoded)} for #{inspect(mapping.name)} does not match the number of mapped addresses."} end end diff --git a/lib/modboss/encoding.ex b/lib/modboss/encoding.ex index 7ac8f22..7ac8ca8 100644 --- a/lib/modboss/encoding.ex +++ b/lib/modboss/encoding.ex @@ -143,10 +143,10 @@ defmodule ModBoss.Encoding do ## Examples - iex> encode_ascii("Hi!", %ModBoss.Mapping{register_count: 3}) + iex> encode_ascii("Hi!", %ModBoss.Mapping{address_count: 3}) {:ok, [18537, 8448, 0]} - iex> {:error, _too_many_characters} = encode_ascii("Hi!", %ModBoss.Mapping{register_count: 1}) + iex> {:error, _too_many_characters} = encode_ascii("Hi!", %ModBoss.Mapping{address_count: 1}) """ @spec encode_ascii(binary(), Mapping.t()) :: {:ok, list(integer())} | {:error, binary()} def encode_ascii(text, %Mapping{} = mapping) do @@ -179,13 +179,13 @@ defmodule ModBoss.Encoding do end defp pad(chars, mapping) do - max_chars = mapping.register_count * 2 + max_chars = mapping.address_count * 2 pad_count = max_chars - length(chars) if pad_count < 0 do message = """ Text for #{inspect(mapping.name)} contains too many characters. \ - With #{mapping.register_count} registers, it can hold up to #{max_chars} ASCII characters.\ + With #{mapping.address_count} registers, it can hold up to #{max_chars} ASCII characters.\ """ {:error, message} diff --git a/lib/modboss/mapping.ex b/lib/modboss/mapping.ex index 96327cd..f233071 100644 --- a/lib/modboss/mapping.ex +++ b/lib/modboss/mapping.ex @@ -8,29 +8,37 @@ defmodule ModBoss.Mapping do type: :holding_register | :input_register | :coil | :discrete_input, addresses: Range.t(), starting_address: integer(), - register_count: integer(), + address_count: integer(), as: atom() | {module(), atom()}, value: any(), encoded_value: integer() | [integer()], mode: :r | :rw | :w } - defstruct name: nil, - type: nil, - addresses: nil, - starting_address: nil, - register_count: nil, - as: nil, - value: nil, - encoded_value: nil, - mode: :r + defstruct [ + :name, + :type, + :addresses, + :starting_address, + :address_count, + :as, + :value, + :encoded_value, + :mode + ] defguardp is_address_or_range(address) when is_integer(address) or is_struct(address, Range) @doc false def new(module, name, type, addresses, opts \\ []) when is_atom(module) and is_atom(name) and is_address_or_range(addresses) and is_list(opts) do - {address_range, starting_address, register_count} = registers(addresses) + address_range = + case addresses do + %Range{step: 1} -> addresses + %Range{step: _other} -> raise("Only address ranges with step `1` are supported.") + address when is_integer(address) -> address..address + end + as = Keyword.get(opts, :as) |> expand_as(module) opts = @@ -38,12 +46,13 @@ defmodule ModBoss.Mapping do name: name, type: type, addresses: address_range, - starting_address: starting_address, - register_count: register_count, + starting_address: address_range.first, + address_count: address_range.last - address_range.first + 1, as: as ) __MODULE__ + |> struct(%{mode: :r}) |> struct!(opts) |> validate!(:type) |> validate!(:mode) @@ -93,17 +102,6 @@ defmodule ModBoss.Mapping do raise "Invalid ModBoss option #{inspect([{field, value}])} for #{inspect(mapping.name)}." end - defp registers(addresses) do - range = - case addresses do - %Range{step: 1} -> addresses - %Range{step: _other} -> raise("Only address ranges with step `1` are supported.") - address when is_integer(address) -> address..address - end - - {range, range.first, range.last - range.first + 1} - end - @read_modes [:r, :rw] @doc false def readable?(%__MODULE__{} = mapping), do: mapping.mode in @read_modes diff --git a/lib/modboss/schema.ex b/lib/modboss/schema.ex index b0a3d07..1954b1f 100644 --- a/lib/modboss/schema.ex +++ b/lib/modboss/schema.ex @@ -2,13 +2,13 @@ defmodule ModBoss.Schema do @moduledoc """ Macros for establishing Modbus schema. - The schema allows names to be assigned to individual registers or groups of contiguous - registers along with encoder/decoder functions. It also allows registers to be flagged + The schema allows names to be assigned to individual modbus objects or groups of contiguous + modbus objects along with encoder/decoder functions. It also allows objects to be flagged as readable and/or writable. - ## Naming an address + ## Naming a mapping - You'll name a Modbus address with this format: + You create a ModBoss mapping with this format: holding_register 17, :outdoor_temp, as: {ModBoss.Encoding, :signed_int} @@ -27,8 +27,8 @@ defmodule ModBoss.Schema do ## Mode - All registers are read-only by default. Use `mode: :rw` to allow both reads & writes. - Or use `mode: :w` to mark a register as write-only. + 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. ## Automatic encoding/decoding @@ -36,9 +36,9 @@ defmodule ModBoss.Schema do will provide functions with `encode_` or `decode_` prepended to the value provided by the `:as` option. - For example, if you specify `as: :on_off` for a read-only register, ModBoss will expect that - the schema module defines an `encode_on_off/1` function that accepts the value to encode and - returns either `{:ok, encoded_value}` or `{:error, messsage}`. + For example, if you specify `as: :on_off` for a read-only mapping, ModBoss will expect that + the schema module defines an `encode_on_off/1` function which accepts the value to encode and + returns either `{:ok, encoded_value}` or `{:error, message}`. If the function to be used lives outside of the current module, a tuple including the module name can be passed. For example, you can use built-in translation from `ModBoss.Encoding` such @@ -46,23 +46,23 @@ defmodule ModBoss.Schema do > #### output of `encode_*` {: .info} > - > Your encode function may need to encode for **one or multiple** registers, depending on the + > Your encode function may need to encode for **one or multiple** objects, depending on the > mapping. You are free to return either a single value or a list of values—the important thing - > is that the number of values returned needs to match the number of registers for your mapping. - > If it doesn't, ModBoss will detect that and return an error during encoding. + > is that the number of values returned needs to match the number of objects from your mapping. + > If it doesn't, ModBoss will return an error when encoding. > - > For example, if encoding "ABC!" as ascii into a mapping with 3 registers, this would - > technically only "require" 2 registers (one 16-bit register for every 2 characters). - > However, your encoding should return a list of 3 values if that's what you've assigned - > to the mapping in your schema. + > For example, if encoding "ABC!" as ascii into a mapping with 3 registers, these characters + > would technically only _require_ 2 registers (one 16-bit register for every 2 characters). + > However, your encoding should return a list of length equaling what you've assigned to + > the mapping in your schema—i.e. in this example, a list of length 3. > #### input to `decode_*` {: .info} > - > When decoding a single register, the decode function will be passed the single value from that - > register as provided by your read function. + > When decoding a mapping involving a single address, the decode function will be passed the + > single value from that address/object as provided by your read function. > - > When decoding multiple registers (e.g. in `ModBoss.Encoding.decode_ascii/1`), the decode - > function will be passed a **List** of values. + > When decoding a mapping involving multiple addresses (e.g. in `ModBoss.Encoding.decode_ascii/1`), + > the decode function will be passed a **List** of values. ## Example @@ -96,7 +96,7 @@ defmodule ModBoss.Schema do quote do import unquote(__MODULE__), only: [modbus_schema: 1] - Module.register_attribute(__MODULE__, :register_mappings, accumulate: true) + 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)) @@ -140,7 +140,7 @@ defmodule ModBoss.Schema do module = __CALLER__.module quote bind_quoted: binding() do - ModBoss.Schema.create_register_mapping(module, :holding_register, addresses, name, opts) + ModBoss.Schema.create_mapping(module, :holding_register, addresses, name, opts) end end @@ -156,7 +156,7 @@ defmodule ModBoss.Schema do module = __CALLER__.module quote bind_quoted: binding() do - ModBoss.Schema.create_register_mapping(module, :input_register, addresses, name, opts) + ModBoss.Schema.create_mapping(module, :input_register, addresses, name, opts) end end @@ -173,7 +173,7 @@ defmodule ModBoss.Schema do module = __CALLER__.module quote bind_quoted: binding() do - ModBoss.Schema.create_register_mapping(module, :coil, addresses, name, opts) + ModBoss.Schema.create_mapping(module, :coil, addresses, name, opts) end end @@ -189,7 +189,7 @@ defmodule ModBoss.Schema do module = __CALLER__.module quote bind_quoted: binding() do - ModBoss.Schema.create_register_mapping(module, :discrete_input, addresses, name, opts) + ModBoss.Schema.create_mapping(module, :discrete_input, addresses, name, opts) end end @@ -202,16 +202,16 @@ defmodule ModBoss.Schema do def validate_name!(_env, _name), do: :ok - def create_register_mapping(module, register_type, address_or_range, name, opts) do - if not Module.has_attribute?(module, :register_mappings) do + def create_mapping(module, object_type, address_or_range, name, opts) do + if not Module.has_attribute?(module, :modboss_mappings) do raise """ - Cannot assign modbus registers. Please make sure you have invoked \ + Cannot create modbus mappings. Please make sure you have invoked \ `use ModBoss.Schema` in #{inspect(module)}.\ """ end - with %Mapping{} = mapping <- Mapping.new(module, name, register_type, address_or_range, opts) do - Module.put_attribute(module, :register_mappings, mapping) + with %Mapping{} = mapping <- Mapping.new(module, name, object_type, address_or_range, opts) do + Module.put_attribute(module, :modboss_mappings, mapping) end end @@ -226,7 +226,7 @@ defmodule ModBoss.Schema do max_holding_register_writes = max_writes[:holding_registers] || 123 max_coil_writes = max_writes[:coils] || 1968 - mappings = Module.get_attribute(env.module, :register_mappings) + mappings = Module.get_attribute(env.module, :modboss_mappings) duplicate_names = mappings @@ -239,7 +239,7 @@ defmodule ModBoss.Schema do file: env.file, line: env.line, description: - "The following names were used to identify more than one register: [#{Enum.join(duplicate_names, ", ")}]." + "The following names were used to identify more than one mapping: [#{Enum.join(duplicate_names, ", ")}]." end duplicate_addresses = @@ -259,7 +259,7 @@ defmodule ModBoss.Schema do file: env.file, line: env.line, description: """ - Each address can only be registered once per object type, but the following were mapped more than once: + Each address can only be mapped once per object type, but the following were mapped more than once: #{Enum.map_join(duplicate_addresses, "\n", fn dup -> " * #{inspect(dup)}" end)} """ diff --git a/test/modboss/encoding_test.exs b/test/modboss/encoding_test.exs index 7b26e8e..745e7e0 100644 --- a/test/modboss/encoding_test.exs +++ b/test/modboss/encoding_test.exs @@ -87,20 +87,20 @@ defmodule ModBoss.EncodingTest do describe "ascii" do test "encodes ASCII text to the expected number of register values" do - assert {:ok, [0, 0, 0]} == Encoding.encode_ascii("", %Mapping{register_count: 3}) - assert {:ok, [0x4100]} == Encoding.encode_ascii("A", %Mapping{register_count: 1}) - assert {:ok, [0x4142]} == Encoding.encode_ascii("AB", %Mapping{register_count: 1}) - assert {:ok, [0x4142, 0x4300]} == Encoding.encode_ascii("ABC", %Mapping{register_count: 2}) + assert {:ok, [0, 0, 0]} == Encoding.encode_ascii("", %Mapping{address_count: 3}) + assert {:ok, [0x4100]} == Encoding.encode_ascii("A", %Mapping{address_count: 1}) + assert {:ok, [0x4142]} == Encoding.encode_ascii("AB", %Mapping{address_count: 1}) + assert {:ok, [0x4142, 0x4300]} == Encoding.encode_ascii("ABC", %Mapping{address_count: 2}) end test "returns an error when attempting to encode more characters than what the mapped registers can hold" do - mapping = %Mapping{name: :my_mapping, register_count: 2} + mapping = %Mapping{name: :my_mapping, address_count: 2} assert {:error, message} = Encoding.encode_ascii("ABCDE", mapping) assert String.match?(message, ~r/too many characters/) end test "returns an error when attempting to encode non-ASCII text" do - assert {:error, message} = Encoding.encode_ascii("José", %Mapping{register_count: 2}) + assert {:error, message} = Encoding.encode_ascii("José", %Mapping{address_count: 2}) assert String.match?(message, ~r/non-ASCII/i) end @@ -117,7 +117,7 @@ defmodule ModBoss.EncodingTest do test "roundtrips as expected" do [{"", 1}, {"A", 1}, {"AB", 2}, {"ABC", 2}, {"ABCD", 2}, {"A", 4}] |> Enum.each(fn {text, count} -> - {:ok, encoded} = Encoding.encode_ascii(text, %Mapping{name: :foo, register_count: count}) + {:ok, encoded} = Encoding.encode_ascii(text, %Mapping{name: :foo, address_count: count}) {:ok, decoded} = Encoding.decode_ascii(encoded) assert text == decoded end) diff --git a/test/modboss/mapping_test.exs b/test/modboss/mapping_test.exs index 88687d1..a3891d5 100644 --- a/test/modboss/mapping_test.exs +++ b/test/modboss/mapping_test.exs @@ -9,7 +9,7 @@ defmodule ModBoss.MappingTest do assert mapping.type == :holding_register assert mapping.addresses == 1..1 assert mapping.starting_address == 1 - assert mapping.register_count == 1 + assert mapping.address_count == 1 assert mapping.mode == :r end @@ -19,7 +19,7 @@ defmodule ModBoss.MappingTest do assert mapping.type == :coil assert mapping.addresses == 1..5 assert mapping.starting_address == 1 - assert mapping.register_count == 5 + assert mapping.address_count == 5 assert mapping.mode == :rw end diff --git a/test/modboss/schema_test.exs b/test/modboss/schema_test.exs index d35c69f..fcc1661 100644 --- a/test/modboss/schema_test.exs +++ b/test/modboss/schema_test.exs @@ -20,7 +20,7 @@ defmodule ModBoss.SchemaTest do end end - describe "create_register_mapping" do + describe "create_mapping" do test "creates read-only mappings by default" do assert mapping(ExampleSchema, :foo_holding_register).mode == :r end @@ -36,7 +36,7 @@ defmodule ModBoss.SchemaTest do end test "raises an exception if the same name is used twice" do - assert_raise CompileError, ~r/names were used to identify more than one register/, fn -> + assert_raise CompileError, ~r/names were used to identify more than one mapping/, fn -> Code.compile_string(""" defmodule #{unique_module()} do use ModBoss.Schema diff --git a/test/modboss_test.exs b/test/modboss_test.exs index 1427e5b..52b274c 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -29,26 +29,26 @@ defmodule ModBossTest do @initial_state %{ reads: 0, writes: 0, - registers: %{} + objects: %{} } describe "ModBoss.read/4" do - test "reads a single register by name, returning a single result" do + test "reads a individual mapping by name, returning a single result" do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{1 => 123}) + set_objects(device, %{1 => 123}) {:ok, 123} = ModBoss.read(FakeSchema, read_func(device), :foo) end test "reads values for mappings that cover multiple address" do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{10 => :a, 11 => :b, 12 => :c}) + set_objects(device, %{10 => :a, 11 => :b, 12 => :c}) {:ok, [:a, :b, :c]} = ModBoss.read(FakeSchema, read_func(device), :qux) end - test "reads multiple (and non-contiguous) registers by name, returning a map of requested registers" do + test "reads multiple (and non-contiguous) mappings by name, returning a map of requested values" do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{ + set_objects(device, %{ 1 => :a, 2 => :b, 3 => :c, @@ -68,10 +68,10 @@ defmodule ModBossTest do ModBoss.read(FakeSchema, read_func(device), [:foobar, :bazqux]) end - test "refuses to read unless all registers are declared readable" do + test "refuses to read unless all mappings are declared readable" do device = start_supervised!({Agent, fn -> @initial_state end}) - assert {:error, "Register(s) :baz in ModBossTest.FakeSchema are not readable."} = + assert {:error, "ModBoss Mapping(s) :baz in ModBossTest.FakeSchema are not readable."} = ModBoss.read(FakeSchema, read_func(device), [:bar, :baz]) end @@ -104,7 +104,6 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) - # Set holding registers holding_register_values = for i <- 1..126, into: %{}, do: {i, 1} input_register_values = for i <- 201..326, into: %{}, do: {i, 1} coil_values = for i <- 2001..4001, into: %{}, do: {i, 1} @@ -117,7 +116,7 @@ defmodule ModBossTest do |> Map.merge(coil_values) |> Map.merge(discrete_input_values) - set_registers(device, all_values) + set_objects(device, all_values) single = [:holding_1, :holding_125] double = [:holding_1, :holding_125, :holding_126] @@ -156,7 +155,7 @@ defmodule ModBossTest do assert 2 = get_read_count(device) end - test "can customize batch sizes per register type" do + test "can customize batch sizes per object type" do schema = unique_module() max_holding_register_reads = Enum.random(1..3) max_input_register_reads = Enum.random(1..3) @@ -198,7 +197,7 @@ defmodule ModBossTest do values = for i <- 1..16, into: %{}, do: {i, 1} device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, values) + set_objects(device, values) # Holding registers holding_registers = [:holding_foo, :holding_bar, :holding_baz, :holding_qux] @@ -245,7 +244,7 @@ defmodule ModBossTest do assert 2 = get_read_count(device) end - test "reads registers of different types separately" do + test "reads mappings of different object types separately" do schema = unique_module() Code.compile_string(""" @@ -267,7 +266,7 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{ + set_objects(device, %{ 1 => 1, 2 => 2, 101 => 101, @@ -286,10 +285,10 @@ defmodule ModBossTest do test "raises an error if it doesn't get back the expected number of values" do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{1 => 1}) + set_objects(device, %{1 => 1}) assert_raise RuntimeError, - "Attempted to read 3 registers starting from address 10 but received 0 values.", + "Attempted to read 3 values starting from address 10 but received 0 values.", fn -> ModBoss.read(FakeSchema, read_func(device), [:foo, :qux]) end @@ -319,7 +318,7 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{ + set_objects(device, %{ 1 => 1, 2 => 0, 3 => 20328, @@ -351,7 +350,7 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{ + set_objects(device, %{ 1 => 1, 2 => 0, 3 => 18533, @@ -366,7 +365,7 @@ defmodule ModBossTest do ModBoss.read(schema, read_func(device), [:yep, :nope, :text], decode: false) end - test "fetches all readable registers if told to read `:all`" do + test "fetches all readable mappings if told to read `:all`" do schema = unique_module() Code.compile_string(""" @@ -384,7 +383,7 @@ defmodule ModBossTest do device = start_supervised!({Agent, fn -> @initial_state end}) - set_registers(device, %{ + set_objects(device, %{ 1 => 10, 2 => 20, 300 => 30, @@ -404,13 +403,13 @@ defmodule ModBossTest do end describe "ModBoss.write/3" do - test "writes registers referenced by human-readable names from map" do + test "writes objects referenced by human-readable names from map" do device = start_supervised!({Agent, fn -> @initial_state end}) :ok = ModBoss.write(FakeSchema, write_func(device), %{baz: 1, corge: 1234}) assert %{3 => 1, 15 => 1234} = get_registers(device) end - test "writes registers referenced by human-readable names from keyword" do + test "writes objects referenced by human-readable names from keyword" do device = start_supervised!({Agent, fn -> @initial_state end}) :ok = ModBoss.write(FakeSchema, write_func(device), baz: 1, corge: 1234) assert %{3 => 1, 15 => 1234} = get_registers(device) @@ -423,12 +422,12 @@ defmodule ModBossTest do ModBoss.write(FakeSchema, write_func(device), %{foobar: 1, bazqux: 2}) end - test "refuses to write unless all registers are declared writable" do + test "refuses to write unless all mappings are declared writable" do device = start_supervised!({Agent, fn -> @initial_state end}) initial_values = %{1 => 0, 2 => 0, 3 => 0} - set_registers(device, initial_values) + set_objects(device, initial_values) - assert {:error, "Register(s) :foo, :bar in ModBossTest.FakeSchema are not writable."} = + assert {:error, "ModBoss Mapping(s) :foo, :bar in ModBossTest.FakeSchema are not writable."} = ModBoss.write(FakeSchema, write_func(device), %{foo: 1, bar: 2, baz: 3}) assert get_registers(device) == initial_values @@ -437,7 +436,7 @@ defmodule ModBossTest do assert get_registers(device) == Map.put(initial_values, 3, 3) end - test "writes named registers that span more than one actual register" do + test "writes named mappings that span more than one address" do device = start_supervised!({Agent, fn -> @initial_state end}) :ok = ModBoss.write(FakeSchema, write_func(device), %{qux: [0, 10, 20], quux: [-1, -2]}) assert %{10 => 0, 11 => 10, 12 => 20, 13 => -1, 14 => -2} = get_registers(device) @@ -478,15 +477,15 @@ defmodule ModBossTest do } = get_registers(device) end - test "returns an error if the number of values doesn't match the number of registers" do + test "returns an error if the number of values doesn't match the number of mapped addresses" do device = start_supervised!({Agent, fn -> @initial_state end}) assert {:error, - "Failed to encode :qux. Encoded value [100, 200] for :qux does not match the number of registers."} = + "Failed to encode :qux. Encoded value [100, 200] for :qux does not match the number of mapped addresses."} = ModBoss.write(FakeSchema, write_func(device), %{qux: [100, 200]}) end - test "batches contiguous writes for each type up to the Modbus protocol's maximum" do + test "batches contiguous writes for each object type up to the Modbus protocol's maximum" do schema = unique_module() Code.compile_string(""" @@ -526,7 +525,7 @@ defmodule ModBossTest do assert 2 = get_write_count(device) end - test "writes registers of different types separately" do + test "writes mappings of different object types separately" do schema = unique_module() Code.compile_string(""" @@ -673,7 +672,7 @@ defmodule ModBossTest do }) end - test "returns an error if any registers are unrecognized" do + test "returns an error if any mapping names are unrecognized" do schema = unique_module() Code.compile_string(""" @@ -694,24 +693,24 @@ defmodule ModBossTest do end end - defp set_registers(device, %{} = values) when is_pid(device) do + defp set_objects(device, %{} = values) when is_pid(device) do keys = Map.keys(values) if not Enum.all?(keys, &is_integer/1) do raise """ The fake test device uses a map with integer keys to simulate a real device. \n - Manually set registers using their numeric address rather than their human name. + Manually set objects using their numeric address rather than their human name. """ end Agent.update(device, fn state -> - updated_registers = Map.merge(state.registers, values) - %{state | registers: updated_registers} + updated_objects = Map.merge(state.objects, values) + %{state | objects: updated_objects} end) end defp get_registers(device) when is_pid(device) do - Agent.get(device, fn state -> state.registers end) + Agent.get(device, fn state -> state.objects end) end # Getting the write count also resets it @@ -731,7 +730,7 @@ defmodule ModBossTest do values = Agent.get(device, fn state -> - state.registers + state.objects |> Map.take(addresses) |> Enum.sort_by(fn {address, _value} -> address end) |> Enum.map(fn {_address, value} -> value end) @@ -748,15 +747,15 @@ defmodule ModBossTest do defp write_func(device) when is_pid(device) do fn _type, starting_address, values -> - registers = + objects = values |> List.wrap() |> Enum.with_index(starting_address) |> Enum.into(%{}, fn {value, address} -> {address, value} end) Agent.update(device, fn state -> - updated_registers = Map.merge(state.registers, registers) - %{state | registers: updated_registers, writes: state.writes + 1} + updated_objects = Map.merge(state.objects, objects) + %{state | objects: updated_objects, writes: state.writes + 1} end) end end From 794cabb4a3424e219e603f363bb18dab55fc8851 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 17:22:29 -0700 Subject: [PATCH 7/8] Only gather readable mappings when reading all --- lib/modboss.ex | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/modboss.ex b/lib/modboss.ex index 670b9e9..44db071 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -60,14 +60,9 @@ defmodule ModBoss do @spec read(module(), read_func(), atom() | [atom()], keyword()) :: {:ok, any()} | {:error, any()} def read(module, read_func, name_or_names, opts \\ []) do - readable_mappings = - module.__modbus_schema__() - |> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end) - |> Enum.map(fn {name, _mapping} -> name end) - {names, plurality} = case name_or_names do - :all -> {readable_mappings, :plural} + :all -> {readable_mappings(module), :plural} name when is_atom(name) -> {[name], :singular} names when is_list(names) -> {names, :plural} end @@ -79,6 +74,12 @@ defmodule ModBoss do end end + defp readable_mappings(module) do + module.__modbus_schema__() + |> Enum.filter(fn {_, mapping} -> Mapping.readable?(mapping) end) + |> Enum.map(fn {name, _mapping} -> name end) + end + defp collect_results(mappings, plurality, opts) do field_to_return = if Keyword.get(opts, :decode, true), do: :value, else: :encoded_value From 30d67b437769fe9a68423fac09daed63e7a8561c Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Thu, 3 Jul 2025 17:23:45 -0700 Subject: [PATCH 8/8] Bump version to 0.1.1 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 78ac38c..d2d3d29 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule ModBoss.MixProject do use Mix.Project @source_url "https://github.com/goodpixel/modboss" - @version "0.1.0" + @version "0.1.1" def project do [