BB.Ufactory.Simulator (bb_ufactory v0.1.0)

Copy Markdown View Source

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: the SIM_HOST environment 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/2 sends 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_LIMIT checks the configured safety boundary, not reachability — use reachable?/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

opts()

@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.

report()

@type report() :: map()

A parsed real-time report frame — see BB.Ufactory.Report.

Functions

available?(opts \\ [])

@spec available?(opts()) :: boolean()

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.

await_report(socket, buffer, timeout, fun)

@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)

command(socket, frame, timeout \\ 5000)

@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))

connect_command(opts \\ [])

@spec connect_command(opts()) :: {:ok, :gen_tcp.socket()} | {:error, term()}

Opens a passive TCP connection to the command socket (port 502).

connect_report(opts \\ [])

@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.

current_state(opts \\ [])

@spec current_state(opts()) :: {:ok, report()} | {:error, term()}

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}.

drain(socket)

@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.

prepare_arm(socket, opts \\ [])

@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 — when false, 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 calling prepare_arm(socket, self_collision_check: true) or leaving the option out on a fresh simulator. Defaults to leaving the current setting untouched.

reachable?(socket, pose, opts \\ [])

@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 and

    100 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.

read_report(socket, buffer \\ <<>>, timeout \\ 2000)

@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).

send_frame(socket, frame)

@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.

wait_until_ready(opts \\ [])

@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.