diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0308e..16af00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.2.0] + +### Added + +- Add BEAM telemetry support via optional `:telemetry` dependency. +- Enable improved read batching via `:max_gap` option at runtime. +- Add compile time `gap_safe: false` option to flag mappings as ineligible for gap reads. +- Add `:max_attempts` option to automatically retry failed read/write callbacks. +- Support optional 2-arity encode/decode functions which receive + `%ModBoss.Encoding.Metadata{}` struct as the second argument. +- Add `:context` option to `ModBoss.read/4`, `ModBoss.encode/3`, and `ModBoss.write/4` + for passing arbitrary contextual info to encode/decode functions and telemetry metadata. +- Add `debug: true` option to `ModBoss.read/4` which returns detailed mapping info + alongside values. + +### Changed + +- Rename `modbus_schema` macro to `schema`. +- Swap the order of the 2nd and 3rd arguments to `ModBoss.read/4` and `ModBoss.write/4` to + better align with Elixir conventions and improve readability of pipelines. + +### Fixed + +- Return an error tuple when a decode function returns an error (previously would crash + with a match error). +- Skip decoding of Mapping when `decode: false` is passed to `ModBoss.read/4`. Previously, this + opt caused us to return the encoded version, but we would still decode under the hood. + This meant if there was a bug in the decoding logic, `ModBoss.read/4` would fail even if + `decode: false` had been passed. This fix improves debuggability. + ## [v0.1.1] ### Added diff --git a/README.md b/README.md index 213ed1e..0a370d0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ by adding `modboss` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:modboss, "~> 0.1.0"} + {:modboss, "~> 0.2.0"} ] end ``` @@ -50,10 +50,9 @@ defmodule MyDevice.Schema do holding_register 1, :outdoor_temp, as: {ModBoss.Encoding, :signed_int} holding_register 2..5, :model_name, as: {ModBoss.Encoding, :ascii} holding_register 6, :version, as: :fw_version, mode: :rw - # Also supports: input_register / coil / discrete_input end - def encode_fw_version(value, _metadata) do + def encode_fw_version(value) do encoded_value = do_encode(value) {:ok, encoded_value} end @@ -92,8 +91,8 @@ end ``` 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}`. +`:coil`), the starting address for the batch to be written, and a single value or list of values +to write. It must return either `:ok` or `{:error, message}`. ```elixir write_func = fn object_type, starting_address, value_or_values -> @@ -151,7 +150,7 @@ reads and writes. To enable telemetry, add `:telemetry` to your dependencies: ```elixir def deps do [ - {:modboss, "~> 0.1.0"}, + # …other deps {:telemetry, "~> 1.0"} ] end diff --git a/lib/modboss.ex b/lib/modboss.ex index b2c156c..feddab2 100644 --- a/lib/modboss.ex +++ b/lib/modboss.ex @@ -191,10 +191,10 @@ defmodule ModBoss do stop_measurements = %{ objects_requested: stats.objects, - modbus_requests: stats.requests, + batches: stats.batches, addresses_read: stats.addresses, gap_addresses_read: stats.gap_addresses, - max_gap_size: stats.largest_gap, + largest_gap: stats.largest_gap, total_attempts: stats.total_attempts } @@ -228,7 +228,7 @@ defmodule ModBoss do defp read_chunks(chunks, module, read_func, opts) do initial_stats = %{ objects: 0, - requests: 0, + batches: 0, addresses: 0, gap_addresses: 0, largest_gap: 0, @@ -255,7 +255,7 @@ defmodule ModBoss do updated_stats = %{ objects: stats.objects + object_count, - requests: stats.requests + 1, + batches: stats.batches + 1, addresses: stats.addresses + address_count, gap_addresses: stats.gap_addresses + gap_addresses, largest_gap: max(stats.largest_gap, largest_gap), @@ -288,7 +288,7 @@ defmodule ModBoss do :telemetry.span([:modboss, :read_callback], start_metadata, fn -> result = read_func.(type, starting_address, address_count) - stop_measurements = %{gap_addresses_read: gap_addresses, max_gap_size: largest_gap} + stop_measurements = %{gap_addresses_read: gap_addresses, largest_gap: largest_gap} stop_metadata = Map.put(start_metadata, :result, result) {result, stop_measurements, stop_metadata} @@ -553,7 +553,7 @@ defmodule ModBoss do stop_measurements = %{ objects_requested: stats.objects, - modbus_requests: stats.requests, + batches: stats.batches, total_attempts: stats.total_attempts } @@ -569,7 +569,7 @@ defmodule ModBoss do end defp write_mappings(module, mappings, write_func, opts) do - initial_stats = %{objects: 0, requests: 0, total_attempts: 0} + initial_stats = %{objects: 0, batches: 0, total_attempts: 0} mappings |> chunk_mappings(module, :write, opts) @@ -591,7 +591,7 @@ defmodule ModBoss do updated_stats = %{ objects: stats.objects + address_count, - requests: stats.requests + 1, + batches: stats.batches + 1, total_attempts: stats.total_attempts + attempts } diff --git a/lib/modboss/telemetry.ex b/lib/modboss/telemetry.ex index e06a5ec..353fe7d 100644 --- a/lib/modboss/telemetry.ex +++ b/lib/modboss/telemetry.ex @@ -49,12 +49,12 @@ defmodule ModBoss.Telemetry do %{ duration: integer(), monotonic_time: integer(), - modbus_requests: non_neg_integer(), + batches: non_neg_integer(), total_attempts: pos_integer(), objects_requested: non_neg_integer(), addresses_read: non_neg_integer(), gap_addresses_read: non_neg_integer(), - max_gap_size: non_neg_integer() + largest_gap: non_neg_integer() } # Metadata @@ -110,7 +110,7 @@ defmodule ModBoss.Telemetry do %{ duration: integer(), monotonic_time: integer(), - modbus_requests: non_neg_integer(), + batches: non_neg_integer(), total_attempts: pos_integer(), objects_requested: non_neg_integer() } @@ -182,7 +182,7 @@ defmodule ModBoss.Telemetry do duration: integer(), monotonic_time: integer(), gap_addresses_read: non_neg_integer(), - max_gap_size: non_neg_integer() + largest_gap: non_neg_integer() } # Metadata @@ -297,26 +297,25 @@ defmodule ModBoss.Telemetry do * `duration` — elapsed time in native time units. Convert with `System.convert_time_unit(duration, :native, :millisecond)`. - * `modbus_requests` — number of batches for the operation. Each contiguous - address range of one object type is one request. Note that this _does not_ - account for retries. + * `batches` — number of batches attempted for the operation. Each contiguous + address range of one object type is one batch. Does not account for retries. * `total_attempts` — total number of `read_func`/`write_func` invocations, - including retries. Equal to `modbus_requests` when no retries were needed. - The difference of `total_attempts - modbus_requests` is the number of retries. + including retries. Equal to `batches` when no retries were needed. + The difference of `total_attempts - batches` is the number of retries. * `objects_requested` — total Modbus objects (registers/coils) covered by attempted callbacks. * `addresses_read` — total addresses attempted on the wire, including gap addresses (read operations only). * `gap_addresses_read` — gap addresses read and discarded (read events only). - * `max_gap_size` — largest address gap bridged (read events only). + * `largest_gap` — largest address gap bridged (read events only). > #### Partial failures {: .info} > > When an operation requires multiple callbacks and one fails partway through, > measurements reflect what was **attempted** (including the failed callback), - > not what was planned. For example, if a read batches into 3 callbacks and - > the 2nd fails, `modbus_requests` will be `2`, and `objects_requested` and - > `addresses_read` will cover only the first two batches. + > not what was planned. For example, if a read groups into 3 address batches + > and the 2nd fails, `batches` will be `2`, and `objects_requested` and + > `addresses_read` will cover only those first 2 batches. ## Metadata details diff --git a/mix.exs b/mix.exs index 5f98a57..7d20175 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.1" + @version "0.2.0" def project do [ diff --git a/test/modboss/telemetry_test.exs b/test/modboss/telemetry_test.exs index c89c254..4e9621f 100644 --- a/test/modboss/telemetry_test.exs +++ b/test/modboss/telemetry_test.exs @@ -65,11 +65,11 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :read, :stop], stop_measurements, stop_metadata} assert is_integer(stop_measurements.duration) assert stop_measurements.duration >= 0 - assert stop_measurements.modbus_requests == 1 + assert stop_measurements.batches == 1 assert stop_measurements.objects_requested == 1 assert stop_measurements.addresses_read == 1 assert stop_measurements.gap_addresses_read == 0 - assert stop_measurements.max_gap_size == 0 + assert stop_measurements.largest_gap == 0 assert stop_measurements.total_attempts == 1 assert stop_metadata.schema == TestSchema assert stop_metadata.names == [:foo] @@ -101,7 +101,7 @@ defmodule ModBoss.TelemetryTest do assert is_integer(stop_measurements.duration) assert stop_measurements.duration >= 0 assert stop_measurements.gap_addresses_read == 0 - assert stop_measurements.max_gap_size == 0 + assert stop_measurements.largest_gap == 0 assert stop_metadata.schema == TestSchema assert stop_metadata.names == [:foo] assert stop_metadata.object_type == :holding_register @@ -131,7 +131,7 @@ defmodule ModBoss.TelemetryTest do # Batched reads assert_receive {:telemetry, [:modboss, :read, :stop], read_measurements, _stop_metadata} - assert read_measurements.modbus_requests == 2 + assert read_measurements.batches == 2 assert read_measurements.objects_requested == 3 assert read_measurements.total_attempts == 2 @@ -157,7 +157,7 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :read, :stop], read_measurements, _} assert read_measurements.objects_requested == 3 assert read_measurements.addresses_read == 3 - assert read_measurements.modbus_requests == 1 + assert read_measurements.batches == 1 assert read_measurements.total_attempts == 1 assert_receive {:telemetry, [:modboss, :read_callback, :stop], _req_measurements, req_meta} @@ -183,11 +183,11 @@ defmodule ModBoss.TelemetryTest do # Per-operation: 1 request, 2 objects requested, 5 addresses read, 3 gap addresses assert_receive {:telemetry, [:modboss, :read, :stop], measurements, _} - assert measurements.modbus_requests == 1 + assert measurements.batches == 1 assert measurements.objects_requested == 2 assert measurements.addresses_read == 5 assert measurements.gap_addresses_read == 3 - assert measurements.max_gap_size == 3 + assert measurements.largest_gap == 3 assert measurements.total_attempts == 1 # Per-request: single request spanning addresses 1-5 @@ -195,7 +195,7 @@ defmodule ModBoss.TelemetryTest do assert req_meta.address_count == 5 assert req_meta.attempt == 1 assert req_measurements.gap_addresses_read == 3 - assert req_measurements.max_gap_size == 3 + assert req_measurements.largest_gap == 3 end test "reports multiple gaps correctly", %{device: device} do @@ -213,18 +213,18 @@ defmodule ModBoss.TelemetryTest do ModBoss.read(GapSchema, [:alpha, :charlie, :echo], read_func(device), max_gap: 10) assert_receive {:telemetry, [:modboss, :read, :stop], measurements, _} - assert measurements.modbus_requests == 1 + assert measurements.batches == 1 assert measurements.objects_requested == 3 assert measurements.addresses_read == 5 assert measurements.gap_addresses_read == 2 # Two gaps of size 1 each (addr 2 and addr 4) - assert measurements.max_gap_size == 1 + assert measurements.largest_gap == 1 assert measurements.total_attempts == 1 assert_receive {:telemetry, [:modboss, :read_callback, :stop], req_measurements, req_meta} assert req_meta.attempt == 1 assert req_measurements.gap_addresses_read == 2 - assert req_measurements.max_gap_size == 1 + assert req_measurements.largest_gap == 1 end test "reports zero gap measurements without max_gap", %{device: device} do @@ -237,7 +237,7 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :read, :stop], measurements, _} assert measurements.gap_addresses_read == 0 - assert measurements.max_gap_size == 0 + assert measurements.largest_gap == 0 assert measurements.total_attempts == 1 assert measurements.addresses_read == measurements.objects_requested end @@ -275,10 +275,10 @@ defmodule ModBoss.TelemetryTest do # 1 object requested, 1 request attempted, 1 address attempted assert stop_measurements.objects_requested == 1 - assert stop_measurements.modbus_requests == 1 + assert stop_measurements.batches == 1 assert stop_measurements.addresses_read == 1 assert stop_measurements.gap_addresses_read == 0 - assert stop_measurements.max_gap_size == 0 + assert stop_measurements.largest_gap == 0 assert stop_measurements.total_attempts == 1 assert_receive {:telemetry, [:modboss, :read_callback, :start], _, _} @@ -336,7 +336,7 @@ defmodule ModBoss.TelemetryTest do assert metadata.result == {:error, "timeout"} # 3 planned, but only 2 attempted (1 succeeded + 1 failed) - assert measurements.modbus_requests == 2 + assert measurements.batches == 2 # 2 objects in chunk 1 + 2 objects in chunk 2 assert measurements.objects_requested == 4 @@ -348,7 +348,7 @@ defmodule ModBoss.TelemetryTest do assert measurements.gap_addresses_read == 5 # 3 gap addresses from chunk 2 (vs. 2 gap address from chunk 1) - assert measurements.max_gap_size == 3 + assert measurements.largest_gap == 3 # attempted the read callback twice assert measurements.total_attempts == 2 @@ -507,7 +507,7 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :write, :stop], stop_measurements, stop_metadata} assert is_integer(stop_measurements.duration) assert stop_measurements.duration >= 0 - assert stop_measurements.modbus_requests == 1 + assert stop_measurements.batches == 1 assert stop_measurements.objects_requested == 1 assert stop_measurements.total_attempts == 1 assert stop_metadata.schema == TestSchema @@ -555,7 +555,7 @@ defmodule ModBoss.TelemetryTest do # Batched writes assert_receive {:telemetry, [:modboss, :write, :stop], write_measurements, _} - assert write_measurements.modbus_requests == 2 + assert write_measurements.batches == 2 assert write_measurements.objects_requested == 3 assert write_measurements.total_attempts == 2 @@ -576,7 +576,7 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :write, :stop], write_measurements, _} assert write_measurements.objects_requested == 3 - assert write_measurements.modbus_requests == 1 + assert write_measurements.batches == 1 assert write_measurements.total_attempts == 1 assert_receive {:telemetry, [:modboss, :write_callback, :stop], _req_measurements, req_meta} @@ -595,7 +595,7 @@ defmodule ModBoss.TelemetryTest do assert_receive {:telemetry, [:modboss, :write, :stop], stop_measurements, write_metadata} assert write_metadata.result == {:error, "device busy"} - assert stop_measurements.modbus_requests == 1 + assert stop_measurements.batches == 1 assert stop_measurements.objects_requested == 1 assert stop_measurements.total_attempts == 1 @@ -639,7 +639,7 @@ defmodule ModBoss.TelemetryTest do assert metadata.result == {:error, "timeout"} # 3 planned, but only 2 attempted (1 succeeded + 1 failed) - assert measurements.modbus_requests == 2 + assert measurements.batches == 2 assert measurements.objects_requested == 2 assert measurements.total_attempts == 2 end