Robot factory¶
Robot(...) returns a Simulation or HardwareRobot based on mode.
from strands_robots import Robot
robot = Robot("so100") # Simulation (default, safe)
robot = Robot("so100", mode="real") # HardwareRobot
robot = Robot("so100", mode="auto") # probes USB, falls back to sim
Parameters¶
| Param | Type | Default | What |
|---|---|---|---|
name |
str | required | Catalog name or alias. Resolved via registry/robots.json. |
mode |
str | "sim" |
"sim" / "real" / "auto". Overridden by STRANDS_ROBOT_MODE. |
backend |
str | "mujoco" |
Sim backend. Ignored when mode="real". |
urdf_path |
str | None |
Explicit MJCF/URDF path - bypasses registry. Ignored when mode="real" (reported at debug level). |
cameras |
dict | None |
Real-hardware camera config, attached by the lerobot driver. Rejected in mode="sim", and rejected for a native driver that does not open cameras - both raise ValueError. |
position |
list | None |
Robot position [x, y, z] in sim world. Ignored when mode="real" (reported at debug level). |
data_config |
str | None |
GR00T data_config name. Honoured in both modes: mode="sim" defaults it to the canonical robot name, mode="real" forwards it to the hardware driver, which carries it into the policy_config a policy is built with. |
mesh |
bool | None | None |
Join the Zenoh fleet mesh. None consults STRANDS_MESH, which leaves it off unless set to true/1/yes - pass mesh=True to opt in per robot. |
peer_id |
str | None |
Stable mesh peer id. Auto-generated if omitted. |
orientation |
list | None |
Robot base orientation [w, x, y, z] in sim world. Ignored when mode="real" (reported at debug level). |
keyframe |
str | int | None |
Spawn in a model <keyframe> pose (name or index) instead of the zero configuration. Ignored when mode="real" (reported at debug level). |
driver |
str | "auto" |
Which implementation drives a real robot: "auto" / "lerobot" / "strands". "auto" honours the robot's registry hardware.driver and otherwise builds the lerobot driver. Checked in every mode; only mode="real" acts on it (sim reports it as ignored at debug level). See Choosing a driver. |
**kwargs |
Forwarded to the backend or driver constructor as given. A name it does not recognize is ignored, not refused, so check the spelling against the forwardable list below. |
Name resolution¶
from strands_robots.registry import resolve_name
resolve_name("SO-100") # 'so100'
resolve_name("franka") # 'panda'
resolve_name("g1") # 'unitree_g1'
Case-insensitive, hyphens/underscores interchangeable. That fold is
registry.normalize_robot_name, and it is the rule the registry is keyed by, not
just the rule queries pass through: a canonical name is stored folded and an
alias is keyed folded, so an alias declared "My-Arm" answers my_arm,
MY-ARM and My-Arm alike. Two aliases that fold to one key are therefore one
alias, and register_robot refuses an alias that folds onto another robot's name
or alias rather than letting it resolve to that robot. Full alias map in
registry/robots.json.
Real hardware¶
robot = Robot(
"so100",
mode="real",
cameras={
"wrist": {"type": "opencv", "index_or_path": "/dev/video0"},
"top": {"type": "intelrealsense", "serial_number_or_name": "819312071961"},
},
port="/dev/tty.usbserial-A50285BI",
control_frequency=50.0,
)
Each cameras entry is a serialized lerobot CameraConfig, so type is resolved
against lerobot's own choice registry - the same registry the robot name itself is
resolved against. Every backend lerobot ships is therefore attachable
(opencv, intelrealsense, zmq, reachy2_camera), as is any installed
lerobot_camera_* plugin, and the remaining keys are the fields of the class the
type resolves to. Note the registered name for Intel RealSense is
intelrealsense, not realsense; an unregistered type raises ValueError
listing the registered ones. fps, width and height are common to every
backend and default to 30/640/480 when unset - a vendor SDK the backend needs
(pyrealsense2 for intelrealsense) is required when the device is opened, not
when the config is built.
Cameras are attached by the lerobot driver. A native driver
(driver="strands") addresses its cameras through its own SDK, so it does not
take a caller-supplied config - and none of the drivers shipped here does. Rather
than accept the keyword and hand back a robot with no cameras, the factory
refuses it by name:
>>> Robot("unitree_go2", mode="real", cameras={"front": {"type": "opencv", "index_or_path": 0}})
ValueError: Go2Driver does not open cameras, so cameras= cannot be honored for
'unitree_go2'. Forwarding it would return a robot with no cameras at all under
status=success. Use driver='lerobot', which attaches them through lerobot's
camera backends, or capture the frames outside the driver.
This reaches robots that never mention driver=: eight shipped robots declare
driver="strands" in the registry (see Choosing a
driver). A driver that does open the cameras it is given
declares reads_cameras = True on the class and receives the dict verbatim - the
opt-in is that one attribute, described with the rest of the constructor contract
in strands_robots.drivers.base.
control_frequency (Hz) sets the control loop's per-action period,
1 / control_frequency - the only throttle between two servo commands. It must be a
positive finite number: 0, a negative rate, nan or inf raises ValueError at
construction, before the serial port is opened, rather than leaving the loop free-running
against the arm. This is the same domain the simulation applies to run_policy's
control_frequency, so a rollout rehearsed in sim is honored identically on hardware.
Forwardable kwargs: port, robot_ip, kp, kd, default_positions, control_dt,
is_simulation, gravity_compensation, controller, calibration_dir, mock,
use_degrees, max_relative_target, disable_torque_on_disconnect.
Forwardable values are passed to the driver as given, because their accepted domains are
robot-specific. max_relative_target is the exception: it caps how far each commanded goal
position may move from the joint's present position, so it must be a positive finite number
(or a mapping of motor name to one). 0, a negative limit, nan, inf, a bool or a
non-numeric value raises ValueError when the config is built, before the serial port is
opened - a non-finite limit would otherwise disable the clamp with no signal, and a negative
one inverts it into a fixed-magnitude step that ignores the policy. An int limit is
normalized to float so it reaches the motors. Omit the parameter (or pass None) to leave
the clamp disabled.
Choosing a driver¶
mode="real" builds a driver. By default that is the lerobot one - it constructs a lerobot
RobotConfig and wraps a lerobot driver, which is what most robots in the shipped registry
use. A robot lerobot cannot model needs a native driver, but the two are not exclusive - a
robot lerobot can build may have one as well, and then driver= decides which is used.
list_native_drivers() reports every robot that has one, and is the answer to "is my robot
driven natively" - the refusal below lists them only as of the day it was captured.
driver= selects a different one:
| Value | Builds |
|---|---|
"auto" (default) |
The robot's registry hardware.driver if it declares one, else the lerobot driver. |
"lerobot" |
The lerobot driver, explicitly. |
"strands" |
The native driver registered for this robot. |
list_driver_coverage() reports the join for every registered robot: which driver= values
can build it, and an empty tuple where neither can.
from strands_robots.drivers import list_driver_coverage
coverage = list_driver_coverage()
coverage["so101"], coverage["vx300s"], coverage["panda"]
# (('lerobot', 'strands'), ('strands',), ())
sim_only = [name for name, drivers in coverage.items() if not drivers]
so101 is reported as both and resolve_driver("so101") returns "lerobot" - coverage is
what can build a robot, resolution is what does. vx300s has no lerobot robot type, so its
native driver is the only one that can build it. An empty tuple is the driver gap: sim_only
is every robot mode="real" has nowhere to go for, derived on each call rather than
maintained by hand.
A native driver is for a robot lerobot's arm/serial shape cannot model - a humanoid with its
own state machine, a rover reporting GPS, a base publishing a point cloud. It is a separate
class satisfying strands_robots.drivers.HardwareDriver, registered against a robot name:
from strands_robots.drivers import register_native_driver
register_native_driver("unitree_g1", G1Driver)
robot = Robot("unitree_g1", mode="real", driver="strands", port="192.168.123.161")
register_native_driver refuses a class that does not satisfy the contract and names the
members it is missing, so a half-built driver fails at the line that registers it rather
than on the first agent call. port= stays polymorphic - a serial path, an IP address or a
URL - because only the driver knows how to read it.
baud_rate= does not stay polymorphic. Every surface that opens a serial bus - the Feetech
and Dynamixel drivers, FeetechBus, and the baudrate of serial_tool and pose_tool -
holds it to one domain, a positive integer, and refuses anything else by name at
construction. pyserial takes the speed through its own int() and refuses only a negative,
so an ungraded value is applied: 2.7 opens the port at 2 baud, and 0 opens it
successfully at a speed no servo answers, after which every read times out exactly as an
unplugged arm does. A refusal at the line that states the speed is the only place that
reads as a caller mistake rather than as broken hardware.
The read window is the same shape. timeout= - on FeetechBus, and on FeetechDriver, which
forwards a caller's window to it - is how long a read waits for a servo's reply, and it is held
to a positive finite number at construction. pyserial takes 0, nan, inf and None
verbatim, and each of them leaves the read looking at an empty buffer that the retry loop cannot
tell from a servo that never answered, so a healthy arm reports as motors that did not reply. A
keyword a driver records instead of forwarding fails the same way one layer earlier: the caller
lengthens the window, the bus opens at its default, and nothing says so.
A driver has two ways to halt its robot and they are not the same contract. stop_task()
returns a status envelope and decides an outcome, so that is what a caller reads. stop() is
the lifecycle hook and is annotated -> None, so it carries no verdict at all - which makes
its log the only place a halt it could not complete can be recorded. A stop() that
delegates to a halt verb must therefore read that verb's envelope and log a non-success,
naming what may still be moving; strands_robots.drivers.halt_failure_detail reads the
reason out of one. Discarding it returns from shutdown reporting the robot as stopped on the
one surface that has no way to say otherwise.
A driver that decodes its own telemetry decides, field by field, whether a reading exists. The
convention is to read every field as getattr(msg, name, None) and coerce it, because a typed
default is a well-formed value: a firmware that renames a field would publish a plausible constant
rather than an absence. strands_robots.drivers.base owns that coercion -- telemetry_float,
telemetry_int, telemetry_float_list, telemetry_int_list -- so the answer does not depend on
which driver asked. Each returns None for anything that is not a reading, including a bool
(float(True) is 1.0, indistinguishable from a real one-percent pack) and a bytes-like value
(str, bytes, bytearray, memoryview all iterate, so a raw buffer would otherwise decode as a
vector of the wrong length). The vector readers are all-or-nothing and return a fresh list, so a
caller mutating the envelope does not race the callback thread's next write.
The rule covers the scalars a decoder sends back out, not only the ones it publishes. The Unitree
mode_machine is read from rt/lowstate and echoed on every LowCmd_, and the firmware drops a
frame whose layout id does not match the one it announced -- so an id that came from something other
than a number is a write the robot silently ignores. A bare int() is the wrong coercion for that:
int(True) is 1 and int(False) is 0, both valid uint8 ids, so a flag on the field would be
indistinguishable from a reading. telemetry_int refuses both. A float is still truncated, because
that is the shared answer and a decoder stricter than its sibling on a value both accept is the
drift these functions exist to prevent.
A refused scalar leaves the cached value at the last reading that parsed, rather than clearing it.
That matters when a gate reads the cache: mode_machine gates every G1 motion write and its refusal
reads "lowstate has not delivered yet", which one unreadable frame should not make true of a robot
whose lowstate is arriving.
The coercion has to be per field for that to hold at the frame level too. A decoder that builds its
whole record inside one try loses every field a message carried because one of them stopped reading,
and the staleness that leaves behind looks like a dropped wire rather than one renamed field. The
record keeps the key either way and lets the value be None, so a consumer asking for a field always
gets an answer and the answer can be "the robot did not report this"; a frame in which nothing read
is simply not cached, for the same reason a refused scalar does not clear its own cache.
The rule matters most where a reading is also a command source. BoosterDriver.send_action holds
every uncommanded upper-body joint at its last observed position, so the T1's joints vector is what
the next LowCmd writes. A defaulted 0.0 there is finite and full-width, clears the "is this a
frame" guards, gets cached, and gets commanded -- eight arm joints driven to exactly zero from a frame
that carried no positions at all. Reporting the absence instead reaches the refusal the driver already
spells for a robot that has reported nothing yet. All-or-nothing matters for the same reason: held_q
is indexed by slot, so a vector short one element would renumber every slot after the gap and hold the
wrong joint at each of them.
Asking for a driver that is not there is refused, never quietly substituted:
>>> Robot("xarm7", mode="real", driver="strands")
ValueError: No native driver is registered for 'xarm7', so driver='strands' cannot build
it. Robots with a native driver: aloha, dynamixel_2r, fr3, fr3_v2, hope_jr, koch, lekiwi,
microduck, open_duck_mini, panda, reachy_mini, robotiq_2f85, robotiq_2f85_v4, so100,
so101, trossen_wxai, unitree_g1, unitree_go2, ur10e, ur5e, vx300s, wx250s. Either use
driver='lerobot' (today's default, which builds it through lerobot) or
register one with strands_robots.drivers.register_native_driver().
A robot may also declare its driver in the registry, so a caller needs no driver= at all:
"unitree_g1": {"hardware": {"lerobot_type": "unitree_g1", "driver": "strands"}}
"reachy_mini": {"hardware": {"driver": "strands"}}
lerobot_type is independent of driver. The G1 declares one because lerobot also
has a class for it, so driver="lerobot" remains a usable fallback. The Reachy Mini
declares none: lerobot has no robot type for it, so the native driver is the only way
to reach it and driver="lerobot" is refused by name.
A robot that declares neither - the UR arms, for instance - still resolves to the
default, so driver="strands" is how its native driver is reached, and the refusal
driver="lerobot" produces names that driver rather than listing lerobot's types.
A native driver reports what it cannot reach rather than raising. The Reachy Mini's
daemon transport is a standard-library-only module in the core distribution, so
nothing an extra installs decides whether it loads - but if it cannot be imported at
all, on a broken install or behind a shadowing module, the driver still builds,
registers and answers get_status, and every surface that would touch the daemon
returns a reason naming the module and the error instead:
>>> Robot("reachy_mini", mode="real").connect_eagerly()
"cannot import strands_robots.device_connect.reachy_transport: No module named
'strands_robots.device_connect.reachy_transport'"
The reason stops at what it can establish. It prescribes no pip install, because no
install supplies a module that ships in the core distribution, and a remedy that
cannot help is worse than none - the same rule
require_optional applies when it is told a module
arrives from a system package rather than an index.
The same reason arrives as connect_error in get_status, so a mesh peer for a Mini
whose transport will not load is still constructible and still reports why it is not
connected.
A bring-up that reaches the daemon but whose real-time link never finishes its handshake is reported the same way, and the link is not left behind. The driver cancels the handshake and asks the link to stop before returning, so nothing stays subscribed to a Mini the caller has just been told it is not connected to:
>>> Robot("reachy_mini", mode="real").connect_eagerly()
"link to reachy-a.local:8000 did not finish its handshake within 10s"
The reason names the budget that expired rather than the timeout's own message, which is empty.
Either way the loop the bring-up opened is closed, not merely stopped. The link runs
on a background asyncio loop, and loop.stop() only asks it to return from
run_forever - the selector and self-pipe it opened are released by loop.close().
So teardown waits for that thread (up to 5s) and then closes the loop, on the success
path through cleanup() and on both give-up paths, rather than leaving one open loop
per connect cycle for the garbage collector to complain about later. A thread that
outlasts the wait keeps its loop, because closing a running loop raises, and that
outcome is logged instead of reported as a teardown that finished.
hardware.driver is optional and validated when the registry loads: a value that is not a
driver name is refused there, naming the robot, rather than being read as "no preference".
Mesh¶
Mesh is opt-in, so a bare Robot(...) never starts Zenoh, ACL or e-stop machinery:
sim = Robot("so100")
sim.mesh # None - never joined
sim = Robot("so100", mesh=True) # per-robot on
sim.mesh.peer_id # 'so100_sim-a1b2c3d4'
sim.mesh.alive # True
# STRANDS_MESH=true # process-wide on, for a bare Robot(...)
STRANDS_MESH=false is a kill switch: it keeps mesh off even where a caller passed
mesh=True. The environment never forces mesh on for a robot constructed with
mesh=False.
Mesh failure is non-fatal; .mesh = None if Zenoh unavailable.
See also¶
- Robot catalog - 68 catalog names.
- Architecture - factory in the module map.
- Multi-robot mesh - mesh peer discovery.