Arb (arb v0.20.0-beta.1)

Copy Markdown View Source

A NIF for controlling the ABACOM CH341A relay board.

Getting started

Open a context once, name a board, and drive it:

{:ok, usb} = Arb.open()
board = Arb.board(usb)

:ok = Arb.set_relays(board, [1, 4, 7])
{:ok, [1, 4, 7]} = Arb.relays(board)
:ok = Arb.set_relays(board, [])

Holding the context

Arb.open/0 is expensive and every other call is not, so open a context when your application starts and hold it — see Arb.Usb for the figures and for how to replace one that has gone bad.

Hold it in whatever owns the recovery. One context for the node would do for throughput: an Arb.Usb is safe to use from any process, a board is claimed only for the duration of a single call, and the saving is per call either way. But a failure means dropping the context and opening another, and the context belongs to whoever does the dropping. One per process that drives a board, or one per board where two of them should not share a bad day, costs an Arb.open/0 per owner at boot and buys each a recovery that leaves the others alone:

defmodule MyApp.Board do
  use GenServer

  def init(port) do
    {:ok, usb} = Arb.open()
    {:ok, %{board: Arb.board(usb, port: port)}}
  end
end

The example keeps no Arb.Usb. A board holds its own reference to the context it came from, so holding the board keeps the context alive, and replacing one replaces both. Keep the Arb.Usb too only if you mean to name another board or to call list_boards/1 through it.

Arb.Board is the cheap half. It resolves nothing until an operation is called on it, so it can be built per call, held in state, or passed around freely.

Checking the board

Arb.relays/1 is a plain read that takes the shift register at its word. Arb.self_test/1 is the separate check that the board is still answering correctly; it moves no relay, so it is safe on a board driving live outputs, and it returns the relays it found while checking, so where you want both a verdict and a state that is one claim rather than two.

A read followed by a write is two claims rather than one — see Arb.Board for what that means on a board shared with another application.

Changed in 0.20.0

get_active/1 used to run the self-test on the way past; Arb.relays/1 does not. Callers that relied on reading to vet the board must now call Arb.self_test/1 themselves.

Telling boards apart

These boards carry no identity of their own — no serial number, no manufacturer or product strings — so where a board is plugged in is the only thing distinguishing two of them. inspect/1 renders that as #Arb.Board<port 5 (1-5)> for an enumerated board, in the notation lsusb -t uses.

It follows that if identical boards are unplugged and returned to different sockets, nothing in software can tell they were swapped — a config naming port 5 will drive whatever is now on port 5, silently. Label the cables where that matters.

Summary

Types

An option to board/2.

The relays are labeled from 1 to 8 according to the data sheet.

Functions

Names a relay board reachable through usb.

Lists every attached relay board, in a stable order.

Initialises libusb.

Returns the ids of the active relays, in ascending order.

Performs a USB reset on the relay board.

Checks that the board answers correctly, without moving any relay, and returns the ids of the relays it found active.

Activates the relays with the given ids, deactivating every relay not in the list. An empty list deactivates all relays.

Types

board_option()

@type board_option() :: {:port, Arb.Board.port_no()}

An option to board/2.

relay_id()

@type relay_id() :: 1..8

The relays are labeled from 1 to 8 according to the data sheet.

set_relays_option()

@type set_relays_option() :: {:verify, boolean()}

An option to set_relays/3.

Functions

board(usb, opts \\ [])

(since 0.20.0)
@spec board(Arb.Usb.t(), [board_option()]) :: Arb.Board.t()

Names a relay board reachable through usb.

Resolves nothing and touches no hardware: the board is looked up when an operation is called on it, so nothing the hardware does can make this fail.

It can still be called wrongly. A :port outside 0..255 raises NimbleOptions.ValidationError rather than returning an Arb.Error — that is a caller's mistake, not something a board reported, and the two are worth keeping apart. A port almost always arrives from configuration, though, so validate it where it enters your application if you would rather a misconfigured release fail there than raise out of an init/1.

Options

  • :port - The USB port to select a specific board when multiple are connected. A port number is the board's port on the hub it is plugged into, so it is unique only among that hub's ports: two boards behind two hubs can both be on port 3, and naming one then fails with :multiple_found. list_boards/1 is the way out. Defaults to whichever board is attached.

Examples

iex> {:ok, usb} = Arb.open()
iex> Arb.Board.port(Arb.board(usb))
nil

iex> {:ok, usb} = Arb.open()
iex> Arb.Board.port(Arb.board(usb, port: 3))
3

list_boards(usb)

(since 0.20.0)
@spec list_boards(Arb.Usb.t()) :: {:ok, [Arb.Board.t()]} | {:error, Arb.Error.t()}

Lists every attached relay board, in a stable order.

Each board is named by where it sits on the USB tree rather than by port number, so an enumerated board always resolves back to the board it came from and never collides with another on the same port number.

An empty list means no board is attached; that is not an error.

Examples

iex> {:ok, usb} = Arb.open()
iex> {:ok, boards} = Arb.list_boards(usb)
iex> is_list(boards)
true

open()

(since 0.20.0)
@spec open() :: {:ok, Arb.Usb.t()} | {:error, Arb.Error.t()}

Initialises libusb.

Expensive relative to everything else, so open one context and hold it for the lifetime of your application — see Arb.Usb.

Examples

iex> {:ok, usb} = Arb.open()
iex> is_struct(usb, Arb.Usb)
true

relays(board)

(since 0.20.0)
@spec relays(Arb.Board.t()) :: {:ok, [relay_id()]} | {:error, Arb.Error.t()}

Returns the ids of the active relays, in ascending order.

The ordering is guaranteed, so a comparison against a list you built the same way needs no Enum.sort/1 on either side. An empty list means no relay is active.

A plain read: it does not check that the board is answering correctly. That is self_test/1.

What a read reports

The relays hang off a shift register, and reading it is destructive — the contents clock out — so a read writes back what it consumed, without latching. What this returns is therefore the register, which the latch keeps equal to the relay outputs.

A USB failure between the two halves breaks that equality: the read consumed the register and the write-back did not land, leaving the register holding the zeros the read shifted in while the outputs stay latched where they were. The relays have not moved — nothing here latches — but the register no longer says where they are.

That failure is announced. It comes back as {:register_out_of_sync, cause} rather than as the transport error underneath, because a transport error's documented remedy is to retry and retrying is what makes this stick: the retried read finds the register the last one left and succeeds, so it answers {:ok, []} for a board driving live outputs. The announcement is that one call only. This library holds no state between calls, so the reads after it look no different from good ones.

It does not clear itself either. A repeat read finds the same register and puts the same contents back; only a set_relays/3 that succeeds writes the register and the outputs together again. So a read taken to settle where the relays are after a failed write — see After a failure — is trustworthy unless that write, or a read since, answered {:register_out_of_sync, _}. With the outputs unknown either way, the honest recovery is to drive them somewhere known rather than to keep asking.

Examples

Arb.relays(board)
#=> {:ok, [1, 3, 6]}

reset_device(board)

(since 0.20.0)
@spec reset_device(Arb.Board.t()) :: :ok | {:error, Arb.Error.t()}

Performs a USB reset on the relay board.

This resets the USB device, not the relays: previously activated relays stay active. If board operations start failing with a USB error — say {:error, {:usb, "Input/Output Error"}} — this may resolve it. The effect is similar to replugging the device.

Including the part after the plug goes back in. The board re-enumerates, and until it is back every call answers :not_found. :ok here means the reset was issued, not that the board is ready — so do not read the next call's failure as the reset having failed.

How long it stays away is the host's business: a hub, a loaded machine or another kernel each answer differently. Arb.Error.retry_in_place?/1 vouches for :not_found, so retrying until the board answers is what finds out; a delay guessed in advance is either too short or wasted.

Examples

Arb.reset_device(board)
#=> :ok

self_test(board)

(since 0.20.0)
@spec self_test(Arb.Board.t()) :: {:ok, [relay_id()]} | {:error, Arb.Error.t()}

Checks that the board answers correctly, without moving any relay, and returns the ids of the relays it found active.

Writes an inverted test pattern through the shift register and reads it back. The pattern is never latched and the register's original contents are put back afterwards, so this is safe to call on a board driving live outputs. Fails with :self_test_failed if the pattern does not survive the round trip.

Those original contents are what it returns. The check cannot write its pattern without first reading what the register holds, so a caller wanting both a verdict and a state gets them from one claim rather than following this with relays/1, which would be a second claim and, on a board shared with another application, a second moment.

A diagnostic rather than a guard on the operating path: set_relays/3 with verify: true already writes, latches, reads back and compares within a single claim — everything this covers, on the value you actually asked for, plus the latch it never touches. And since a self-test is its own claim, it vouches for no other relays/1 call either side of it, only for the state it hands back itself. Reach for it at startup, from a health check, or when a board is suspect.

Examples

Arb.self_test(board)
#=> {:ok, [1, 3, 6]}

set_relays(board, ids, opts \\ [])

(since 0.20.0)
@spec set_relays(Arb.Board.t(), [relay_id()], [set_relays_option()]) ::
  :ok | {:error, Arb.Error.t()}

Activates the relays with the given ids, deactivating every relay not in the list. An empty list deactivates all relays.

An id outside 1..8 raises ArgumentError, as a :port outside its range does on board/2 and for the same reason. The ids are checked before the board is claimed, so one bad id cannot latch the good ones alongside it.

Options

  • :verify (boolean/0) - Whether to read the shift register back after latching and fail with {:verification_failed, expected, actual} on a mismatch. The relays are latched before the read-back, so a failure leaves their physical state unknown — relays/1 is how you find out what actually landed. The default value is true.

After a failure

:not_found, :multiple_found and :busy are raised while claiming the board, before a single byte is written, so the relays are where they were.

Every other failure may have latched. A write is a run of USB transfers with the latch partway through, and a transfer that fails — {:usb, _}, {:unexpected_transfer_length, _} — may or may not have reached the board. {:verification_failed, expected, actual} is that situation named precisely: it latched, then read back something else. So on anything but those three the relay position is unknown until you look, and relays/1 is how you look — within the limit under What a read reports, which the same failure can put on the answer.

{:register_out_of_sync, _} is the exception that says more. Here it can only come from the read-back, which runs after the latch, so the relays are holding the ids you asked for. What was lost is the shift register, and with it the board's ability to report them, so this is the one failure where looking is the wrong move: write the state again instead, which puts the register and the outputs back in step.

Which failures leave them unknown is a property of this call rather than of the reason, which is why Arb.Error has no function to ask: the same {:usb, _} moved nothing when relays/1 raised it. No other call here can leave the relays somewhere unknown — self_test/1 writes without latching, and reset_device/1 resets the USB device rather than the outputs.

Examples

Arb.set_relays(board, [1, 4, 7])
#=> :ok

Arb.set_relays(board, [], verify: false)
#=> :ok