Packages

Beam Bots integration for UFactory xArm robotic arms.

Current section

Files

Jump to
bb_ufactory lib bb ufactory actuator linear_track.ex
Raw

lib/bb/ufactory/actuator/linear_track.ex

# SPDX-FileCopyrightText: 2026 Holden Oullette
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.Ufactory.Actuator.LinearTrack do
@moduledoc """
Linear track position actuator for xArm arms.
Controls the UFactory linear track via the xArm RS485 RTU proxy. The track
position is expressed in **millimetres** and converted internally to the
hardware's native int32 encoding (`round(mm * 2000)`).
Unlike joint positions which are batched at 100 Hz via ETS, linear track
commands are forwarded immediately to the controller via
`BB.Process.call/3`.
## Lifecycle
The track motor is **not** enabled during `init/1`. Instead, the actuator
registers its enable frame with the controller, which sends it at the end
of its own arm sequence on every `:armed` transition. This guarantees no
RS485 command reaches the bus before the arm controller is fully
initialized (mode 0, state 0) — ordering a separate state-machine
subscription could not provide, since pubsub dispatch order across
subscribers is unspecified.
## Protocol Note
The linear track uses big-endian int32 encoding for position — the only
place in the UFactory protocol where position is not little-endian fp32.
This is handled transparently by `BB.Ufactory.Protocol.cmd_linear_track_move/3`,
which returns `{pos_frame, spd_frame}`. Both frames are sent sequentially:
speed first, then position.
## Command Interface
Receives standard `%BB.Message.Actuator.Command.Position{}` commands, where
`position` is the target position in millimetres.
"""
use BB.Actuator,
options_schema: [
controller: [
type: :atom,
required: true,
doc: "Name of the xArm controller in the robot's registry"
],
speed: [
type: :pos_integer,
default: 200,
doc: "Linear track speed in mm/s (default: 200)"
],
stroke_mm: [
type: :pos_integer,
default: 700,
doc:
"Track stroke length in millimetres; position commands are clamped to " <>
"[0, stroke_mm]. UFactory tracks ship in 700 mm and 1000 mm variants " <>
"(default: 700)"
]
]
require Logger
alias BB.Message
alias BB.Message.Actuator.BeginMotion
alias BB.Message.Actuator.Command
alias BB.Ufactory.Protocol
# ── init/1 ──────────────────────────────────────────────────────────────────
@impl BB.Actuator
def init(opts) do
bb = Keyword.fetch!(opts, :bb)
controller = Keyword.fetch!(opts, :controller)
speed = Keyword.get(opts, :speed, 200)
stroke_mm = Keyword.get(opts, :stroke_mm, 700)
# Commands may be delivered over pubsub (BB.Actuator.set_position/4) —
# nothing else subscribes this process to its own command topic.
BB.subscribe(bb.robot, [:actuator | bb.path])
register_arm_frames(bb.robot, controller)
{:ok, %{bb: bb, controller: controller, speed: speed, stroke_mm: stroke_mm}}
end
# ── disarm/1 — disable track motor when arm disarms ──────────────────────────
@impl BB.Actuator
def disarm(opts) do
bb = Keyword.fetch!(opts, :bb)
controller = Keyword.fetch!(opts, :controller)
frame = Protocol.cmd_linear_track_enable(0, false)
try do
BB.Process.call(bb.robot, controller, {:send_command, frame})
catch
_, _ -> :ok
end
:ok
end
# ── handle_cast position commands ────────────────────────────────────────────
@impl BB.Actuator
def handle_cast({:command, %Message{payload: %Command.Position{position: pos_mm}}}, state) do
state = apply_track_position(pos_mm, state)
{:noreply, state}
end
def handle_cast(_request, state), do: {:noreply, state}
# ── handle_info — pubsub delivery ──────────────────────────────────────────
@impl BB.Actuator
def handle_info(
{:bb, [:actuator | _path], %Message{payload: %Command.Position{position: pos_mm}}},
state
) do
state = apply_track_position(pos_mm, state)
{:noreply, state}
end
def handle_info(_msg, state), do: {:noreply, state}
# ── Private helpers ──────────────────────────────────────────────────────────
defp apply_track_position(pos_mm, state) do
clamped = pos_mm |> max(0.0) |> min(state.stroke_mm * 1.0)
if clamped != pos_mm do
Logger.debug(
"[BB.Ufactory.Actuator.LinearTrack] position #{pos_mm} clamped to #{clamped} " <>
"(stroke #{state.stroke_mm} mm)"
)
end
pos_mm = clamped
initial_position = read_track_position(state.bb.robot, state.controller)
{pos_frame, spd_frame} = Protocol.cmd_linear_track_move(0, pos_mm, state.speed)
# Speed must be set before position so the arm uses the new speed for this move.
with :ok <- BB.Process.call(state.bb.robot, state.controller, {:send_command, spd_frame}),
:ok <- BB.Process.call(state.bb.robot, state.controller, {:send_command, pos_frame}) do
publish_begin_motion(pos_mm, initial_position, state)
else
{:error, reason} ->
Logger.warning(
"[BB.Ufactory.Actuator.LinearTrack] send_command failed: #{inspect(reason)}"
)
end
state
end
# Registers the enable frame with the controller, which sends it at the end
# of its arm sequence (or immediately if already armed).
defp register_arm_frames(robot, controller) do
frames = [Protocol.cmd_linear_track_enable(0, true)]
case BB.Process.call(robot, controller, {:register_arm_frames, :linear_track, frames}) do
:ok ->
:ok
{:error, reason} ->
Logger.warning(
"[BB.Ufactory.Actuator.LinearTrack] arm-frame registration failed: #{inspect(reason)}"
)
end
end
defp read_track_position(robot, controller) do
frame = Protocol.cmd_linear_track_read_position(0)
case BB.Process.call(robot, controller, {:send_and_recv, frame}) do
{:ok, {_reg, 0x00, params}, _rest} ->
case Protocol.parse_linear_track_position(params) do
{:ok, pos_mm} -> pos_mm
_ -> 0.0
end
_ ->
0.0
end
end
defp publish_begin_motion(pos_mm, initial_position, state) do
actuator_name = List.last(state.bb.path)
travel_distance = abs(pos_mm - initial_position)
travel_ms = round(travel_distance / max(state.speed, 1) * 1000)
expected_arrival = System.monotonic_time(:millisecond) + travel_ms
case Message.new(BeginMotion, actuator_name,
initial_position: initial_position * 1.0,
target_position: pos_mm * 1.0,
expected_arrival: expected_arrival,
command_type: :position
) do
{:ok, msg} ->
BB.publish(state.bb.robot, [:actuator | state.bb.path], msg)
{:error, reason} ->
Logger.warning(
"[BB.Ufactory.Actuator.LinearTrack] Failed to build BeginMotion: #{inspect(reason)}"
)
end
end
end