Menlo

Reference

The public surface of menlo-sdk: Robot, connection configs, preflight, Sent, State, media, the store, transports and errors.

Everything here is importable from menlo.asimov unless a module is named. menlo.__version__ is the distribution version. Units are metres per second, radians per second, radians, degrees Celsius.

Where to Start

Do thisUse
say where the robot isConnection, Robot
check it is readypreflight, armed
balance in place, walk, strafe, turnbalance, set_velocity
stand up, or go compliantstand, damp
pose a jointset_joints, trajectory
wait for somethingwait_until; stand, balance and damp wait for their robot mode themselves
read what it reportsState, get_state
check what it can dohas, require, RobotInfo
see what went outSent, Outcomes
pictures and soundMedia
record a runrecord
save a robot for next timeStore and CLI
handle failureErrors

Connection

ConnectionConfig(udp=None, livekit=None, limits=None, mode=None, name=None, hints={})
Member
udp: UdpConfig | Nonethe robot's address on the local network
livekit: ManagerConfig | LiveKitConfig | Nonethe room, through Asimov Manager or directly
limits: Limits | Nonevelocity limits for this robot
mode: ConnectMode | Nonethe connection mode connect() uses when given none
namethe saved robot this config came from, if any
available_modes() -> tuple[ConnectMode, ...]which of "udp", "hybrid", "livekit" this config can reach
default_mode() -> ConnectModemode when set, else only_mode()
only_mode() -> ConnectModethe mode the set slots imply: udp alone is udp, livekit alone is livekit, both are hybrid; ConnectError with none set
ConnectionConfig.from_environment(environ=None, *, robot=None)from MENLO_*, else the saved robot
transport_for(mode, ...)builds the transport for a mode
Config
UdpConfig(host, command_port=8850, state_bind=("0.0.0.0", 8851), state_source=None)commands to host:8850, state on state_bind. state_source pins the one address state may come from; without it, samples that do not match this robot are dropped
ManagerConfig(url, credential, label=None, timeout=5.0)Asimov Manager mints the room and a join token per connect. .host; .resolve(); .check() -> ManagerGrant(url, room, identity, role) validates the credential without joining
LiveKitConfig(url, room, token)room details you hold; token is a string or a callable minting one per join. One token is one participant
default_label()the label the SDK uses for this host

The LiveKit identity is sdk-<credential id>-<host>-<6 random hex>; robot.info.endpoint reads room@url as <identity> on livekit and host:8850 + room@url as <identity> on hybrid. Two sessions with the same label evict each other.

Robot

Robot(source: ConnectionConfig | Transport | None = None, *, limits=None, link_timeout=2.0)

None resolves the robot from the environment, else the saved robot. limits here wins over MENLO_LIMITS, which wins over the saved robot's limits, which win over Limits().

connect

connect(mode=None, *, timeout=5.0, media_timeout=3.0, connect_timeout=10.0,
        allow_version_skew=False, require_state=True, persist=None) -> Robot

Attach on "udp", "hybrid" or "livekit"; mode may be left out when the config names one or can reach only one. Returns once the first State has arrived and its protocol version matches. Raises ConnectError before touching the network when the config lacks what the mode needs, naming the menlo robots add command that fixes it. media_timeout bounds the wait for camera, microphone and speaker tracks. require_state=False returns as soon as the connection is open. persist=True, or MENLO_PERSIST=1, saves the robot after a successful connect with a ManagerConfig. close() then connect() again to switch modes.

open(*, timeout=5.0, allow_version_skew=False, require_state=True) does the same over a Transport passed to the constructor. RuntimeError on an open robot.

Preflight

Action = Literal["stand", "move", "trajectory"]
preflight(action: Action = "move") -> Preflight
armed: bool | None

preflight reads the latest state, sends nothing and never raises for a robot condition. stand() runs it for "stand", balance() from STAND and set_velocity() for "move", set_joints() and trajectory() for "trajectory", before they send; see Verbs. armed is True in MOVE and in STAND once the robot has been upright (gravity z below -0.87) for 0.5 s since STAND began; False in DAMP or before; None with no state, closed, in UNKNOWN, or in STAND with no gravity.

Preflight member
actionthe action checked
problems: tuple[Problem, ...]blocking first
state: State | Nonethe sample the check read
armed: bool | None
ok: boolno blocking problem
blocking: tuple[Problem, ...]
has(code) -> bool
str()ready to move, or not ready to move: followed by one - code: message line each, warnings suffixed (warning)
explain()one line: not ready to move: message (code); fix. ..., blocking problems only; the message of the NotReadyError a command raises

Problem(code, message, blocking); str() is code: message. The codes and their meaning are in Check Before You Move. The thresholds: state older than 0.5 s is stale, battery below 20 %, a joint at or above 60 °C, armed means gravity z below -0.87 for 0.5 s.

Verbs

Each returns a Sent. The robot reports no per-command verdict: Sent.outcome stays None and wait_outcome() returns Unknown in every mode. Read the effect from robot.get_state().

set_velocity, stand, balance from STAND, set_joints and trajectory run preflight() for their action before they send. A passing check reads one cached sample and adds no delay. A blocking problem that clears on its own (no_state, stale_state, not_armed, or DAMP still reported just after a stand()) is waited out for up to timeout seconds; any other raises NotReadyError at once, a latched fault RobotFaultedError, and nothing is sent. balance in MOVE and damp are never checked.

set_velocity

set_velocity(vx=0.0, vy=0.0, vyaw=0.0, *, duration=None, wait=None, hold=True,
             timeout=None) -> Sent

Walk in MOVE. Checks "move": from STAND it raises NotReadyError (wrong_mode; balance() it first), from DAMP the same (stand() it first), and nothing is sent; a stale_state is waited out up to timeout (5 s by default). Clamped to limits; re-sent at 10 Hz until balance(), another verb, close(), or duration seconds pass, when zero is sent. wait defaults to True: block until the hold has ended and its zero has gone out, or another verb took over; that needs a duration, else ValueError. wait=False returns once sent and keeps the velocity until the next command, or until duration. hold=False sends exactly one packet and holds nothing, for a loop that clocks its own commands; the check is made once, as in trajectory(), and duration or wait=True with it is a ValueError. ValueError on a non-finite value or a non-positive duration. See Move the Robot.

balance

balance(*, timeout=5.0, wait=True) -> Sent

Zero velocity: the robot is in MOVE and the walking policy balances it in place. In MOVE, never checked; it ends any velocity hold and sends zero, so this is how a walk ends. In STAND, checks "move" first, waiting up to timeout for not_armed to clear, then sends zero velocity, which puts the robot in MOVE; with wait, returns once the robot reports MOVE, WaitTimeoutError when the whole call passes timeout. In DAMP, FAULT_DAMP or UNKNOWN it raises NotReadyError (RobotFaultedError for a latched fault) and sends nothing. Not an emergency stop.

stand

stand(*, timeout=10.0, wait=True) -> Sent

Put the robot in STAND: the actuators hold the standing pose, without a balance loop. Checks "stand": from MOVE it raises NotReadyError (wrong_mode) and sends nothing, since a free-standing robot tips over. Sends STAND once and, with wait, returns when the robot reports STAND and is armed; WaitTimeoutError when the whole call passes timeout, RobotFaultedError when a fault latches meanwhile. wait=False returns once sent. Ends a running set_joints() and a held velocity.

damp

damp(*, timeout=5.0, wait=True) -> Sent

Every actuator compliant now. A standing robot folds to the ground. Never checked: DAMP is sent whatever the robot's state. With wait, returns once the robot reports DAMP or FAULT_DAMP, WaitTimeoutError after timeout. Not an emergency stop.

set_joints

set_joints(positions, *, duration=2.0, hz=50.0, kp=None, kd=None,
           wait=True, tolerance=0.05, timeout=None) -> Sent

Checks "trajectory" (MOVE or an armed STAND; a stale pose is waited out), then interpolates from the last reported pose to positions over duration, clocking trajectory() setpoints from a thread at hz, then holds the target until another verb. Any verb from any thread ends it before its next setpoint leaves. wait=True blocks until every joint is within tolerance rad, WaitTimeoutError otherwise. An ankle target more than 0.02 rad past the firmware's limits (0.35 rad pitch, 0.1 rad roll) is a ValueError before anything is sent. timeout bounds the whole call; left None, the check may wait 5 s and the target duration + 2 s. See Control Joints.

trajectory

trajectory(positions, *, kp=None, kd=None, timeout=0.0) -> Sent

One setpoint, radians, firmware order; clock these yourself or use set_joints. Checks "trajectory" once, with no delay when it passes, so a loop at 50 Hz can call this. When the robot is not ready it raises NotReadyError at once and sends nothing; pass timeout to wait that long instead. ValueError unless len(positions) == info.dof, or when only one of kp and kd is given. A gain of 0 is not "no gain": the firmware substitutes its damping constants, so the joint does not hold a position. None applies the robot's per-joint gain table. The robot enters DAMP 2 s after the last setpoint.

Waits

Every wait reads the robot's own report, checks for a fault before its predicate, and refuses to succeed on a stale stream. stand(), balance(), damp() and set_joints(wait=True) wait this way for their own effect.

wait_until

wait_until(predicate, *, timeout=10.0, stale_after=None, poll=0.05) -> State

Blocks until predicate(state) is true and returns that first State. Raises WaitTimeoutError (the message names the robot mode and the age of the last sample), StateStaleError when the stream goes quiet for stale_after, and RobotFaultedError when a fault latches. stale_after defaults to link_timeout. A wait for one robot mode is wait_until(lambda s: s.mode is Mode.MOVE).

Properties and Callbacks

NameTypeNotes
configConnectionConfigwhat this Robot was bound to
infoRobotInfotransport, endpoint, dof, joint names, protocol version, limits, capabilities
has(cap), require(*caps)bool, raises UnsupportedErrorthis connection's set: drive, state, battery, camera, microphone, speaker
camera, microphone, speakerCamera, Microphone, Speakersee Media; UnsupportedError when the connection does not carry them
record(path)Recordingcontext manager: every state sample and every command as JSON lines
get_state()Statethe latest sample; NotConnectedError before the first
armedbool | Nonesee Preflight
connectedboolopen and not LinkLostError
outcomes()Iterator[Refused]received refusals, oldest first; empty with this robot
on_stateCallable[[State], None] | Noneevery accepted sample, on the transport thread
on_alertCallable[[Alert, str], None] | Nonean alert "raised" or "cleared"
on_mode_changeCallable[[Mode, Mode], None] | Nonebefore, after
on_refusedCallable[[Refused], None] | Nonenever fires with this robot
on_link_lostCallable[[LinkLostError], None] | None
close()zero velocity if held, drop the link; idempotent; never raises

Robot is a context manager; __exit__ calls close().

Sent

MemberTypeMeaning
namestrthe verb: set_velocity, balance, stand, damp, trajectory; set_joints() sends its setpoints as trajectory
sequenceintthe command's sequence number
commandVelocity | ModeCommand | Trajectorywhat went out, after clamping
clampedboolcommand differs from what you asked
sent_atfloattime.monotonic()
outcomeApplied | Refused | Nonenon-blocking; None while pending
wait_outcome(timeout=None)Applied | Refused | UnknownNone uses the transport default
require(timeout=None, *, unknown_ok=True)as aboveraises CommandRefusedError; with unknown_ok=False also OutcomeUnknownError

Outcomes

TypeFields
Appliedsequence
Refusedsequence, reason: Refusal, detail: str (diagnostic text; never branch on it), verb
Unknownsequence, waited_s, verb

Refusal (IntEnum): UNSPECIFIED, FW_DAMPED, FAULT_DAMPED, GATE, UNSUPPORTED_SOURCE, BAD_LENGTH, EMPTY_TRAJECTORY, NON_FINITE, PARSE, UNKNOWN_COMMAND, UNKNOWN_MODE, SHUTTING_DOWN, NO_CAMERA, NOT_ACTIVE, UNRECOGNIZED; retryable property; Refusal.from_wire().

State

@dataclass(frozen=True)
class State:
    mode: Mode                                   # DAMP | STAND | MOVE | FAULT_DAMP | UNKNOWN
    joints: tuple[Joint, ...]                    # firmware order
    gravity: tuple[float, float, float] | None   # body frame; z near -1 when upright
    gyro: tuple[float, float, float] | None      # rad/s
    quat: tuple[float, float, float, float] | None   # w, x, y, z
    error_flags: int
    alerts: tuple[Alert, ...]
    sequence: int
    fw_timestamp_us: int
    protocol_version: int
    battery: Battery | None                      # None when the robot reports no battery
    received_at: float
    edge_timestamp_us: int                       # Asimov Edge's wall clock (µs); livekit only, 0 on udp and hybrid
    # properties
    age_s: float
    upright: bool | None                         # gravity z < -0.8; None without gravity
    euler: tuple[float, float, float] | None     # roll, pitch, yaw (rad) from quat
    yaw: float | None                            # rad, counter-clockwise, (-pi, pi]
    faulted: bool                                # FAULT_DAMP, error_flags or any critical alert
    joint_pos: tuple[float, ...]
    def joint(self, name: str) -> Joint          # KeyError on an unknown name

Joint(name, pos, vel, current, temp): vel, current and temp are None when the robot did not report them. Alert(id, severity, value, threshold, source_id, first_set_us=0) with critical (severity == 0) and name. Battery(voltage_v, current_a, soc_percent, max_cell_temp_c, protection: BatteryProtection) with charging and protecting; BatteryProtection is an IntFlag.

RobotInfo(transport, endpoint, dof, joint_names, protocol_version, limits, capabilities) with joint_index(name), has(capability) and a readable str().

Limits(vx=0.4, vy=0.4, vyaw=0.8): the defaults are the firmware caps (0.4 m/s, 0.4 m/s, 0.8 rad/s). Limits.parse("0.3,0.3,0.6"). Negative or non-finite values raise ValueError.

Media

robot.camera.photo(*, timeout=5.0) -> Frame     # one fresh frame; WaitTimeoutError when quiet
robot.camera.latest() -> Frame | None           # newest frame; None before the first
robot.camera.frames(*, timeout=5.0)             # iterator, always the newest frame
robot.camera.subscribe(cb)                      # cb(Frame) on the transport thread
robot.camera.stream(*, timeout=5.0)             # every frame, in order
robot.camera.capture_clip(seconds, audio=True) -> Clip
robot.microphone.chunks(*, timeout=5.0)         # every AudioChunk in order; .dropped counts loss
robot.speaker.play(chunk)
robot.speaker.play_pcm(pcm_s16le, sample_rate_hz=16000, channels=1)

Frame(width, height, encoding, data, stride_bytes, key_frame, frame_id, timestamp_ns, sequence) with shape, age_s, to_numpy(), to_jpeg(quality=85). AudioChunk(sample_rate_hz, channels, samples_per_channel, encoding, data, stream_id, timestamp_ns, sequence) with duration_s, to_numpy(). Clip(frames, audio, started_at) with duration_s, fps, save_wav(path) -> Path, save_frames(dir), save_mp4(path, *, fps=None) -> Path, frames_as_numpy(). numpy, Pillow and OpenCV are needed only at the call that uses them, and named in the ImportError.

Recording

with robot.record("run.jsonl") as rec:
    ...
rec.samples, rec.commands_written

from menlo.asimov import recording
for line in recording.load("run.jsonl"):        # dicts with kind "state" | "sent", t, mode
    ...

Store and CLI

~/.menlo/robots.toml (MENLO_HOME moves it), 0600 in a 0700 directory.

RobotStore()get(name=None), put(robot, *, default=None, allow_manager_change=False), remove(name), use(name), names, save(); len(), iteration and in
StoredRobot(name, manager_url=None, credential=None, room=None, mode=None, udp_host=None, limits=None)resolved_mode(), missing(mode), fix_command(mode), manager(label=), connection(label=)
menlo.asimov.store.persist(config, room, *, name=None, mode=None)what connect(persist=True) calls: records the manager, credential, room, udp_host, mode and limits; ValueError without a ManagerConfig
Command
menlo setup [--no-check]wizard: name, mode, that mode's fields, live checks
menlo robots [--json]the saved robots
menlo robots add NAME --mode --udp --manager --credential --limits VX,VY,VYAW [--default] [--no-check] [--no-input]add or update; unspecified values are kept
menlo robots remove NAME, menlo robots use NAMEforget, make default
menlo status [--watch] [--json]readiness, robot mode, battery, hottest joint, faults, state rate
menlo stand [-y], menlo balance [-y], menlo walk --vx --vy --vyaw --duration S [-y], menlo damp [-y]the verbs; each prints a plan and asks Proceed? [y/N], -y/--yes goes ahead without asking. stand, balance and walk run the readiness check first and print Not feasible: when it refuses; balance in MOVE sends at once without asking; damp is not gated
menlo --robot NAME --mode MODE, menlo --version

Environment: MENLO_ROBOT, MENLO_MODE, MENLO_UDP_HOST, MENLO_MANAGER_URL, MENLO_CREDENTIAL, MENLO_LIMITS, MENLO_HOME, MENLO_PERSIST. MENLO_MANAGER_URL and MENLO_CREDENTIAL go together; one alone is a ConnectError. Environment wins over the saved robot and is not merged with it. See Command Line.

Transports

UdpTransport(host, *, command_port, state_bind, state_source), LiveKitTransport(url, room, *, token, media_timeout, connect_timeout), HybridTransport(host, *, livekit_url, room, token, command_port, state_bind, ...). All implement Transport; a ConnectionConfig builds the right one for the mode. LiveKitTransport.identity and .room read back what the token claimed.

Errors

MenloError
  ConnectError
    ProtocolMismatchError(expected, observed)
  NotConnectedError
  LinkLostError
  NotReadyError(message, *, action, preflight)      # .problems, .has(code)
    RobotFaultedError(message, *, state, sent=None)  # plus action, preflight
  WaitTimeoutError(message, *, last, sent=None)     # also TimeoutError
    StateStaleError
  CommandRefusedError(refused)
  OutcomeUnknownError(unknown)      # also TimeoutError
  UnsupportedError(capability, transport)

NotReadyError reads not ready to <action>: <message> (<code>); <fix>. ..., with (waited <t> s) appended when a transient problem did not clear in time; nothing was sent. RobotFaultedError is raised by a command's check with nothing sent, and by a wait after a command went out, when sent is set. WaitTimeoutError.sent is the command whose effect did not arrive. Caller mistakes are builtins: ValueError (non-finite velocity, limit or duration, wrong trajectory length, a lone kp or kd, an unknown connection mode), KeyError (unknown joint name), RuntimeError (open() on an open robot). A mode the config cannot reach raises ConnectError.

Threading

Robot is synchronous and thread-safe. Callbacks run on the transport thread, the one that delivers state: keep them short. robot.damp() from a callback, such as on_alert damping on an alert, sends DAMP and returns at once. The other commands that wait for the robot, and wait_until(), raise RuntimeError there with nothing sent; pass wait=False, or call them from your own thread.

Per-Robot Tables

menlo.asimov.robots.ASIMOV_1_BIPED_JOINTS is the 25-actuator order of the Asimov 1 biped firmware profile, chosen by the reported joint count. menlo.asimov.robots.PROTOCOL_VERSION is the protocol version the SDK was built against; connect() compares it with what the robot echoes and raises ProtocolMismatchError on a difference.

The Protocol

Commands on udp go to port 8850 and state arrives on 8851, each datagram one bare asimov.io.RobotCommand or RobotState. On livekit, commands are data messages on the commands topic and state is the state data track. Every command carries protocol_version = 1, a sequence number and timestamp_us. UDP commands are not authenticated; the SDK credential and its role apply to the LiveKit room: every command on livekit, and the camera and audio on hybrid. Without the SDK and the livekit_raw/ examples speak it with the livekit and asimov-protocol packages alone.

How is this guide?

On this page