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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions lib/modboss.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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
}

Expand Down
25 changes: 12 additions & 13 deletions lib/modboss/telemetry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
[
Expand Down
42 changes: 21 additions & 21 deletions test/modboss/telemetry_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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}
Expand All @@ -183,19 +183,19 @@ 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
assert_receive {:telemetry, [:modboss, :read_callback, :stop], req_measurements, req_meta}
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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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], _, _}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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}
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading