Drivers¶
The HardwareDriver protocol a native driver satisfies and the functions that register and look drivers up.
A native driver speaks a robot's wire protocol without lerobot; Robot(name, mode="real", driver="strands") builds one through the registry below. Here is the HardwareDriver protocol a driver satisfies and the functions that register and look drivers up.
Protocol¶
strands_robots.drivers.base.HardwareDriver ¶
Bases: Protocol
The surface :func:~strands_robots.robot.Robot mode="real" returns.
See the module docstring for what is deliberately absent and why.
stream ¶
stream(tool_use: ToolUse, invocation_state: dict[str, Any], **kwargs: Any) -> AsyncGenerator[Any, None]
Run one agent tool call, yielding events and finally the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool_use
|
ToolUse
|
The agent's request, carrying the tool id and parameters. |
required |
invocation_state
|
dict[str, Any]
|
Caller-provided state passed to the agent. |
required |
**kwargs
|
Any
|
Additional keyword arguments, for forward compatibility. |
{}
|
Yields:
| Type | Description |
|---|---|
AsyncGenerator[Any, None]
|
Tool events, the last of which is the tool result. Spelled out |
AsyncGenerator[Any, None]
|
rather than borrowing |
AsyncGenerator[Any, None]
|
is an alias for exactly this type: the reference implementation |
AsyncGenerator[Any, None]
|
spells it out too, and naming the alias would add an SDK symbol this |
AsyncGenerator[Any, None]
|
package does not otherwise depend on. |
send_action ¶
Command one action.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action
|
dict[str, Any]
|
Joint targets, keyed the way this driver names its joints. |
required |
robot_name
|
str | None
|
Which robot to command when the driver fronts several;
|
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope describing what was commanded. |
start_task ¶
start_task(instruction: str, policy_port: int | None = None, policy_host: str = 'localhost', policy_provider: str = 'lerobot_local', duration: float = 30.0, **policy_kwargs: Any) -> dict[str, Any]
Start a policy-driven task in the background.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instruction
|
str
|
Natural-language instruction for the policy. |
required |
policy_port
|
int | None
|
Port the policy server listens on; |
None
|
policy_host
|
str
|
Host the policy server runs on. |
'localhost'
|
policy_provider
|
str
|
Which policy provider to build. |
'lerobot_local'
|
duration
|
float
|
Wall-clock budget for the task, in seconds. |
30.0
|
**policy_kwargs
|
Any
|
Extra provider-specific policy options. |
{}
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope describing the task that started. |
run_policy ¶
run_policy(policy_object: Policy, instruction: str = '', duration: float = 30.0, n_steps: int | None = None) -> dict[str, Any]
Run an already-built policy against this robot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
policy_object
|
Policy
|
The policy to roll out. |
required |
instruction
|
str
|
Natural-language instruction handed to the policy. |
''
|
duration
|
float
|
Wall-clock budget for the rollout, in seconds. |
30.0
|
n_steps
|
int | None
|
Step budget; when given it wins over |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope describing the rollout. |
get_task_status ¶
Report the running task's state.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope; the shape a caller polls between steps. |
stop_task ¶
Stop the running task.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope describing what was stopped. |
get_status
async
¶
Report the driver's own health and connection state.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A status envelope the mesh publishes as this peer's presence. |
stop
async
¶
Stop motion and any background loop, leaving the robot connected.
Annotated -> None, so it carries no verdict: a caller that needs the
halt outcome reads :meth:stop_task, which decides one. That is exactly
what makes the log the only place a halt this hook could not complete
can be recorded, so an implementation that delegates to a halt verb must
read that verb's envelope and log a non-success, naming what may still be
moving. Discarding it returns from shutdown reporting a robot as stopped
on the one surface that carries no way to say otherwise.
:func:halt_failure_detail reads the reason out of such an envelope.
cleanup ¶
Release the device and every background resource held for it.
Annotated -> None, so like :meth:stop it carries no verdict, and
the same obligation follows: an implementation that delegates to a halt
verb must read that verb's envelope and log a non-success, naming what
may still be moving. Here it is the more urgent of the two, because this
hook goes on to release the channel a retry would need - a refused halt
it did not report leaves a robot moving with nothing left in the process
able to reach it. The release is owed either way: stopping half-way
leaks the resource and leaves the robot moving.
Helpers for driver authors¶
The contract a mode="real" driver satisfies.
:func:~strands_robots.robot.Robot with mode="real" builds one object and
hands it to everything downstream: a Strands agent invokes it as a tool, the
Zenoh mesh publishes from it, and the teleop rail commands through it. Until a
second implementation existed that object was always
:class:strands_robots.hardware_robot.Robot, so the surface those consumers
rely on was recorded nowhere - a driver author had to read a 3000-line class to
learn which members are load-bearing.
:class:HardwareDriver writes that surface down. It is deliberately the
measured contract rather than an aspirational one, which makes it smaller than
a reader might expect:
get_observationis not a member. A lerobot robot is a wrapper: it holds the device that owns the bus underrobot, and :func:strands_robots.bus_access.read_observationtakes that inner device - so the top-level driver is not the thing asked for a frame.
A native driver has no inner device, and for joint telemetry that is now
resolved rather than assumed:
:func:strands_robots.bus_access.joint_read_source prefers robot.robot
and falls back to the driver itself, so a driver that owns its bus publishes
joints on the state topic by exposing either a bus with sync_read
or a get_observation, plus is_connected to say it is live. Neither is
required, which is why neither is a member: a driver with no motors to report
is otherwise complete.
* The sensor attributes a mesh publishes (_pose, _imu, _battery and
their siblings) are not members either. Every one is read with a
getattr(robot, name, None) default, so a driver with no IMU publishes no
IMU topic and is otherwise complete - making them optional by construction. A
Protocol cannot express "optional", and requiring them would refuse a
perfectly good arm for lacking a lidar.
What remains is the surface a driver must have for an agent to call it and for
the mesh's command and task paths to work. The four tool members
(tool_name, tool_type, tool_spec, stream) are also the abstract
surface of :class:strands.tools.tools.AgentTool, which is what makes an object
usable as a Strands tool at all.
Structural, not nominal: a driver satisfies this by having the members, with no
import of - or inheritance from - anything here. Inheriting AgentTool is
still the easy way to get the tool quarter right.
Constructor contract (a Protocol cannot express __init__): the factory
builds a native driver as
driver_cls(tool_name=<canonical name>, cameras=<cameras or None>,
data_config=<data_config or None>, **kwargs), so a driver must accept those
three keywords. Every further keyword it honours it declares as a parameter -
port= among them, which stays polymorphic (a serial path, an IP address or a
URL, interpreted by the driver that receives it). The signature is therefore the
driver's whole keyword roster, and the factory refuses a keyword outside it
(:func:constructor_keywords) rather than forwarding it into a **kwargs
sink: a driver that tolerates an unread keyword reports success having ignored
it, so Robot('so101', mode='real', driver='strands', prot='/dev/ttyACM0')
built an arm that auto-detects a port while the caller believed they had named
one. The lerobot path already refuses that typo by name; this is the mirror.
cameras is the one of the three that is not forwarded unconditionally. A
driver that accepts it only for parity - which every driver shipped here does,
because these robots address their cameras through their own SDK rather than
through a caller-supplied config - must not be handed one it will never open: a
dropped camera is invisible until a recording turns out to have no image
columns. So the factory refuses a non-empty cameras= unless the class
declares reads_cameras = True. Declaring it is the whole opt-in; the driver
then receives the dict verbatim and owns opening, reading and closing the
devices in it.
sim is the twin transport's engine. A driver that declares it is handed one
by the factory on transport="twin" - built at the class's twin_keyframe
when it declares one - so no driver imports the simulation package upward.
constructor_keywords ¶
Return every keyword driver_cls's constructor binds.
Derived from the signature rather than listed anywhere, so a driver's roster
cannot drift from what it reads: the keywords a driver honours are the
parameters it declares (the constructor contract in this module's
docstring). A **kwargs sink contributes nothing - it binds no name and
reads none.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
driver_cls
|
type
|
A driver class, typically one registered through
:func: |
required |
Returns:
| Type | Description |
|---|---|
str
|
The parameter names, sorted. Read off the class rather than off |
...
|
|
missing_driver_members ¶
Report which :data:DRIVER_SURFACE members candidate does not have.
Answers for a class as well as an instance, which is what a caller
holding a driver class before construction needs -
:func:issubclass cannot: a Protocol declaring a @property has
non-method members, and issubclass refuses those outright with
TypeError.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
candidate
|
object
|
A driver class or a built driver instance. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The missing member names in sorted order; empty when |
...
|
satisfies the whole surface. |
drifted_driver_parameters ¶
Report verbs whose parameters candidate spells differently from the Protocol.
:func:missing_driver_members answers whether the names on the class are
all there, and that is all it can answer: it is hasattr, so a driver
that renames a documented parameter satisfies it completely. A driver is
invoked as an agent tool, and a dispatcher that spells the contract's own
parameter names as keywords is the ordinary caller - so a renamed parameter
is not a style difference, it is a TypeError raised past dispatch in
place of the status envelope every verb here promises to return.
The check is the call a conforming caller makes: bind every parameter
:class:HardwareDriver declares for the verb, by keyword. That admits the
freedoms a driver legitimately has - extra parameters of its own, its own
ordering, absorbing the ones it ignores in **kwargs - and refuses only
the one thing no caller can work around, a required parameter reachable
solely under a name the contract does not document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
candidate
|
object
|
A driver class or a built driver instance. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, str]
|
|
...
|
failure; empty when every verb accepts the contract's own spelling. |
declared_verbs ¶
The action verbs a driver's own tool spec declares, in schema order.
Read back out of the spec rather than restated, so the verb list a refusal hands an agent is the one the schema really carries. A hand-copied list drifts the moment a verb is added or narrowed, and the agent then corrects itself towards a verb that is not there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool_spec
|
ToolSpec | dict[str, Any]
|
The driver's :attr: |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The declared verbs, in the order the schema lists them. |
undeclared_verb_error ¶
Refuse an action the driver's own tool spec does not declare.
The enum a tool_spec carries describes the verbs an agent may
send; nothing between the model and :meth:HardwareDriver.stream enforces
it. A dispatcher whose last branch is a bare else therefore runs its
final verb for every value the enum does not cover, and on these drivers
the final verb is the write one - so a typo, a stale verb from an earlier
schema, or a verb borrowed from a sibling driver halted the robot and
answered status="success", leaving the caller unable to tell that its
own verb had not been dispatched.
One owner rather than one per driver, because the list the refusal quotes
has to come off the same schema the agent planned against
(:func:declared_verbs).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
driver
|
Any
|
The driver that was invoked; named in the refusal, and read for
its :attr: |
required |
action
|
Any
|
Whatever arrived in the tool input, quoted back verbatim - it is not necessarily a string, and a caller cannot correct a value the refusal does not show. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A |
policy_step ¶
Return the one-step callable for policy_object, or None.
:meth:HardwareDriver.run_policy types its first argument
:class:~strands_robots.policies.Policy, and that class declares exactly
two ways to ask for an action: :meth:~strands_robots.policies.Policy.get_actions
and its synchronous wrapper
:meth:~strands_robots.policies.Policy.get_actions_sync. It declares no
step and no __call__, and no subclass in this package adds either -
so a driver whose admission asks for step refuses every policy the
package builds, while accepting objects the seam does not type. Resolving
the shapes here, once, is what keeps the admission a driver performs and the
call its loop makes describing the same set.
Three shapes are legitimate and all three resolve:
- a built :class:
~strands_robots.policies.Policy, called asget_actions_sync(observation, instruction)- the typed contract; - an object exposing
step(observation)- the shape a control-loop policy written against a native driver already uses; - a bare callable
policy(observation).
An action chunk is unwrapped to its first action.
:meth:~strands_robots.policies.Policy.get_actions returns a list whose
length is the chunk horizon, but a native control loop commands one frame
per step, so it needs the dict rather than the list - the same first-action
convention :mod:strands_robots.hardware_robot applies when it consumes a
chunk. Unwrapping for all three shapes rather than only the typed one keeps
a single return contract: the returned callable answers with an action dict,
or None when the policy produced no action for this step.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
policy_object
|
Any
|
The candidate policy. |
required |
instruction
|
str
|
Instruction bound into the |
required |
Returns:
| Type | Description |
|---|---|
Callable[[dict[str, Any]], Any] | None
|
A callable taking an observation dict and answering with the action the |
Callable[[dict[str, Any]], Any] | None
|
policy commanded - or |
Callable[[dict[str, Any]], Any] | None
|
three shapes, which is the refusal a driver's admission renders. What |
Callable[[dict[str, Any]], Any] | None
|
that callable answers is not narrowed to a dict: each loop validates |
Callable[[dict[str, Any]], Any] | None
|
the action's shape against its own wire and names its own refusal, and |
Callable[[dict[str, Any]], Any] | None
|
widening here would hide the value that refusal has to quote. |
decode_motor_state ¶
Decode a Unitree LowState_.motor_state array into per-joint readings.
Both Unitree drivers subscribe rt/lowstate and both are handed the same
fixed-length motor_state array, addressed by wire slot: the G1's
unitree_hg layout declares 35 slots of which
:data:~strands_robots.drivers.g1._G1_JOINT_INDEX names 29, and the Go2's
unitree_go layout is read through
:data:~strands_robots.drivers.go2.GO2_JOINT_INDEX's 12. The array is the
only proprioception either robot publishes, so a driver that does not read
it commands PD targets it cannot check against a measured pose.
One decoder rather than one per driver, for the reason the vector readers are written once: two copies of a coercion rule drift into disagreeing about which values are readings, and the copy that never gets audited is the one still manufacturing numbers.
Every field is read through getattr(motor, name, None) and coerced by
:func:telemetry_float / :func:telemetry_int, so a name a firmware
revision drops lands None in the record rather than a typed default. A
zero here is not a harmless placeholder: q=0.0 is a valid reading of a
joint at its zero position, so a defaulted read of a renamed field
publishes a plausible pose - and on the G1 that pose is what a proprioceptive
policy is handed at 500 Hz.
A slot the array cannot answer is skipped rather than defaulted, so a firmware carrying fewer slots than the index names costs those joints and not the rest of the frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
motors
|
Any
|
The |
required |
index
|
Mapping[str, int]
|
Joint name to wire slot, as the driver's own index table declares it. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, dict[str, Any]] | None
|
A mapping of joint name to a |
dict[str, dict[str, Any]] | None
|
record for every slot the array answered, or |
dict[str, dict[str, Any]] | None
|
absent, bytes-like (a buffer indexes to integers, which carry none of |
dict[str, dict[str, Any]] | None
|
these names and would read as a full-width record of |
dict[str, dict[str, Any]] | None
|
answered no slot at all. |
dict[str, dict[str, Any]] | None
|
a different fact from a robot reporting no joints. |
Native driver registry¶
Choose which driver builds a mode="real" robot, and find it.
Three questions, kept apart because they have different answers:
- Which driver? :func:
resolve_driver- a name, from the caller'sdriver=or the robot's registry entry; otherwise the native driver when one is registered for the robot, and :data:~strands_robots.registry.DEFAULT_DRIVER(lerobot) when none is. - Which class? :func:
get_native_driver_class- the class a native driver package registered for this robot, orNone. - Which drivers could? :func:
list_driver_coverage- for every registered robot, the driver names able to build it, joined from this table and the package registry's declared lerobot types. Resolution picks one of them; the join is the only surface that reports the robots for which there is nothing to pick.
The native table starts empty and is filled by :func:register_native_driver
- by the drivers this package ships on import, and by any driver package. That
is the seam - the point where an implementation that is not lerobot-shaped can
be reached by :func:~strands_robots.robot.Robot without the factory knowing
anything about it. Registering a driver is also what makes it the robot's
default: a robot that declares no hardware.driver is built by its native
driver once one exists, and by lerobot until then.
resolve_driver ¶
Decide which driver name builds canonical.
Precedence, highest first: the caller's explicit choice, the robot's
registry hardware.driver, then :data:~strands_robots.registry.NATIVE_DRIVER
when a native driver is registered for the robot, then
:data:~strands_robots.registry.DEFAULT_DRIVER (lerobot) for a robot this
package cannot drive itself. "auto" and None both mean "no explicit
choice", so they defer to the registry and the native table.
The native driver wins over lerobot for an undeclared robot because it is the
one that works out of the box: it needs no lerobot extra (torch), no lerobot
calibration file, and speaks the servo bus this package maintains. A robot
whose documented surface is lerobot's (the EarthRover's teleop reads) keeps
lerobot by declaring hardware.driver: "lerobot" on its registry entry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
canonical
|
str
|
Canonical robot name (or any alias -
:func: |
required |
explicit
|
str | None
|
The caller's |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A concrete driver name - never |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
register_native_driver ¶
Register driver_cls as the native driver for canonical.
The surface is checked here rather than at build time, because here is where
the mistake is made: a driver missing stream registers fine and then
fails on the first agent call, one process and several minutes away from the
line that is wrong.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
canonical
|
str
|
Robot name the driver drives; resolved through
:func: |
required |
driver_cls
|
type
|
The class to build. Must satisfy
:class: |
required |
overwrite
|
bool
|
Replace an existing registration instead of refusing it. |
False
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
get_native_driver_class ¶
Return the native driver class for canonical, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
canonical
|
str
|
Robot name or alias. |
required |
Returns:
| Type | Description |
|---|---|
type | None
|
The registered class, or |
type | None
|
robot - which is every robot until a driver package registers one. |
list_native_drivers ¶
Report which robots have a native driver.
The discovery surface behind the refusal
:func:~strands_robots.robot.Robot raises for driver="strands" on a
robot with no native driver: a caller who asked for one needs to see what
is available, not only that their choice was not.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Canonical robot name -> driver class name, sorted by robot name. |
list_driver_coverage ¶
Report which drivers can build each registered robot, for every robot.
:func:list_native_drivers answers half the question and
:func:~strands_robots.registry.get_hardware_type the other half, and
neither is a complete answer to "can I drive this robot for real". A robot
may be reachable through lerobot only, through a native driver only, through
both, or through neither -- and the last group is the one no existing surface
reports at all, because it is defined by two absences. Assembling it by hand
is how a coverage list goes stale: a robot that gained a native driver still
reads as a gap until someone re-runs the join.
Reports what the two registries declare, so it needs no optional dependency and no hardware:
"lerobot"when the robot declares ahardware.lerobot_type. That is the type string :class:~strands_robots.hardware_robot.Robothands to lerobot'sRobotConfig; whether lerobot is installed is a property of the environment, not of the robot, so it is deliberately not consulted."strands"when a native driver is registered for the robot -- by this package's own :data:~strands_robots.drivers._SHIPPED_DRIVERS, or by any driver package that has called :func:register_native_driver.
Both names are :data:~strands_robots.registry.DRIVER_CHOICES values, so
an entry reads as the set of driver= arguments that can build that robot.
Which one wins for a robot that has both is :func:resolve_driver's
answer, not this one's: coverage is about what exists, resolution about what
is chosen.
Returns:
| Type | Description |
|---|---|
dict[str, tuple[str, ...]]
|
Canonical robot name -> the driver names that can build it, sorted by |
dict[str, tuple[str, ...]]
|
robot name. An empty tuple means the robot is simulation-only: neither |
dict[str, tuple[str, ...]]
|
registry can reach hardware for it, and |
dict[str, tuple[str, ...]]
|
go until a driver is written or a lerobot type is declared. |
driver_choice_error ¶
Report why value is not a driver name, or None if it is one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
object
|
The candidate driver name. |
required |
param
|
str
|
Parameter name to quote in the reason. |
required |
context
|
str
|
Calling surface to quote in the reason. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
A reason naming the accepted values, or |
str | None
|
one of :data: |
Shipped drivers¶
Driver seam for Robot(..., mode="real").
:class:~strands_robots.drivers.base.HardwareDriver is the contract a real
robot satisfies; :mod:strands_robots.drivers.registry decides which
implementation a given robot gets. A driver package that is not lerobot-shaped
registers itself here::
from strands_robots.drivers import register_native_driver
register_native_driver("unitree_g1", G1Driver)
and Robot("unitree_g1", mode="real", driver="strands") then builds it. The
drivers shipped in this package register themselves from :data:_SHIPPED_DRIVERS
on import.
shipped_robot_names ¶
Resolve one table entry's robot names against the driver's own module.
Shared with the test that grades every shipped driver against the seam, so the names it checks are the names actually registered rather than a second reading of the same table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
object
|
The driver's imported module. |
required |
names
|
tuple[str, ...] | str
|
A literal tuple of canonical names, or the name of an attribute on module holding them. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, ...]
|
The canonical robot names for that entry. |