Share a Robot Across Processes#
SharedRobot lets one process own a robot’s exclusive hardware connection
while any number of other processes read its state and send actions over
Zenoh. It satisfies the same Robot protocol as a
direct driver, so it is a drop-in replacement anywhere a robot is expected
(including RobotRuntime).
Install#
pip install "physicalai[transport]"
Create or attach#
Every SharedRobot has a required, caller-chosen logical name — it keys
the Zenoh topics directly. The first SharedRobot constructed for a given
name that finds no existing owner spawns one (in a detached subprocess);
later instances (same or different process, same name) attach to it.
Construction uses robot= or from_config(). Prefer from_config() when you
already have a recipe. A disconnected @export_config driver can be passed
directly to the constructor or exported explicitly:
import numpy as np
from physicalai.config import Config
from physicalai.robot import SO101, SharedRobot
driver = SO101(
port="/dev/ttyUSB0",
calibration="~/.cache/calibration/so101.json", # path stays relative/as given
)
robot = SharedRobot.from_config(driver, name="left-arm")
# equivalent: SharedRobot.from_config(Config.from_instance(driver), name="left-arm")
# or: SharedRobot("left-arm", robot={"class_path": "physicalai.robot.SO101", "init_args": {...}})
robot.connect()
obs = robot.get_observation() # pull latest state, non-blocking
robot.send_action(np.asarray(obs.joint_positions), goal_time=0.1)
robot.disconnect() # detaches; the owner keeps running
Any importable @export_config robot class works (including third-party
plugins) — pass its public class_path + init_args; there is no flat
robot_class / robot_kwargs API.
Serve a robot in the foreground#
Use the operator command when a shell, systemd, Docker, or Kubernetes should own the robot lifecycle:
physicalai robot serve --config examples/so101/serve.yaml
The command constructs and connects the driver in its own foreground process. Normal
output reports readiness, state-subscriber presence changes, a health summary every
30 seconds, and clean shutdown. Add --verbose for startup and cleanup details. The
command does not daemonize; use your service manager for background supervision.
List reachable owners without importing their advertised driver class:
physicalai robot discover
physicalai robot discover --json
physicalai robot discover --allow_remote
Discovery is local-only unless --allow_remote is explicit. Human output is a sorted
ASCII table. JSON mode writes one sorted array to stdout, including [] when no robot
answers.
Notes:
get_observation()returns the newest owner-published state; if no new sample arrived since the last call, the cached last-known observation is returned. Staleness is visible viaobs.timestamp.obs.stateis the owner-computed state vector (e.g. WidowXAI ships positions + velocities, 14 values), so model inputs are correct without robot-specific logic on the subscriber side.obs.imagesis alwaysNone— camera frames go throughSharedCamera(the capture transport), not the robot transport.Actions are absolute joint targets, delivered latest-wins and fire-and-forget. When no action is pending, the owner holds the last commanded position.
Attach to a known owner#
For manually-launched or remote owners, attach by name only — no construction recipe needed:
from physicalai.robot import SharedRobot
robot = SharedRobot.attach("left-arm")
robot.connect()
Enumerate reachable robots with:
from physicalai.robot.transport import discover_robots
for metadata in discover_robots():
print(metadata["name"], metadata["robot_class"], metadata["joint_names"])
Reuse a remote client#
For a long-running process that repeatedly discovers or attaches to remote
owners, use SharedRobotClient(allow_remote=True). It keeps one Zenoh
scouting session open, so the first operation establishes remote routes and
later operations reuse them:
from physicalai.robot.transport import SharedRobotClient
with SharedRobotClient(allow_remote=True) as client:
robots = client.discover()
robot = client.attach(robots[0]["name"])
robot.connect()
# Later discovery reuses the established remote session.
robots = client.discover()
SharedRobotClient is attach-only: it never starts an owner process. It
disconnects every robot created through attach() before closing its shared
session on context exit. Its first discover() call uses a one-second budget
for Zenoh scouting; later calls use 0.1 seconds with the warmed session.
Pass timeout= to override either budget. The timeout remains a wildcard-query
collection window, so use a larger explicit value when a more complete inventory
is needed after a network or owner change. SharedRobotClient() also works for
same-host owners; omit allow_remote=True to keep that client local-only.
Network scope: local-only by default#
allow_remote=False (the default) keeps the owner’s Zenoh session
unreachable off-host — multicast/gossip scouting is disabled and the owner
listens on 127.0.0.1 only. Same-host spawn-or-attach still works without
depending on multicast: owner and subscriber derive the same deterministic
loopback port from name.
Opt into cross-host reachability explicitly when you need it:
robot = SharedRobot.from_config(
{
"class_path": "physicalai.robot.SO101",
"init_args": {"port": "/dev/ttyUSB0", "calibration": "calibration.json"},
},
name="left-arm",
allow_remote=True,
)
Each caller has its own Zenoh session. For an attacher, allow_remote
controls only that session’s ability to find remote owners. For the caller
that spawns an owner, it also fixes the owner’s reachability for its
entire lifetime. Later attachers cannot change an existing owner’s scope.
See Security below.
Remote owners omit physical device_ids from their /metadata
responses. Other discovery information, including the logical name, driver
class, host, joint layout, and state dimensions, remains visible to reachable
peers.
Physical device identity vs. logical name#
name is what you choose and what keys the Zenoh topics — it never
requires constructing a driver to resolve. Physical device identity
(Robot.device_ids, e.g. ("serial:ttyUSB0",)) is a separate concern the
owner uses to enforce host-local exclusivity: two different names
cannot claim the same physical device at the same time
(RobotDeviceAlreadyOwned), and a race for the same name with
different devices is rejected (RobotNameConflict) rather than silently
picked by whichever process happened to start first.
An existing owner’s advertised robot_class is compared against yours only
as a diagnostic (logged on mismatch, never fatal, and never imported from
the network) — subclasses, wrappers, and re-exports can all preserve the
wire contract.
Owner lifecycle#
One owner process per
name, and one owner per physical device — both enforced by host-local, crash-safeflocklocks (under$XDG_RUNTIME_DIR/physicalai/robot-locks/on Linux, or a private per-user temporary directory elsewhere on Unix). If two processes race to spawn the samename, the loser attaches (same devices) or raisesRobotNameConflict(different devices).The owner runs a single write-first control loop at a fixed rate (
rate_hzspawn parameter, default 100 Hz; override per instance when hardware measurements justify a different value).When the last subscriber disconnects (cleanly or by crashing), the owner waits
idle_timeoutseconds, then calls the driver’sdisconnect()— honoring the safe-state contract (hold/home) — and exits.An explicit
physicalai robot serveowner has no idle timeout. It remains in the foreground until interrupted or until the owner loop fails.A subscriber’s
disconnect()never stops the robot’s motors; the owner owns safe-state.Subscribers reject an owner advertising an unsupported transport protocol version before ever declaring the action publisher.
Security: trusted network required#
In allow_remote=True mode, this transport applies any action received on
its Zenoh /action key without authentication and encryption — any peer that can
reach the owner’s Zenoh session can move the physical robot. It is designed
for a trusted robot-cell network (isolated LAN/VLAN) in that mode.
Isolating the network — via VLAN/firewall segmentation or Zenoh’s own
ACL/TLS features — is the deployer’s responsibility. allow_remote=False
(the default) avoids this exposure entirely by keeping the owner
unreachable off-host.