From 4c7e8da7516a0423f7836048d56e0d9cc493a46c Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Sat, 21 Mar 2026 12:37:09 -0700 Subject: [PATCH 1/4] Support optional 1 & 2-arity encode/decode functions The 2-arity version passes a `Metadata` struct which can be useful for conditional encode/decode logic at runtime. --- lib/modboss.ex | 45 ++++-- lib/modboss/encoding/metadata.ex | 17 ++- lib/modboss/schema.ex | 110 +++++++++++-- test/modboss/schema_test.exs | 124 +++++++++++++++ test/modboss_test.exs | 254 ++++++++++++++++++++++++++++++- 5 files changed, 524 insertions(+), 26 deletions(-) diff --git a/lib/modboss.ex b/lib/modboss.ex index 0860e54..7940e5a 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -767,12 +767,23 @@ defmodule ModBoss do defp get_encode_mfa(%Mapping{as: {module, as}} = mapping) do function = String.to_atom("encode_#{as}") - if exists?(module, function, 2) do - metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) - {module, function, [mapping.value, metadata]} - else - {:error, - "Expected #{inspect(module)}.#{function}/2 to be defined for ModBoss mapping #{inspect(mapping.name)}."} + has_arity_1 = exists?(module, function, 1) + has_arity_2 = exists?(module, function, 2) + + cond do + has_arity_1 and has_arity_2 -> + {:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."} + + has_arity_2 -> + metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) + {module, function, [mapping.value, metadata]} + + has_arity_1 -> + {module, function, [mapping.value]} + + true -> + {:error, + "Expected #{inspect(module)}.#{function}/1 or #{function}/2 to be defined for ModBoss mapping #{inspect(mapping.name)}."} end end @@ -808,11 +819,23 @@ defmodule ModBoss do defp get_decode_mfa(%Mapping{as: {module, as}} = mapping) do function = String.to_atom("decode_#{as}") - if exists?(module, function, 1) do - {module, function, [mapping.encoded_value]} - else - {:error, - "Expected #{inspect(module)}.#{function}/1 to be defined for ModBoss mapping #{inspect(mapping.name)}."} + has_arity_1 = exists?(module, function, 1) + has_arity_2 = exists?(module, function, 2) + + cond do + has_arity_1 and has_arity_2 -> + {:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."} + + has_arity_2 -> + metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) + {module, function, [mapping.encoded_value, metadata]} + + has_arity_1 -> + {module, function, [mapping.encoded_value]} + + true -> + {:error, + "Expected #{inspect(module)}.#{function}/1 or #{function}/2 to be defined for ModBoss mapping #{inspect(mapping.name)}."} end end diff --git a/lib/modboss/encoding/metadata.ex b/lib/modboss/encoding/metadata.ex index 53b94db..f0a38e7 100644 --- a/lib/modboss/encoding/metadata.ex +++ b/lib/modboss/encoding/metadata.ex @@ -1,10 +1,19 @@ defmodule ModBoss.Encoding.Metadata do @moduledoc """ - Metadata to support encoding + Metadata to support encoding and decoding. - Sometimes it's essential to know how many registers you're encoding a value - to fit into. For example, when encoding ASCII text, you might choose to pad - unused bits with zeros. See the implementation of + Passed as the second argument to encode and decode functions that accept + 2-arity (e.g. `encode_foo/2`, `decode_foo/2`). Contains information about + the mapping being encoded/decoded and any user-provided context. + + ## Fields + + * `:name` — the mapping name (e.g. `:outdoor_temp`) + * `:type` — the object type (e.g. `:holding_register`) + * `:address_count` — the number of addresses in the mapping + + For example, when encoding ASCII text, `address_count` tells you how many + registers to pad into. See the implementation of `ModBoss.Encoding.encode_ascii/2` for an example. """ diff --git a/lib/modboss/schema.ex b/lib/modboss/schema.ex index 47d6fae..c0deb65 100644 --- a/lib/modboss/schema.ex +++ b/lib/modboss/schema.ex @@ -14,7 +14,7 @@ defmodule ModBoss.Schema do This establishes address 17 as a holding register with the name `:outdoor_temp`. The raw value from the register will be passed to - `ModBoss.Encoding.decode_signed_int/1` before being returned. + `ModBoss.Encoding.decode_signed_int/1` when decoding. Similarly, to set aside a **group of** registers to hold a single logical value, it would look like: @@ -23,7 +23,7 @@ defmodule ModBoss.Schema do This establishes addresses 20–23 as holding registers with the name `:model_name`. The raw values from these registers will be passed (as a list) - to `ModBoss.Encoding.decode_ascii/1` before being returned. + to `ModBoss.Encoding.decode_ascii/1` when decoding. ## Mode @@ -36,11 +36,20 @@ defmodule ModBoss.Schema do that you 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 mapping, ModBoss - will expect that the schema module defines an `encode_on_off/2` function which - accepts the value to encode and `ModBoss.Encoding.Metadata` (to optionally - assist with encoding) and must return either `{:ok, encoded_value}` or - `{:error, message}`. + For example, if you specify `as: :on_off` for a writable mapping, ModBoss + will expect that the schema module defines either: + + * `encode_on_off/1` — accepts the value to encode + * `encode_on_off/2` — accepts the value to encode and a + `ModBoss.Encoding.Metadata` struct + + Use the 2-arity version when you need access to metadata (e.g. address count, + mapping name, or user-provided context). Both must return either + `{:ok, encoded_value}` or `{:error, message}`. + + The same applies to decode functions: define either `decode_on_off/1` (accepts + the encoded value) or `decode_on_off/2` (accepts the encoded value and + metadata). 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 @@ -82,14 +91,24 @@ defmodule ModBoss.Schema do input_register 200, :foo, as: {ModBoss.Encoding, :unsigned_int} coil 300, :bar, as: :on_off, mode: :rw + holding_register 301, :setpoint, as: :scaled, mode: :w discrete_input 400, :baz, as: {ModBoss.Encoding, :boolean} end - def encode_on_off(:on, _metadata), do: {:ok, 1} - def encode_on_off(:off, _metadata), do: {:ok, 0} + # 1-arity: no metadata needed + def encode_on_off(:on), do: {:ok, 1} + def encode_on_off(:off), do: {:ok, 0} def decode_on_off(1), do: {:ok, :on} def decode_on_off(0), do: {:ok, :off} + + # 2-arity: uses metadata.context for runtime behavior + def encode_scaled(value, metadata) do + case metadata.context do + %{unit: :fahrenheit} -> {:ok, round((value - 32) * 5 / 9)} + _ -> {:ok, value} + end + end end """ @@ -292,6 +311,9 @@ defmodule ModBoss.Schema do """ end + validate_local_encode_functions!(env, mappings) + validate_local_decode_functions!(env, mappings) + mappings = mappings |> Enum.reverse() @@ -310,4 +332,74 @@ defmodule ModBoss.Schema do def __modboss_schema__, do: unquote(mappings) end end + + @doc false + def validate_local_encode_functions!(env, mappings) do + mappings + |> Enum.filter(fn mapping -> + {module, _function} = mapping.as + module == env.module and Mapping.writable?(mapping) + end) + |> Enum.uniq_by(fn mapping -> mapping.as end) + |> Enum.each(fn mapping -> + {_module, as} = mapping.as + function = String.to_atom("encode_#{as}") + + has_arity_1 = Module.defines?(env.module, {function, 1}) + has_arity_2 = Module.defines?(env.module, {function, 2}) + + cond do + has_arity_1 and has_arity_2 -> + raise CompileError, + file: env.file, + line: env.line, + description: "Please define #{function}/1 or #{function}/2, but not both." + + not (has_arity_1 or has_arity_2) -> + raise CompileError, + file: env.file, + line: env.line, + description: + "Expected #{function}/1 or #{function}/2 to be defined for writable mapping #{inspect(mapping.name)}." + + true -> + :ok + end + end) + end + + @doc false + def validate_local_decode_functions!(env, mappings) do + mappings + |> Enum.filter(fn mapping -> + {module, _function} = mapping.as + module == env.module and Mapping.readable?(mapping) + end) + |> Enum.uniq_by(fn mapping -> mapping.as end) + |> Enum.each(fn mapping -> + {_module, as} = mapping.as + function = String.to_atom("decode_#{as}") + + has_arity_1 = Module.defines?(env.module, {function, 1}) + has_arity_2 = Module.defines?(env.module, {function, 2}) + + cond do + has_arity_1 and has_arity_2 -> + raise CompileError, + file: env.file, + line: env.line, + description: "Please define #{function}/1 or #{function}/2, but not both." + + not (has_arity_1 or has_arity_2) -> + raise CompileError, + file: env.file, + line: env.line, + description: + "Expected #{function}/1 or #{function}/2 to be defined for readable mapping #{inspect(mapping.name)}." + + true -> + :ok + end + end) + end end diff --git a/test/modboss/schema_test.exs b/test/modboss/schema_test.exs index 5b89951..4a3b010 100644 --- a/test/modboss/schema_test.exs +++ b/test/modboss/schema_test.exs @@ -189,6 +189,130 @@ defmodule ModBoss.SchemaTest do end end + describe "compile-time encode/decode validation" do + test "raises a compile error when a writable local mapping has no encode function at either arity" do + assert_raise CompileError, ~r/encode_missing/, fn -> + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :missing, mode: :w + end + end + """) + end + end + + test "does not raise when a writable local mapping has an arity-2 encode function" do + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :toggle, mode: :w + end + + def encode_toggle(_value, _metadata), do: {:ok, 1} + end + """) + end + + test "raises a compile error when both arity-1 and arity-2 encode functions are defined" do + assert_raise CompileError, + ~r/define encode_boolean\/1 or encode_boolean\/2, but not both/, + fn -> + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :boolean, mode: :w + end + + def encode_boolean(true, _metadata), do: {:ok, 1} + def encode_boolean(true), do: {:ok, 1} + end + """) + end + end + + test "does not raise when a writable local mapping has an arity-1 encode function" do + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :toggle, mode: :w + end + + def encode_toggle(_value), do: {:ok, 1} + end + """) + end + + test "raises a compile error when a readable local mapping has no decode function at either arity" do + assert_raise CompileError, ~r/decode_missing/, fn -> + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :missing + end + end + """) + end + end + + test "does not raise when a readable local mapping has an arity-1 decode function" do + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :toggle + end + + def decode_toggle(_value), do: {:ok, :on} + end + """) + end + + test "does not raise when a readable local mapping has an arity-2 decode function" do + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :toggle + end + + def decode_toggle(_value, _metadata), do: {:ok, :on} + end + """) + end + + test "raises a compile error when both arity-1 and arity-2 decode functions are defined" do + assert_raise CompileError, + ~r/define decode_toggle\/1 or decode_toggle\/2, but not both/, + fn -> + Code.compile_string(""" + defmodule #{unique_module()} do + use ModBoss.Schema + + schema do + holding_register 1, :foo, as: :toggle + end + + def decode_toggle(_value, _metadata), do: {:ok, :on} + def decode_toggle(_value), do: {:ok, :on} + end + """) + end + end + end + defp unique_module do "#{__MODULE__}#{System.unique_integer([:positive])}" end diff --git a/test/modboss_test.exs b/test/modboss_test.exs index c1343c1..0bfdb86 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -377,6 +377,148 @@ defmodule ModBossTest do ModBoss.read(schema, [:yep, :nope, :text], read_func(device)) end + test "supports decode functions with arity 2 (with metadata)" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :scaled + end + + def decode_scaled(value, metadata) do + # Use metadata to confirm it flows through + if metadata.name == :temp do + {:ok, value * 10} + else + {:error, "unexpected name"} + end + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 5}) + + assert {:ok, 50} = ModBoss.read(schema, :temp, read_func(device)) + end + + test "supports external decode functions with arity 2 (with metadata)" do + decoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{decoder_module} do + def decode_scaled(value, metadata) do + if metadata.name == :temp do + {:ok, value * 10} + else + {:error, "unexpected name"} + end + end + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: {#{decoder_module}, :scaled} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 5}) + + assert {:ok, 50} = ModBoss.read(schema, :temp, read_func(device)) + end + + test "supports external decode functions with arity 1 (no metadata)" do + decoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{decoder_module} do + def decode_toggle(1), do: {:ok, :on} + def decode_toggle(0), do: {:ok, :off} + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{decoder_module}, :toggle} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 1}) + + assert {:ok, :on} = ModBoss.read(schema, :switch, read_func(device)) + end + + test "returns error when external decode function has both arities" do + decoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{decoder_module} do + def decode_toggle(1, _meta), do: {:ok, :on} + def decode_toggle(1), do: {:ok, :on} + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{decoder_module}, :toggle} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 1}) + + assert {:error, message} = ModBoss.read(schema, :switch, read_func(device)) + assert message =~ "decode_toggle/1 or decode_toggle/2, but not both" + end + + test "returns error when external decode function has neither arity" do + decoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{decoder_module} do + # No decode function defined + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{decoder_module}, :toggle} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 1}) + + assert {:error, message} = ModBoss.read(schema, :switch, read_func(device)) + assert message =~ "decode_toggle" + end + test "returns an error if decoding fails" do schema = unique_module() @@ -1253,8 +1395,8 @@ defmodule ModBossTest do holding_register 3..5, :text, as: {Encoding, :ascii}, mode: :w end - def encode_boolean(false, _), do: {:ok, 0} - def encode_boolean(true, _), do: {:ok, 1} + def encode_boolean(false), do: {:ok, 0} + def encode_boolean(true), do: {:ok, 1} end """) @@ -1271,6 +1413,114 @@ defmodule ModBossTest do } = get_objects(device) end + test "supports encode functions with arity 1 (no metadata)" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :yep, as: :boolean, mode: :w + holding_register 2, :nope, as: :boolean, mode: :w + end + + def encode_boolean(true), do: {:ok, 1} + def encode_boolean(false), do: {:ok, 0} + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{yep: true, nope: false}, write_func(device)) + + assert %{ + {:holding_register, 1} => 1, + {:holding_register, 2} => 0 + } = get_objects(device) + end + + test "supports external encode functions with arity 1 (no metadata)" do + encoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{encoder_module} do + def encode_toggle(:on), do: {:ok, 1} + def encode_toggle(:off), do: {:ok, 0} + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{encoder_module}, :toggle}, mode: :w + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{switch: :on}, write_func(device)) + + assert %{{:holding_register, 1} => 1} = get_objects(device) + end + + test "returns error when external encode function has both arities" do + encoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{encoder_module} do + def encode_toggle(:on, _meta), do: {:ok, 1} + def encode_toggle(:on), do: {:ok, 1} + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{encoder_module}, :toggle}, mode: :w + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + assert {:error, message} = ModBoss.write(schema, %{switch: :on}, write_func(device)) + assert message =~ "encode_toggle/1 or encode_toggle/2, but not both" + end + + test "returns error when external encode function has neither arity" do + encoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{encoder_module} do + # No encode_toggle function defined at all + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :switch, as: {#{encoder_module}, :toggle}, mode: :w + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + assert {:error, message} = ModBoss.write(schema, %{switch: :on}, write_func(device)) + assert message =~ "encode_toggle" + end + 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}) From ea363b05bc1636cb263e290b9354670714a2208e Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Sat, 21 Mar 2026 13:05:25 -0700 Subject: [PATCH 2/4] Support passing context to encode/decode via optional metadata This allows encode/decode functions to support conditional logic at runtime, if needed. For example, based on the firmware reported by a device, etc. The supplied context will also be available in Telemetry data, and it replaces the short-lived `:label` opt since it provides equal (and more) functionality. --- lib/modboss.ex | 123 ++++++++++++-------- lib/modboss/encoding/metadata.ex | 15 ++- lib/modboss/telemetry.ex | 29 +++-- test/modboss/telemetry_test.exs | 67 +++++------ test/modboss_test.exs | 187 +++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 104 deletions(-) diff --git a/lib/modboss.ex b/lib/modboss.ex index 7940e5a..b2c156c 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -63,9 +63,12 @@ defmodule ModBoss do * `:max_attempts` — maximum number of times each `read_func` callback will be attempted before giving up. Defaults to `1` (no retries). Only `{:error, _}` triggers a retry; exceptions are not retried. - * `:telemetry_label` — an arbitrary term attached as `label` in telemetry - event metadata. Useful for identifying which device or connection a - request belongs to. See `ModBoss.Telemetry` for details. + * `:context` — a map of arbitrary data that will be included in the + `ModBoss.Encoding.Metadata` struct passed to decode functions (when using + 2-arity decoders) and in telemetry event metadata. Defaults to `%{}`. + Useful for conditionally decoding values based on runtime information + like firmware version or hardware revision, and for identifying which + device or connection a request belongs to in telemetry handlers. > #### Gaps {: .info} > @@ -144,7 +147,7 @@ defmodule ModBoss do max_attempts: 1, decode: true, debug: false, - telemetry_label: nil + context: %{} } defp evaluate_read_opts(module, name_or_names, opts) do @@ -156,7 +159,7 @@ defmodule ModBoss do end {supported_opts, unsupported_opts} = - Keyword.split(opts, [:max_gap, :max_attempts, :debug, :decode, :telemetry_label]) + Keyword.split(opts, [:max_gap, :max_attempts, :debug, :decode, :context]) if unsupported_opts != [] do raise "Unrecognized opts: #{inspect(unsupported_opts)}" @@ -168,6 +171,7 @@ defmodule ModBoss do |> Map.put(:plurality, plurality) |> Map.put(:max_gap, normalize_max_gap(supported_opts[:max_gap])) |> validate!(:max_attempts) + |> validate!(:context) {names, validated_opts} end @@ -180,7 +184,7 @@ defmodule ModBoss do if Code.ensure_loaded?(:telemetry) do defp do_reads(module, names, mappings, read_func, opts) do - start_metadata = %{schema: module, names: names} |> maybe_put_label(opts.telemetry_label) + start_metadata = %{schema: module, names: names, context: opts.context} :telemetry.span([:modboss, :read], start_metadata, fn -> {result, stats} = read_mappings(module, mappings, read_func, opts) @@ -198,9 +202,6 @@ defmodule ModBoss do {result, stop_measurements, stop_metadata} end) end - - defp maybe_put_label(metadata, nil), do: metadata - defp maybe_put_label(metadata, label), do: Map.put(metadata, :label, label) else defp do_reads(module, _names, mappings, read_func, opts) do {result, _} = read_mappings(module, mappings, read_func, opts) @@ -273,15 +274,14 @@ defmodule ModBoss do max_attempts = opts.max_attempts fn type, starting_address, address_count -> - metadata = - %{ - schema: module, - names: names, - object_type: type, - starting_address: starting_address, - address_count: address_count - } - |> maybe_put_label(opts.telemetry_label) + metadata = %{ + schema: module, + names: names, + object_type: type, + starting_address: starting_address, + address_count: address_count, + context: opts.context + } retry(max_attempts, fn attempt -> start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts}) @@ -384,20 +384,41 @@ defmodule ModBoss do Returns a map with keys of the form `{type, address}` and `encoded_value` as values. + ## Opts + * `:context` — a map of arbitrary data that will be included in the + `ModBoss.Encoding.Metadata` struct passed to encode functions (when using + 2-arity encoders). Defaults to `%{}`. + ## Example iex> ModBoss.encode(MyDevice.Schema, foo: "Yay") {:ok, %{{:holding_register, 15} => 22881, {:holding_register, 16} => 30976}} """ - @spec encode(module(), values()) :: {:ok, map()} | {:error, String.t()} - def encode(module, values) when is_atom(module) do + @spec encode(module(), values(), keyword()) :: {:ok, map()} | {:error, String.t()} + def encode(module, values, opts \\ []) when is_atom(module) do + opts = evaluate_encode_opts(opts) + with {:ok, mappings} <- get_mappings(:any, module, get_keys(values)), mappings <- put_values(mappings, values), - {:ok, mappings} <- encode(mappings) do + {:ok, mappings} <- encode_mappings(mappings, opts.context) do {:ok, flatten_encoded_values(mappings)} end end + @default_encode_opts %{context: %{}} + + defp evaluate_encode_opts(opts) do + {opts, remaining_opts} = Keyword.split(opts, [:context]) + + if remaining_opts != [] do + raise "Unrecognized opts: #{inspect(remaining_opts)}" + end + + opts + |> Enum.into(@default_encode_opts) + |> validate!(:context) + end + defp flatten_encoded_values(mappings) do mappings |> Enum.flat_map(fn %Mapping{} = mapping -> @@ -439,9 +460,12 @@ defmodule ModBoss do * `:max_attempts` — maximum number of times each `write_func` callback will be attempted before giving up. Defaults to `1` (no retries). Only `{:error, _}` triggers a retry; exceptions are not retried. - * `:telemetry_label` — an arbitrary term attached as `label` in telemetry - event metadata. Useful for identifying which device or connection a - request belongs to. See `ModBoss.Telemetry` for details. + * `:context` — a map of arbitrary data that will be included in the + `ModBoss.Encoding.Metadata` struct passed to encode functions (when using + 2-arity encoders) and in telemetry event metadata. Defaults to `%{}`. + Useful for conditionally encoding values based on runtime information + like firmware version or hardware revision, and for identifying which + device or connection a request belongs to in telemetry handlers. ## Example @@ -461,7 +485,7 @@ defmodule ModBoss do with {:ok, mappings} <- get_mappings(:writable, module, names), mappings <- put_values(mappings, values), - {:ok, mappings} <- encode(mappings) do + {:ok, mappings} <- encode_mappings(mappings, opts.context) do do_writes(module, names, mappings, write_func, opts) end end @@ -469,10 +493,10 @@ defmodule ModBoss do defp get_keys(params) when is_map(params), do: Map.keys(params) defp get_keys(params) when is_list(params), do: Keyword.keys(params) - @default_write_opts %{max_attempts: 1, telemetry_label: nil} + @default_write_opts %{max_attempts: 1, context: %{}} defp evaluate_write_opts(opts) do - {opts, remaining_opts} = Keyword.split(opts, [:max_attempts, :telemetry_label]) + {opts, remaining_opts} = Keyword.split(opts, [:max_attempts, :context]) if remaining_opts != [] do raise "Unrecognized opts: #{inspect(remaining_opts)}" @@ -481,6 +505,7 @@ defmodule ModBoss do opts |> Enum.into(@default_write_opts) |> validate!(:max_attempts) + |> validate!(:context) end defp put_values(mappings, params) do @@ -521,7 +546,7 @@ defmodule ModBoss do if Code.ensure_loaded?(:telemetry) do defp do_writes(module, names, mappings, write_func, opts) do - start_metadata = %{schema: module, names: names} |> maybe_put_label(opts.telemetry_label) + start_metadata = %{schema: module, names: names, context: opts.context} :telemetry.span([:modboss, :write], start_metadata, fn -> {result, stats} = write_mappings(module, mappings, write_func, opts) @@ -582,15 +607,14 @@ defmodule ModBoss do max_attempts = opts.max_attempts fn type, starting_address, value_or_values -> - metadata = - %{ - schema: module, - names: names, - object_type: type, - starting_address: starting_address, - address_count: address_count - } - |> maybe_put_label(opts.telemetry_label) + metadata = %{ + schema: module, + names: names, + object_type: type, + starting_address: starting_address, + address_count: address_count, + context: opts.context + } retry(max_attempts, fn attempt -> start_metadata = Map.merge(metadata, %{attempt: attempt, max_attempts: max_attempts}) @@ -624,6 +648,7 @@ defmodule ModBoss do end defp validate!(%{max_attempts: a} = opts, :max_attempts) when is_integer(a) and a >= 1, do: opts + defp validate!(%{context: c} = opts, :context) when is_map(c), do: opts defp validate!(opts, opt), do: raise("Invalid option #{inspect([{opt, opts[opt]}])}.") @spec chunk_mappings([Mapping.t()], module(), :read | :write, map()) :: @@ -736,9 +761,9 @@ defmodule ModBoss do } end - defp encode(mappings) do + defp encode_mappings(mappings, context) do Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} -> - case encode_value(mapping) do + case encode_value(mapping, context) do {:ok, encoded_value} -> updated_mapping = %{mapping | encoded_value: encoded_value} {:cont, {:ok, [updated_mapping | acc]}} @@ -756,15 +781,15 @@ defmodule ModBoss do end) end - defp encode_value(%Mapping{} = mapping) do - with {module, function, args} <- get_encode_mfa(mapping), + defp encode_value(%Mapping{} = mapping, context) do + with {module, function, args} <- get_encode_mfa(mapping, context), {:ok, encoded} <- apply(module, function, args), :ok <- verify_value_count(mapping, encoded) do {:ok, encoded} end end - defp get_encode_mfa(%Mapping{as: {module, as}} = mapping) do + defp get_encode_mfa(%Mapping{as: {module, as}} = mapping, context) do function = String.to_atom("encode_#{as}") has_arity_1 = exists?(module, function, 1) @@ -775,7 +800,7 @@ defmodule ModBoss do {:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."} has_arity_2 -> - metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) + metadata = ModBoss.Encoding.Metadata.from_mapping(mapping, context) {module, function, [mapping.value, metadata]} has_arity_1 -> @@ -790,9 +815,9 @@ defmodule ModBoss do @spec maybe_decode([Mapping.t()], map()) :: {:ok, [Mapping.t()]} | {:error, String.t()} defp maybe_decode(mappings, %{decode: false}), do: {:ok, mappings} - defp maybe_decode(mappings, %{decode: true}) do + defp maybe_decode(mappings, %{decode: true} = opts) do Enum.reduce_while(mappings, {:ok, []}, fn mapping, {:ok, acc} -> - case decode_value(mapping) do + case decode_value(mapping, opts.context) do {:ok, decoded_value} -> updated_mapping = %{mapping | value: decoded_value} {:cont, {:ok, [updated_mapping | acc]}} @@ -810,13 +835,13 @@ defmodule ModBoss do end) end - defp decode_value(%Mapping{} = mapping) do - with {module, function, args} <- get_decode_mfa(mapping) do + defp decode_value(%Mapping{} = mapping, context) do + with {module, function, args} <- get_decode_mfa(mapping, context) do apply(module, function, args) end end - defp get_decode_mfa(%Mapping{as: {module, as}} = mapping) do + defp get_decode_mfa(%Mapping{as: {module, as}} = mapping, context) do function = String.to_atom("decode_#{as}") has_arity_1 = exists?(module, function, 1) @@ -827,7 +852,7 @@ defmodule ModBoss do {:error, "Please define #{inspect(module)}.#{function}/1 or #{function}/2, but not both."} has_arity_2 -> - metadata = ModBoss.Encoding.Metadata.from_mapping(mapping) + metadata = ModBoss.Encoding.Metadata.from_mapping(mapping, context) {module, function, [mapping.encoded_value, metadata]} has_arity_1 -> diff --git a/lib/modboss/encoding/metadata.ex b/lib/modboss/encoding/metadata.ex index f0a38e7..1e96a90 100644 --- a/lib/modboss/encoding/metadata.ex +++ b/lib/modboss/encoding/metadata.ex @@ -11,6 +11,9 @@ defmodule ModBoss.Encoding.Metadata do * `:name` — the mapping name (e.g. `:outdoor_temp`) * `:type` — the object type (e.g. `:holding_register`) * `:address_count` — the number of addresses in the mapping + * `:context` — user-provided context map, passed via the `:context` option + on `ModBoss.read/4`, `ModBoss.write/4`, or `ModBoss.encode/3`. + Defaults to `%{}`. For example, when encoding ASCII text, `address_count` tells you how many registers to pad into. See the implementation of @@ -22,13 +25,17 @@ defmodule ModBoss.Encoding.Metadata do @type t() :: %__MODULE__{ name: Mapping.name(), type: Mapping.object_type(), - address_count: Mapping.count() + address_count: Mapping.count(), + context: map() } - defstruct [:name, :type, :address_count] + defstruct [:name, :type, :address_count, context: %{}] @doc false - def from_mapping(%Mapping{} = mapping) do - struct!(__MODULE__, Map.take(mapping, [:name, :type, :address_count])) + def from_mapping(%Mapping{} = mapping, context \\ %{}) do + mapping + |> Map.take([:name, :type, :address_count]) + |> Map.put(:context, context) + |> then(&struct!(__MODULE__, &1)) end end diff --git a/lib/modboss/telemetry.ex b/lib/modboss/telemetry.ex index c4abfe7..e06a5ec 100644 --- a/lib/modboss/telemetry.ex +++ b/lib/modboss/telemetry.ex @@ -38,7 +38,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term() + context: map() } ### Read Stop @@ -61,7 +61,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), result: term() } @@ -79,7 +79,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), kind: atom(), reason: term(), stacktrace: list() @@ -99,7 +99,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term() + context: map() } ### Write Stop @@ -119,7 +119,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), result: term() } @@ -137,7 +137,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), kind: atom(), reason: term(), stacktrace: list() @@ -165,7 +165,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -189,7 +189,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -212,7 +212,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -237,7 +237,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -259,7 +259,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -282,7 +282,7 @@ defmodule ModBoss.Telemetry do %{ schema: module(), names: [atom()], - label: term(), + context: map(), object_type: atom(), starting_address: non_neg_integer(), address_count: pos_integer(), @@ -323,9 +323,8 @@ defmodule ModBoss.Telemetry do * `schema` — the schema module (e.g. `MyDevice.Schema`). * `names` — mapping name(s) as a list of atoms. On per-operation events, all requested names; on per-callback events, only the names in that batch. - * `label` — the value of the `:telemetry_label` option passed to - `ModBoss.read/4` or `ModBoss.write/4`. Can be any term — an atom, tuple, map, etc. - _Only present when the `:telemetry_label` option is provided._ + * `context` — the value of the `:context` option passed to `ModBoss.read/4` + or `ModBoss.write/4`. Always present; defaults to `%{}` when not provided. * `result` — the raw result: `{:ok, value}` or `{:error, reason}` for reads; `:ok` or `{:error, reason}` for writes. * `object_type` — `:holding_register`, `:input_register`, `:coil`, or `:discrete_input`. diff --git a/test/modboss/telemetry_test.exs b/test/modboss/telemetry_test.exs index e03d899..c89c254 100644 --- a/test/modboss/telemetry_test.exs +++ b/test/modboss/telemetry_test.exs @@ -425,41 +425,40 @@ defmodule ModBoss.TelemetryTest do refute_receive {:telemetry, [:modboss, :read_callback, :start], _, _} end - test "includes label in all event metadata when telemetry_label is provided", %{ - device: device - } do + test "includes context in all event metadata when context is provided", %{device: device} do set_objects(device, %{{:holding_register, 1} => 42}) - {:ok, 42} = ModBoss.read(TestSchema, :foo, read_func(device), telemetry_label: :foo_label) + {:ok, 42} = + ModBoss.read(TestSchema, :foo, read_func(device), context: %{firmware: "2.0"}) assert_receive {:telemetry, [:modboss, :read, :start], _, start_metadata} - assert start_metadata.label == :foo_label + assert start_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :read, :stop], _, stop_metadata} - assert stop_metadata.label == :foo_label + assert stop_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :read_callback, :start], _, cb_start_metadata} - assert cb_start_metadata.label == :foo_label + assert cb_start_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :read_callback, :stop], _, cb_stop_metadata} - assert cb_stop_metadata.label == :foo_label + assert cb_stop_metadata.context == %{firmware: "2.0"} end - test "includes label in exception metadata when telemetry_label is provided" do + test "includes context in exception metadata" do boom_func = fn _type, _addr, _count -> raise "boom!" end assert_raise RuntimeError, "boom!", fn -> - ModBoss.read(TestSchema, :foo, boom_func, telemetry_label: :my_device) + ModBoss.read(TestSchema, :foo, boom_func, context: %{firmware: "2.0"}) end assert_receive {:telemetry, [:modboss, :read, :exception], _, meta} - assert meta.label == :my_device + assert meta.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :read_callback, :exception], _, cb_meta} - assert cb_meta.label == :my_device + assert cb_meta.context == %{firmware: "2.0"} end - test "does not include label key in metadata when telemetry_label is not provided", %{ + test "includes empty context in all event metadata when context is not provided", %{ device: device } do set_objects(device, %{{:holding_register, 1} => 42}) @@ -467,16 +466,16 @@ defmodule ModBoss.TelemetryTest do {:ok, 42} = ModBoss.read(TestSchema, :foo, read_func(device)) assert_receive {:telemetry, [:modboss, :read, :start], _, metadata} - refute Map.has_key?(metadata, :label) + assert metadata.context == %{} assert_receive {:telemetry, [:modboss, :read, :stop], _, stop_metadata} - refute Map.has_key?(stop_metadata, :label) + assert stop_metadata.context == %{} assert_receive {:telemetry, [:modboss, :read_callback, :start], _, cb_metadata} - refute Map.has_key?(cb_metadata, :label) + assert cb_metadata.context == %{} assert_receive {:telemetry, [:modboss, :read_callback, :stop], _, cb_stop_metadata} - refute Map.has_key?(cb_stop_metadata, :label) + assert cb_stop_metadata.context == %{} end end @@ -695,57 +694,53 @@ defmodule ModBoss.TelemetryTest do refute_receive {:telemetry, [:modboss, :write_callback, :start], _, _} end - test "includes label in all event metadata when telemetry_label is provided", %{ - device: device - } do + test "includes context in all event metadata when context is provided", %{device: device} do :ok = - ModBoss.write(TestSchema, [baz: 99], write_func(device), - telemetry_label: %{port: :rs485, address: 12} - ) + ModBoss.write(TestSchema, [baz: 99], write_func(device), context: %{firmware: "2.0"}) assert_receive {:telemetry, [:modboss, :write, :start], _, start_metadata} - assert start_metadata.label == %{port: :rs485, address: 12} + assert start_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :write, :stop], _, stop_metadata} - assert stop_metadata.label == %{port: :rs485, address: 12} + assert stop_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :write_callback, :start], _, cb_start_metadata} - assert cb_start_metadata.label == %{port: :rs485, address: 12} + assert cb_start_metadata.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :write_callback, :stop], _, cb_stop_metadata} - assert cb_stop_metadata.label == %{port: :rs485, address: 12} + assert cb_stop_metadata.context == %{firmware: "2.0"} end - test "includes label in exception metadata when telemetry_label is provided" do + test "includes context in exception metadata" do kaboom_func = fn _type, _addr, _values -> raise "kaboom!" end assert_raise RuntimeError, "kaboom!", fn -> - ModBoss.write(TestSchema, [baz: 1], kaboom_func, telemetry_label: :my_device) + ModBoss.write(TestSchema, [baz: 1], kaboom_func, context: %{firmware: "2.0"}) end assert_receive {:telemetry, [:modboss, :write, :exception], _, meta} - assert meta.label == :my_device + assert meta.context == %{firmware: "2.0"} assert_receive {:telemetry, [:modboss, :write_callback, :exception], _, cb_meta} - assert cb_meta.label == :my_device + assert cb_meta.context == %{firmware: "2.0"} end - test "does not include label key in metadata when telemetry_label is not provided", %{ + test "includes empty context in all event metadata when context is not provided", %{ device: device } do :ok = ModBoss.write(TestSchema, [baz: 99], write_func(device)) assert_receive {:telemetry, [:modboss, :write, :start], _, metadata} - refute Map.has_key?(metadata, :label) + assert metadata.context == %{} assert_receive {:telemetry, [:modboss, :write, :stop], _, stop_metadata} - refute Map.has_key?(stop_metadata, :label) + assert stop_metadata.context == %{} assert_receive {:telemetry, [:modboss, :write_callback, :start], _, cb_metadata} - refute Map.has_key?(cb_metadata, :label) + assert cb_metadata.context == %{} assert_receive {:telemetry, [:modboss, :write_callback, :stop], _, cb_stop_metadata} - refute Map.has_key?(cb_stop_metadata, :label) + assert cb_stop_metadata.context == %{} end end diff --git a/test/modboss_test.exs b/test/modboss_test.exs index 0bfdb86..ef13805 100644 --- a/test/modboss_test.exs +++ b/test/modboss_test.exs @@ -519,6 +519,59 @@ defmodule ModBossTest do assert message =~ "decode_toggle" end + test "context option flows through to decode metadata" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :contextual + end + + def decode_contextual(value, metadata) do + case metadata.context do + %{unit: :celsius} -> {:ok, value} + %{unit: :fahrenheit} -> {:ok, value * 9 / 5 + 32} + end + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 100}) + + assert {:ok, 100} = + ModBoss.read(schema, :temp, read_func(device), context: %{unit: :celsius}) + + assert {:ok, 212.0} = + ModBoss.read(schema, :temp, read_func(device), context: %{unit: :fahrenheit}) + end + + test "context defaults to an empty map in decode metadata when not provided" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :check_context + end + + def decode_check_context(value, metadata) do + {:ok, {value, metadata.context}} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + set_objects(device, %{{:holding_register, 1} => 42}) + + assert {:ok, {42, %{}}} = ModBoss.read(schema, :temp, read_func(device)) + end + test "returns an error if decoding fails" do schema = unique_module() @@ -1413,6 +1466,114 @@ defmodule ModBossTest do } = get_objects(device) end + test "context option flows through to encode metadata" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :contextual, mode: :w + end + + def encode_contextual(value, metadata) do + case metadata.context do + %{unit: :celsius} -> {:ok, value} + %{unit: :fahrenheit} -> {:ok, round((value - 32) * 5 / 9)} + end + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{temp: 212}, write_func(device), context: %{unit: :fahrenheit}) + assert %{{:holding_register, 1} => 100} = get_objects(device) + end + + test "context defaults to an empty map in encode metadata when not provided" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :check_context, mode: :w + end + + def encode_check_context(value, metadata) do + {:ok, {value, metadata.context}} + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{temp: 42}, write_func(device)) + assert %{{:holding_register, 1} => {42, %{}}} = get_objects(device) + end + + test "supports encode functions with arity 2 (with metadata)" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :scaled, mode: :w + end + + def encode_scaled(value, metadata) do + if metadata.name == :temp do + {:ok, value * 10} + else + {:error, "unexpected name"} + end + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{temp: 5}, write_func(device)) + assert %{{:holding_register, 1} => 50} = get_objects(device) + end + + test "supports external encode functions with arity 2 (with metadata)" do + encoder_module = unique_module() + schema = unique_module() + + Code.compile_string(""" + defmodule #{encoder_module} do + def encode_scaled(value, metadata) do + if metadata.name == :temp do + {:ok, value * 10} + else + {:error, "unexpected name"} + end + end + end + """) + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: {#{encoder_module}, :scaled}, mode: :w + end + end + """) + + device = start_supervised!({Agent, fn -> @initial_state end}) + + :ok = ModBoss.write(schema, %{temp: 5}, write_func(device)) + assert %{{:holding_register, 1} => 50} = get_objects(device) + end + test "supports encode functions with arity 1 (no metadata)" do schema = unique_module() @@ -1797,6 +1958,32 @@ defmodule ModBossTest do }) end + test "context option flows through to encode metadata" do + schema = unique_module() + + Code.compile_string(""" + defmodule #{schema} do + use ModBoss.Schema + + schema do + holding_register 1, :temp, as: :contextual, mode: :rw + end + + def encode_contextual(value, metadata) do + case metadata.context do + %{unit: :fahrenheit} -> {:ok, round((value - 32) * 5 / 9)} + _ -> {:ok, value} + end + end + + def decode_contextual(value), do: {:ok, value} + end + """) + + assert {:ok, %{{:holding_register, 1} => 100}} = + ModBoss.encode(schema, [temp: 212], context: %{unit: :fahrenheit}) + end + test "returns an error if any mapping names are unrecognized" do schema = unique_module() From 2223663c251bc91f8844ebf12378e7016165f78f Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Sat, 21 Mar 2026 13:51:36 -0700 Subject: [PATCH 3/4] Use 1-arity encode functions where possible --- lib/modboss/encoding.ex | 34 ++++++++++++++++------------------ test/modboss/encoding_test.exs | 32 ++++++++++++++++---------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/lib/modboss/encoding.ex b/lib/modboss/encoding.ex index 1b22d1d..d974267 100644 --- a/lib/modboss/encoding.ex +++ b/lib/modboss/encoding.ex @@ -19,7 +19,7 @@ defmodule ModBoss.Encoding do alias ModBoss.Encoding.Metadata @doc false - def encode_raw(value_or_values, _), do: {:ok, value_or_values} + def encode_raw(value_or_values), do: {:ok, value_or_values} @doc false def decode_raw(value_or_values), do: {:ok, value_or_values} @@ -29,15 +29,15 @@ defmodule ModBoss.Encoding do ## Examples - iex> encode_boolean(true, %{}) + iex> encode_boolean(true) {:ok, 1} - iex> encode_boolean(false, %{}) + iex> encode_boolean(false) {:ok, 0} """ - @spec encode_boolean(boolean(), Metadata.t()) :: {:ok, integer()} | {:error, binary()} - def encode_boolean(true, _metadata), do: {:ok, 1} - def encode_boolean(false, _metadata), do: {:ok, 0} + @spec encode_boolean(boolean()) :: {:ok, integer()} | {:error, binary()} + def encode_boolean(true), do: {:ok, 1} + def encode_boolean(false), do: {:ok, 0} @doc """ Interpret `1` as `true` and `0` as `false` @@ -64,16 +64,15 @@ defmodule ModBoss.Encoding do ## Examples - iex> {:ok, 65_535} = encode_unsigned_int(65_535, %{}) - iex> {:error, _too_large} = encode_unsigned_int(65_536, %{}) + iex> {:ok, 65_535} = encode_unsigned_int(65_535) + iex> {:error, _too_large} = encode_unsigned_int(65_536) """ - @spec encode_unsigned_int(non_neg_integer(), Metadata.t()) :: - {:ok, non_neg_integer()} | {:error, binary()} - def encode_unsigned_int(value, _metadata) when value >= 0 and value <= 65_535 do + @spec encode_unsigned_int(non_neg_integer()) :: {:ok, non_neg_integer()} | {:error, binary()} + def encode_unsigned_int(value) when value >= 0 and value <= 65_535 do {:ok, value} end - def encode_unsigned_int(value, _metadata) do + def encode_unsigned_int(value) do {:error, "Value #{value} is outside the range of a 16-bit unsigned integer (0 to 65,535)"} end @@ -101,16 +100,15 @@ defmodule ModBoss.Encoding do ## Examples - iex> {:ok, -32_768} = encode_signed_int(-32_768, %{}) - iex> {:error, _too_large} = encode_signed_int(32_768, %{}) + iex> {:ok, -32_768} = encode_signed_int(-32_768) + iex> {:error, _too_large} = encode_signed_int(32_768) """ - @spec encode_signed_int(integer(), Metadata.t()) :: {:ok, integer()} | {:error, binary()} - def encode_signed_int(value, _metadata) - when is_integer(value) and value >= -32768 and value <= 32767 do + @spec encode_signed_int(integer()) :: {:ok, integer()} | {:error, binary()} + def encode_signed_int(value) when is_integer(value) and value >= -32768 and value <= 32767 do {:ok, value} end - def encode_signed_int(value, _metadata) do + def encode_signed_int(value) do {:error, "Value #{value} is outside the range of a 16-bit signed integer (-32768 to 32767)"} end diff --git a/test/modboss/encoding_test.exs b/test/modboss/encoding_test.exs index 3e7a388..70db179 100644 --- a/test/modboss/encoding_test.exs +++ b/test/modboss/encoding_test.exs @@ -7,9 +7,9 @@ defmodule ModBoss.EncodingTest do describe "raw" do test "doesn't change the value when encoding" do - assert Encoding.encode_raw(123, %Metadata{}) == {:ok, 123} - assert Encoding.encode_raw(:abc, %Metadata{}) == {:ok, :abc} - assert Encoding.encode_raw([1, 2, 3], %Metadata{}) == {:ok, [1, 2, 3]} + assert Encoding.encode_raw(123) == {:ok, 123} + assert Encoding.encode_raw(:abc) == {:ok, :abc} + assert Encoding.encode_raw([1, 2, 3]) == {:ok, [1, 2, 3]} end test "doesn't change the value when decoding" do @@ -21,8 +21,8 @@ defmodule ModBoss.EncodingTest do describe "boolean" do test "encodes true and 1 and false as 0" do - assert Encoding.encode_boolean(true, %Metadata{}) == {:ok, 1} - assert Encoding.encode_boolean(false, %Metadata{}) == {:ok, 0} + assert Encoding.encode_boolean(true) == {:ok, 1} + assert Encoding.encode_boolean(false) == {:ok, 0} end test "decodes 1 as true and 0 as false" do @@ -33,14 +33,14 @@ defmodule ModBoss.EncodingTest do describe "unsigned_int" do test "returns the value if within the range for 16 bits when encoding" do - assert Encoding.encode_unsigned_int(0, %Metadata{}) == {:ok, 0} - assert Encoding.encode_unsigned_int(65535, %Metadata{}) == {:ok, 65535} + assert Encoding.encode_unsigned_int(0) == {:ok, 0} + assert Encoding.encode_unsigned_int(65535) == {:ok, 65535} end test "returns an error for out-of-range values when encoding" do - assert {:error, _} = Encoding.encode_unsigned_int(-1, %Metadata{}) - assert {:error, _} = Encoding.encode_unsigned_int(65536, %Metadata{}) - assert {:error, _} = Encoding.encode_unsigned_int(:foo, %Metadata{}) + assert {:error, _} = Encoding.encode_unsigned_int(-1) + assert {:error, _} = Encoding.encode_unsigned_int(65536) + assert {:error, _} = Encoding.encode_unsigned_int(:foo) end test "returns the value when decoding" do @@ -51,7 +51,7 @@ defmodule ModBoss.EncodingTest do test "roundtrips as expected" do [0, 65535] |> Enum.each(fn number -> - {:ok, encoded} = Encoding.encode_unsigned_int(number, %Metadata{}) + {:ok, encoded} = Encoding.encode_unsigned_int(number) {:ok, decoded} = Encoding.decode_unsigned_int(encoded) assert number == decoded end) @@ -60,13 +60,13 @@ defmodule ModBoss.EncodingTest do describe "signed_int" do test "returns the value if within the range for 16 bits when encoding" do - assert {:ok, -32768} = Encoding.encode_signed_int(-32768, %Metadata{}) - assert {:ok, 32767} = Encoding.encode_signed_int(32767, %Metadata{}) + assert {:ok, -32768} = Encoding.encode_signed_int(-32768) + assert {:ok, 32767} = Encoding.encode_signed_int(32767) end test "returns error for out-of-range signed integers when encoding" do - assert {:error, _} = Encoding.encode_signed_int(-32769, %Metadata{}) - assert {:error, _} = Encoding.encode_signed_int(32768, %Metadata{}) + assert {:error, _} = Encoding.encode_signed_int(-32769) + assert {:error, _} = Encoding.encode_signed_int(32768) end test "interprets the value as a signed int when decoding" do @@ -78,7 +78,7 @@ defmodule ModBoss.EncodingTest do test "roundtrips as expected" do [-32768, 0, 32767] |> Enum.each(fn initial -> - {:ok, encoded} = Encoding.encode_signed_int(initial, %Metadata{}) + {:ok, encoded} = Encoding.encode_signed_int(initial) {:ok, decoded} = Encoding.decode_signed_int(encoded) assert initial == decoded end) From f4a84f9f0ce8a32517718efb060f86c4bfceb7c8 Mon Sep 17 00:00:00 2001 From: Ben Coppock Date: Sat, 21 Mar 2026 13:52:04 -0700 Subject: [PATCH 4/4] Hide Schema functions that aren't meant for public consumption --- lib/modboss/schema.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/modboss/schema.ex b/lib/modboss/schema.ex index c0deb65..7b82ed2 100644 --- a/lib/modboss/schema.ex +++ b/lib/modboss/schema.ex @@ -238,6 +238,7 @@ defmodule ModBoss.Schema do end end + @doc false def validate_name!(%Macro.Env{file: file, line: line}, :all) do raise CompileError, file: file, @@ -247,6 +248,7 @@ defmodule ModBoss.Schema do def validate_name!(_env, _name), do: :ok + @doc false def create_mapping(module, object_type, address_or_range, name, opts) do if not Module.has_attribute?(module, :modboss_mappings) do raise """