diff --git a/lib/modboss.ex b/lib/modboss.ex index 2abad30..927fc61 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -7,13 +7,13 @@ defmodule ModBoss do alias ModBoss.Mapping - @typep mode :: :readable | :writable + @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() -> {:ok, any()} | {:error, any()}) @type write_func :: (register_type(), starting_address :: integer(), value_or_values :: any() -> :ok | {:error, any()}) - @type values_to_write :: [{atom(), any()}] | %{atom() => any()} + @type values :: [{atom(), any()}] | %{atom() => any()} @doc false def read_all(module, read_func, opts \\ []) do @@ -102,6 +102,41 @@ defmodule ModBoss do end) end + @doc """ + Encode values per the mapping without actually writing them. + + This can be useful in test scenarios and enables values to be encoded in bulk without actually + being written via Modbus. + + Returns a map with keys of the form `{type, address}` and `encoded_value` as values. + + ## Example + + iex> ModBoss.encode(MyDevice.Schema, foo: "Yay") + {:ok, %{{:holding_register, 15} => 22881, {:holding_register, 16} => 30976}} + """ + @spec encode(module(), values()) :: {:ok, map()} | {:error, any()} + def encode(module, values) when is_atom(module) do + with {:ok, mappings} <- get_mappings(:any, module, get_keys(values)), + mappings <- put_values(mappings, values), + {:ok, mappings} <- encode(mappings) do + {:ok, flatten_encoded_values(mappings)} + end + end + + defp flatten_encoded_values(mappings) do + mappings + |> Enum.flat_map(fn %Mapping{} = mapping -> + mapping.encoded_value + |> List.wrap() + |> Enum.with_index(mapping.starting_address) + |> Enum.map(fn {value_for_register, address} -> + {{mapping.type, address}, value_for_register} + end) + end) + |> Enum.into(%{}) + end + @doc """ Write to modbus using named mappings. @@ -136,7 +171,7 @@ defmodule ModBoss do iex> ModBoss.write(MyDevice.Schema, write_func, foo: 75, bar: "ABC") :ok """ - @spec write(module(), write_func(), values_to_write()) :: :ok | {:error, any()} + @spec write(module(), write_func(), values()) :: :ok | {:error, any()} def write(module, write_func, values) when is_atom(module) and is_function(write_func) do with {:ok, mappings} <- get_mappings(:writable, module, get_keys(values)), mappings <- put_values(mappings, values), @@ -175,7 +210,7 @@ defmodule ModBoss do cond do Enum.any?(unknown_names) -> names = unknown_names |> Enum.map_join(", ", fn name -> inspect(name) end) - {:error, "Unknown register(s) #{names} for #{inspect(module)}."} + {:error, "Unknown mapping(s) #{names} for #{inspect(module)}."} mode == :readable and Enum.any?(unreadable(mappings)) -> names = unreadable(mappings) |> Enum.map_join(", ", fn %{name: name} -> inspect(name) end) diff --git a/lib/modboss/encoding.ex b/lib/modboss/encoding.ex index 274a9a2..0ead505 100644 --- a/lib/modboss/encoding.ex +++ b/lib/modboss/encoding.ex @@ -5,7 +5,7 @@ defmodule ModBoss.Encoding do To make use of these functions, use the `:as` option in your `ModBoss.Schema` but leave off the `encode_` or `decode_` prefix. - In other words, to use the built-in ASCII translation, specifiy `as: :ascii` in your schema. + For example, to use the built-in ASCII translation, specify `as: :ascii` in your schema. ### Note about that extra arg… diff --git a/lib/modboss/mapping.ex b/lib/modboss/mapping.ex index df7314e..96327cd 100644 --- a/lib/modboss/mapping.ex +++ b/lib/modboss/mapping.ex @@ -11,7 +11,7 @@ defmodule ModBoss.Mapping do register_count: integer(), as: atom() | {module(), atom()}, value: any(), - encoded_value: integer(), + encoded_value: integer() | [integer()], mode: :r | :rw | :w } diff --git a/test/modboss_test.exs b/test/modboss_test.exs index 5622918..c870cac 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -64,7 +64,7 @@ defmodule ModBossTest do test "returns an error if any mapping names are unrecognized" do device = start_supervised!({Agent, fn -> @initial_state end}) - assert {:error, "Unknown register(s) :foobar, :bazqux for ModBossTest.FakeSchema."} = + assert {:error, "Unknown mapping(s) :foobar, :bazqux for ModBossTest.FakeSchema."} = ModBoss.read(FakeSchema, read_func(device), [:foobar, :bazqux]) end @@ -440,7 +440,7 @@ defmodule ModBossTest do end end - describe "ModBoss.write/4" do + describe "ModBoss.write/3" do test "writes registers 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}) @@ -456,7 +456,7 @@ defmodule ModBossTest do test "returns an error if any mapping names are unrecognized" do device = start_supervised!({Agent, fn -> @initial_state end}) - assert {:error, "Unknown register(s) :foobar, :bazqux for ModBossTest.FakeSchema."} = + assert {:error, "Unknown mapping(s) :foobar, :bazqux for ModBossTest.FakeSchema."} = ModBoss.write(FakeSchema, write_func(device), %{foobar: 1, bazqux: 2}) end @@ -631,6 +631,106 @@ defmodule ModBossTest do end end + describe "ModBoss.encode/2" do + test "translates values from a Keyword List per the schema" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + alias ModBoss.Encoding + + modbus_schema do + holding_register 1, :foo, as: {Encoding, :boolean} + holding_register 2, :bar, as: {Encoding, :boolean} + holding_register 3..4, :baz, as: {Encoding, :ascii} + input_register 100, :qux + coil 101, :quux + discrete_input 102, :corge + end + end + """) + + assert {:ok, + %{ + {:holding_register, 1} => 1, + {:holding_register, 2} => 0, + {:holding_register, 3} => 22383, + {:holding_register, 4} => 30497, + {:input_register, 100} => 3, + {:coil, 101} => 2, + {:discrete_input, 102} => 1 + }} = + ModBoss.encode(schema, + foo: true, + bar: false, + baz: "Wow!", + qux: 3, + quux: 2, + corge: 1 + ) + end + + test "translates values from a map per the schema" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + alias ModBoss.Encoding + + modbus_schema do + holding_register 1, :foo, as: {Encoding, :boolean} + holding_register 2, :bar, as: {Encoding, :boolean} + holding_register 3..4, :baz, as: {Encoding, :ascii} + input_register 100, :qux + coil 101, :quux + discrete_input 102, :corge + end + end + """) + + assert {:ok, + %{ + {:holding_register, 1} => 1, + {:holding_register, 2} => 0, + {:holding_register, 3} => 22383, + {:holding_register, 4} => 30497, + {:input_register, 100} => 3, + {:coil, 101} => 2, + {:discrete_input, 102} => 1 + }} = + ModBoss.encode(schema, %{ + foo: true, + bar: false, + baz: "Wow!", + qux: 3, + quux: 2, + corge: 1 + }) + end + + test "returns an error if any registers are unrecognized" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + modbus_schema do + holding_register 1, :foo + holding_register 2, :bar + end + end + """) + + assert {:error, message} = ModBoss.encode(schema, %{foo: 1, bar: 2, baz: 3}) + assert String.match?(message, ~r/Unknown mapping/i) + + assert {:ok, _encoded_values} = ModBoss.encode(schema, %{foo: 1, bar: 2}) + end + end + defp set_registers(device, %{} = values) when is_pid(device) do keys = Map.keys(values)