Helpers for integration-testing against a UFACTORY arm — simulated or real.
UFACTORY ships its controller firmware as a Docker image, which speaks the
exact same wire protocol as a physical arm (command socket on port 502,
real-time report stream on port 30003). That makes it possible to run true
end-to-end tests of robot applications built on this library without
hardware. See the Testing against the UFACTORY simulator tutorial for
the full workflow, and mix bb_ufactory.sim for managing the container.
Despite the module name, every function here speaks the plain UFACTORY
protocol and works identically against a physical arm — point :host at
the arm's IP instead of the simulator's.
Typical usage
In an ExUnit suite, prefer BB.Ufactory.SimulatorCase, which wires these
helpers up with readiness gating and tagging. Standalone:
alias BB.Ufactory.{Protocol, Simulator}
:ok = Simulator.wait_until_ready(timeout: 60_000)
{:ok, cmd} = Simulator.connect_command()
:ok = Simulator.prepare_arm(cmd)
{:ok, {_reg, _status, params}, _rest} =
Simulator.command(cmd, Protocol.cmd_get_error(0))Options
All functions taking opts accept:
:host— arm/simulator address (default: theSIM_HOSTenvironment variable, falling back to"127.0.0.1"):command_port— command socket port (default 502):report_port— real-time report port (default 30003)
Firmware quirks worth knowing
Observed against both the simulator and documented SDK behavior:
- The firmware accepts TCP connections a few seconds before its services
respond — always gate on
wait_until_ready/1, never on a bare connect. MOTION_EN(0x0B) may get no response;prepare_arm/2sends its sequence fire-and-forget and drains, which works on simulator and hardware alike.- Response status bytes carry flag bits (0x20 warning pending, 0x40 error pending), not plain zero.
IS_TCP_LIMITchecks the configured safety boundary, not reachability — usereachable?/3(an FK/IK round-trip through the firmware's own kinematics) to probe the workspace.
Summary
Types
Connection options. :timeout is honored only by wait_until_ready/1.
A parsed real-time report frame — see BB.Ufactory.Report.
Functions
Checks whether the firmware is actually ready — not merely accepting TCP.
Reads report frames until fun returns a truthy value or timeout
elapses.
Sends a command frame and returns the parsed response.
Opens a passive TCP connection to the command socket (port 502).
Opens a passive TCP connection to the real-time report stream (port 30003).
Snapshots the arm's current state from a fresh report connection.
Drains any unread responses from a command socket's receive buffer.
Brings the arm to a known-good, motion-ready state.
Probes whether a Cartesian pose is reachable, using the firmware's own kinematics.
Reads one complete report frame from a report-stream socket.
Sends a frame without waiting for a response, then drains.
Blocks until available?/1 returns true, polling once per second.
Types
@type opts() :: [ host: String.t(), command_port: pos_integer(), report_port: pos_integer(), timeout: pos_integer() ]
Connection options. :timeout is honored only by wait_until_ready/1.
@type report() :: map()
A parsed real-time report frame — see BB.Ufactory.Report.
Functions
Checks whether the firmware is actually ready — not merely accepting TCP.
Ready means the command socket answers a GET_STATE request AND the
report stream produces a parseable frame. Returns false on any failure.
@spec await_report(:gen_tcp.socket(), binary(), pos_integer(), (report() -> as_boolean(term()))) :: {:ok, report(), binary()} | {:error, term()}
Reads report frames until fun returns a truthy value or timeout
elapses.
Useful for observing motion: "wait until J1 is within 0.02 rad of the
target". Returns {:ok, report, rest} with the first matching frame, or
{:error, :timeout} with the deadline exceeded.
Examples
{:ok, report_socket} = Simulator.connect_report()
{:ok, _first, rest} = Simulator.read_report(report_socket)
{:ok, _frame, _rest} =
Simulator.await_report(report_socket, rest, 20_000, fn r ->
abs(hd(r.angles) - target) < 0.02
end)
@spec command(:gen_tcp.socket(), binary(), timeout()) :: {:ok, {byte(), byte(), binary()}, binary()} | {:more} | {:error, term()}
Sends a command frame and returns the parsed response.
Drains stale responses first, then sends frame (built with
BB.Ufactory.Protocol) and parses the reply. Returns
{:ok, {register, status, params}, rest}, {:more} on a truncated
response, or {:error, reason}.
Examples
{:ok, cmd} = Simulator.connect_command()
{:ok, {0x0F, _status, <<error_code, _warn>>}, _rest} =
Simulator.command(cmd, Protocol.cmd_get_error(0))
@spec connect_command(opts()) :: {:ok, :gen_tcp.socket()} | {:error, term()}
Opens a passive TCP connection to the command socket (port 502).
@spec connect_report(opts()) :: {:ok, :gen_tcp.socket()} | {:error, term()}
Opens a passive TCP connection to the real-time report stream (port 30003).
Read frames with read_report/2 or await_report/4.
Snapshots the arm's current state from a fresh report connection.
Opens a new connection so the frame reflects now rather than a stale
buffer backlog. Returns {:ok, report} with the full report map
(:angles, :pose, :state, :mode, …) or {:error, reason}.
@spec drain(:gen_tcp.socket()) :: :ok
Drains any unread responses from a command socket's receive buffer.
Fire-and-forget frames (moves, RS485 writes, prepare_arm/2) generate
responses nobody reads; drain before a request/response exchange so the
next recv reads its own reply. command/3 does this automatically.
@spec prepare_arm( :gen_tcp.socket(), keyword() ) :: :ok
Brings the arm to a known-good, motion-ready state.
Sends the standard recovery sequence fire-and-forget: clean error, clean warning, enable motors, position-control mode, ready state. Options:
:self_collision_check— whenfalse, additionally disables the firmware's geometric self-collision model. Useful when probing joint limits (the collision model otherwise fires first on folded poses). Re-enable it afterwards by callingprepare_arm(socket, self_collision_check: true)or leaving the option out on a fresh simulator. Defaults to leaving the current setting untouched.
@spec reachable?( :gen_tcp.socket(), {number(), number(), number(), number(), number(), number()}, keyword() ) :: {:ok, boolean()} | {:error, term()}
Probes whether a Cartesian pose is reachable, using the firmware's own kinematics.
Runs the pose through the firmware's IK, feeds the resulting joint
configuration back through its FK, and measures the positional error: a
reachable pose round-trips within a few millimetres, while an unreachable
pose comes back wildly off (the firmware returns unclamped IK solutions).
This is the reliable reachability probe — IS_TCP_LIMIT only checks the
configured safety boundary.
pose is {x_mm, y_mm, z_mm, roll_rad, pitch_rad, yaw_rad}. Options:
:tolerance_mm— maximum round-trip error to count as reachable (default 25.0; observed errors are < 5 mm for reachable poses and100 mm for unreachable ones)
Returns {:ok, boolean} or {:error, reason} on a protocol failure.
Sweeping this over a grid maps the arm's true reachable workspace.
@spec read_report(:gen_tcp.socket(), binary(), timeout()) :: {:ok, report(), binary()} | {:error, term()}
Reads one complete report frame from a report-stream socket.
buffer must be the unconsumed remainder (rest) from the previous call
— starting fresh mid-stream would misalign frame boundaries. Returns
{:ok, report, rest} or {:error, reason} (including
{:error, {:bad_frame_length, n}} on a desynchronized stream).
@spec send_frame(:gen_tcp.socket(), binary()) :: :ok | {:error, term()}
Sends a frame without waiting for a response, then drains.
Some registers (notably MOTION_EN) may never get a reply from the
simulator firmware; send those fire-and-forget.
@spec wait_until_ready(opts()) :: :ok | {:error, :timeout}
Blocks until available?/1 returns true, polling once per second.
Accepts the standard connection opts plus :timeout in milliseconds
(default 60000). Returns :ok, or {:error, :timeout} if the firmware
never became ready.
A freshly started simulator needs several seconds after its ports open before this succeeds; a physical arm is typically ready immediately.