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
endThe 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.
An option to set_relays/3.
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
@type board_option() :: {:port, Arb.Board.port_no()}
An option to board/2.
@type relay_id() :: 1..8
The relays are labeled from 1 to 8 according to the data sheet.
@type set_relays_option() :: {:verify, boolean()}
An option to set_relays/3.
Functions
@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/1is 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
@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
@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
@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]}
@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
@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]}
@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/1is how you find out what actually landed. The default value istrue.
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