menlo-sdk 0.1.0rc2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. menlo_sdk-0.1.0rc2/.github/workflows/ci.yml +102 -0
  2. menlo_sdk-0.1.0rc2/.github/workflows/publish.yml +131 -0
  3. menlo_sdk-0.1.0rc2/.gitignore +9 -0
  4. menlo_sdk-0.1.0rc2/.python-version +1 -0
  5. menlo_sdk-0.1.0rc2/AGENTS.md +46 -0
  6. menlo_sdk-0.1.0rc2/CHANGELOG.md +127 -0
  7. menlo_sdk-0.1.0rc2/CONTRIBUTING.md +19 -0
  8. menlo_sdk-0.1.0rc2/LICENSE +21 -0
  9. menlo_sdk-0.1.0rc2/Makefile +33 -0
  10. menlo_sdk-0.1.0rc2/PKG-INFO +119 -0
  11. menlo_sdk-0.1.0rc2/README.md +92 -0
  12. menlo_sdk-0.1.0rc2/RELEASING.md +104 -0
  13. menlo_sdk-0.1.0rc2/SECURITY.md +12 -0
  14. menlo_sdk-0.1.0rc2/docs/REFERENCE.md +308 -0
  15. menlo_sdk-0.1.0rc2/docs/SKILL.md +167 -0
  16. menlo_sdk-0.1.0rc2/examples/01_connect_and_read.py +27 -0
  17. menlo_sdk-0.1.0rc2/examples/02_stand_and_walk.py +40 -0
  18. menlo_sdk-0.1.0rc2/examples/03_outcomes.py +42 -0
  19. menlo_sdk-0.1.0rc2/examples/04_wait_and_staleness.py +39 -0
  20. menlo_sdk-0.1.0rc2/examples/05_stop_levels.py +36 -0
  21. menlo_sdk-0.1.0rc2/examples/agent_room.py +121 -0
  22. menlo_sdk-0.1.0rc2/examples/checkout.py +634 -0
  23. menlo_sdk-0.1.0rc2/examples/demos/demo_1_locomotion.py +61 -0
  24. menlo_sdk-0.1.0rc2/examples/demos/demo_2_telemetry.py +61 -0
  25. menlo_sdk-0.1.0rc2/examples/demos/demo_3_safety.py +71 -0
  26. menlo_sdk-0.1.0rc2/examples/follow_the_ball.py +180 -0
  27. menlo_sdk-0.1.0rc2/pyproject.toml +112 -0
  28. menlo_sdk-0.1.0rc2/src/menlo/__init__.py +15 -0
  29. menlo_sdk-0.1.0rc2/src/menlo/asimov/__init__.py +115 -0
  30. menlo_sdk-0.1.0rc2/src/menlo/asimov/_command.py +110 -0
  31. menlo_sdk-0.1.0rc2/src/menlo/asimov/_errors.py +115 -0
  32. menlo_sdk-0.1.0rc2/src/menlo/asimov/_media.py +523 -0
  33. menlo_sdk-0.1.0rc2/src/menlo/asimov/_outcome.py +186 -0
  34. menlo_sdk-0.1.0rc2/src/menlo/asimov/_proto.py +34 -0
  35. menlo_sdk-0.1.0rc2/src/menlo/asimov/_state.py +264 -0
  36. menlo_sdk-0.1.0rc2/src/menlo/asimov/connection.py +356 -0
  37. menlo_sdk-0.1.0rc2/src/menlo/asimov/recording.py +106 -0
  38. menlo_sdk-0.1.0rc2/src/menlo/asimov/robot.py +1180 -0
  39. menlo_sdk-0.1.0rc2/src/menlo/asimov/robots.py +55 -0
  40. menlo_sdk-0.1.0rc2/src/menlo/asimov/store.py +366 -0
  41. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/__init__.py +12 -0
  42. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/_livekit_client.py +552 -0
  43. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/_wire.py +153 -0
  44. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/base.py +79 -0
  45. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/livekit.py +319 -0
  46. menlo_sdk-0.1.0rc2/src/menlo/asimov/transport/udp.py +199 -0
  47. menlo_sdk-0.1.0rc2/src/menlo/cli.py +159 -0
  48. menlo_sdk-0.1.0rc2/src/menlo/py.typed +0 -0
  49. menlo_sdk-0.1.0rc2/tests/__init__.py +0 -0
  50. menlo_sdk-0.1.0rc2/tests/conftest.py +463 -0
  51. menlo_sdk-0.1.0rc2/tests/integration/__init__.py +0 -0
  52. menlo_sdk-0.1.0rc2/tests/integration/edge.pin +1 -0
  53. menlo_sdk-0.1.0rc2/tests/integration/test_livekit_server.py +192 -0
  54. menlo_sdk-0.1.0rc2/tests/integration/test_real_udp_connector.py +114 -0
  55. menlo_sdk-0.1.0rc2/tests/live/__init__.py +0 -0
  56. menlo_sdk-0.1.0rc2/tests/live/test_rig.py +52 -0
  57. menlo_sdk-0.1.0rc2/tests/unit/__init__.py +0 -0
  58. menlo_sdk-0.1.0rc2/tests/unit/test_cli.py +72 -0
  59. menlo_sdk-0.1.0rc2/tests/unit/test_connection.py +442 -0
  60. menlo_sdk-0.1.0rc2/tests/unit/test_features.py +298 -0
  61. menlo_sdk-0.1.0rc2/tests/unit/test_hardening.py +246 -0
  62. menlo_sdk-0.1.0rc2/tests/unit/test_livekit.py +418 -0
  63. menlo_sdk-0.1.0rc2/tests/unit/test_media.py +250 -0
  64. menlo_sdk-0.1.0rc2/tests/unit/test_modes.py +181 -0
  65. menlo_sdk-0.1.0rc2/tests/unit/test_proto_source.py +41 -0
  66. menlo_sdk-0.1.0rc2/tests/unit/test_robot.py +857 -0
  67. menlo_sdk-0.1.0rc2/tests/unit/test_store.py +244 -0
  68. menlo_sdk-0.1.0rc2/tests/unit/test_wire.py +125 -0
  69. menlo_sdk-0.1.0rc2/uv.lock +602 -0
@@ -0,0 +1,102 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ check:
17
+ name: lint + types + tests (py${{ matrix.python }})
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python: ["3.12", "3.13"]
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - uses: astral-sh/setup-uv@v6
27
+ with:
28
+ enable-cache: true
29
+ cache-dependency-glob: "uv.lock"
30
+
31
+ - name: Sync (locked)
32
+ run: uv sync --python ${{ matrix.python }} --locked --all-groups
33
+
34
+ - name: Ruff (lint)
35
+ run: uv run ruff check .
36
+
37
+ - name: Ruff (format)
38
+ run: uv run ruff format --check .
39
+
40
+ - name: No merge-conflict markers (they pass ruff, mypy and pytest inside a docstring)
41
+ run: '! git grep -nE "^(<{7}|>{7})( |$)" -- .'
42
+
43
+ - name: Mypy (strict)
44
+ run: uv run mypy
45
+
46
+ - name: Unit tests
47
+ run: uv run pytest -m "not integration and not live"
48
+
49
+ - name: Package builds and ships its bindings and type information
50
+ run: |
51
+ uv build && ls -l dist/
52
+ python -m zipfile -l dist/*.whl | grep -E 'menlo/py.typed'
53
+ unzip -p dist/*.whl '*/METADATA' | grep -E '^Requires-Dist: asimov-protocol'
54
+
55
+ # The real edge connector, in-process: proves the wire, not just the fake. Clones
56
+ # asimov-edge at the commit the SDK is developed against; skipped only when the
57
+ # token is missing, and then LOUDLY.
58
+ integration:
59
+ name: real UdpConnector (asimov-edge)
60
+ runs-on: ubuntu-latest
61
+ needs: check
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: astral-sh/setup-uv@v6
65
+ with:
66
+ enable-cache: true
67
+ cache-dependency-glob: "uv.lock"
68
+ # asimov-edge is an internal repo: the workflow's own token cannot read it. Without
69
+ # ASIMOV_REPOS_TOKEN this job warns and skips instead of failing the PR — the unit
70
+ # suite already drives the wire against a fake edge; this job is the extra proof.
71
+ - name: Token check
72
+ id: tok
73
+ env:
74
+ TOKEN: ${{ secrets.ASIMOV_REPOS_TOKEN }}
75
+ run: |
76
+ if [ -n "$TOKEN" ]; then
77
+ git config --global url."https://x-access-token:${TOKEN}@github.com/menloresearch/".insteadOf "https://github.com/menloresearch/"
78
+ echo "present=true" >> "$GITHUB_OUTPUT"
79
+ else
80
+ echo "::warning::ASIMOV_REPOS_TOKEN is not granted to this repository; skipping the real-edge integration job"
81
+ echo "present=false" >> "$GITHUB_OUTPUT"
82
+ fi
83
+ - name: Check out asimov-edge at the pinned commit
84
+ if: steps.tok.outputs.present == 'true'
85
+ run: |
86
+ EDGE_REF="$(sed -nE 's/^ASIMOV_EDGE_REF=(.*)$/\1/p' tests/integration/edge.pin)"
87
+ git clone --depth 1 https://github.com/menloresearch/asimov-edge.git /tmp/asimov-edge
88
+ git -C /tmp/asimov-edge fetch --depth 1 origin "$EDGE_REF"
89
+ git -C /tmp/asimov-edge checkout "$EDGE_REF"
90
+ - name: Drop the git credential before running anything from the PR
91
+ if: steps.tok.outputs.present == 'true'
92
+ run: git config --global --unset-all url."https://x-access-token:${{ secrets.ASIMOV_REPOS_TOKEN }}@github.com/menloresearch/".insteadOf
93
+ - name: Sync + install the edge into the same environment
94
+ if: steps.tok.outputs.present == 'true'
95
+ run: |
96
+ uv sync --locked --all-groups
97
+ uv pip install /tmp/asimov-edge
98
+ - name: Integration tests
99
+ if: steps.tok.outputs.present == 'true'
100
+ env:
101
+ ASIMOV_EDGE_SRC: /tmp/asimov-edge/src
102
+ run: uv run pytest -m integration
@@ -0,0 +1,131 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes the SDK on every v* tag.
4
+ #
5
+ # A build job proves the tag equals the package version
6
+ # and produces sdist + wheel; a separate publish job only sees the built
7
+ # artifacts and uploads them with PyPI trusted publishing (OIDC, no long-lived
8
+ # token) inside the `pypi` GitHub environment, so a required reviewer can gate
9
+ # it. workflow_dispatch builds without publishing (a packaging dry run).
10
+ #
11
+ # The version is dynamic (hatch reads src/menlo/__init__.py), so name and
12
+ # version are taken from the built wheel's METADATA, which is what PyPI sees.
13
+ #
14
+ # Setup and the tag procedure: RELEASING.md.
15
+
16
+ on:
17
+ push:
18
+ tags:
19
+ - 'v*'
20
+ workflow_dispatch:
21
+
22
+ concurrency:
23
+ group: publish-${{ github.ref }}
24
+ cancel-in-progress: false
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ build:
31
+ name: Build distributions
32
+ runs-on: ubuntu-latest
33
+ outputs:
34
+ name: ${{ steps.meta.outputs.name }}
35
+ version: ${{ steps.meta.outputs.version }}
36
+ steps:
37
+ - name: Checkout
38
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
39
+ with:
40
+ persist-credentials: false
41
+ fetch-depth: 0 # the ancestry check below needs real history, not a shallow tip
42
+
43
+ # A release is a tag on main. A tag pushed on a feature branch (or on a commit
44
+ # that never merged) must not publish; the tag ruleset and the pypi environment
45
+ # gate WHO may release, this gates WHAT. Annotated tags are peeled to the commit.
46
+ - name: Verify the tag is on main
47
+ if: startsWith(github.ref, 'refs/tags/')
48
+ env:
49
+ TAG: ${{ github.ref_name }}
50
+ run: |
51
+ # No fetch here: the checkout above (fetch-depth 0, no persisted credentials)
52
+ # already brought every branch, and a fetch would need a token it does not have.
53
+ tag_commit="$(git rev-parse "${TAG}^{commit}")"
54
+ if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
55
+ echo "::error::tag $TAG ($tag_commit) is not on main; releases are tagged on main only"
56
+ exit 1
57
+ fi
58
+ echo "tag $TAG is on main ($tag_commit)"
59
+
60
+ - name: Set up uv
61
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
62
+ with:
63
+ enable-cache: false
64
+
65
+ - name: Build sdist and wheel
66
+ run: uv build --out-dir dist
67
+
68
+ - name: Read name and version from the wheel
69
+ id: meta
70
+ run: |
71
+ python - <<'PY' >> "$GITHUB_OUTPUT"
72
+ import glob, zipfile
73
+ from email.parser import HeaderParser
74
+ (whl,) = glob.glob("dist/*.whl")
75
+ z = zipfile.ZipFile(whl)
76
+ (meta,) = [n for n in z.namelist() if n.endswith(".dist-info/METADATA")]
77
+ h = HeaderParser().parsestr(z.read(meta).decode())
78
+ print(f"name={h['Name']}")
79
+ print(f"version={h['Version']}")
80
+ PY
81
+ cat "$GITHUB_OUTPUT"
82
+
83
+ # Tags are the releases; a tag that does not match what the wheel says
84
+ # must die here, not on PyPI.
85
+ - name: Verify tag matches package version
86
+ if: startsWith(github.ref, 'refs/tags/')
87
+ env:
88
+ REF: ${{ github.ref }}
89
+ PKG_VERSION: ${{ steps.meta.outputs.version }}
90
+ run: |
91
+ tag_version="${REF#refs/tags/v}"
92
+ if [ "$tag_version" != "$PKG_VERSION" ]; then
93
+ echo "::error::Tag version ($tag_version) does not match __version__ in src/menlo/__init__.py ($PKG_VERSION)"
94
+ exit 1
95
+ fi
96
+ echo "Tag and package version match: $PKG_VERSION"
97
+
98
+ # The wheel must ship the typing marker and the licence, and declare the
99
+ # protocol dependency — the same contract ci.yml checks.
100
+ - name: Check distributions
101
+ run: |
102
+ uvx twine check dist/*
103
+ python -m zipfile -l dist/*.whl | grep -E 'menlo/py.typed|licenses/LICENSE'
104
+ unzip -p dist/*.whl '*/METADATA' | grep -E '^Requires-Dist: asimov-protocol'
105
+
106
+ - name: Upload distributions
107
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
108
+ with:
109
+ name: sdk-dist
110
+ path: dist/
111
+ retention-days: 1
112
+
113
+ publish:
114
+ name: Publish to PyPI
115
+ needs: build
116
+ runs-on: ubuntu-latest
117
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
118
+ environment:
119
+ name: pypi
120
+ url: https://pypi.org/p/${{ needs.build.outputs.name }}
121
+ permissions:
122
+ id-token: write # mandatory for trusted publishing
123
+ steps:
124
+ - name: Download distributions
125
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
126
+ with:
127
+ name: sdk-dist
128
+ path: dist/
129
+
130
+ - name: Publish package distributions to PyPI
131
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 (twine 7: knows core metadata 2.5)
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,46 @@
1
+ # AGENTS.md — how to work in menlo-sdk
2
+
3
+ Read this before changing anything. It is short on purpose.
4
+
5
+ ## What this is
6
+
7
+ A Python SDK that drives an Asimov robot **through its edge**. It is a client of the
8
+ edge's arbiter and safety layer, never a bypass. The import is `menlo`, one subpackage per
9
+ robot: everything that speaks the Asimov wire lives in `menlo.asimov`; the top level holds
10
+ `__version__` and the `menlo` console script. One `Robot`; transports implement
11
+ `transport/base.py`. Nothing in `robot.py` may know which wire it is on.
12
+
13
+ ## Rules
14
+
15
+ - **Verbs are the wire's verbs.** `set_velocity`, `stand`, `damp`, `stop`, `trajectory` —
16
+ the names the edge, the protocol and the robot's other controllers already use. Do not
17
+ invent synonyms.
18
+ - **No synchronous refusal.** A command returns a `Sent`; the verdict arrives as an
19
+ `Outcome`. `Unknown` is never success and never refusal.
20
+ - **State is the truth about effect.** Waits read `robot.state`, never infer from what was
21
+ sent. Every wait refuses to succeed on a stale stream, and a fault is reported before a
22
+ predicate is evaluated.
23
+ - **Caller bugs are builtins** (`ValueError`, `KeyError`, `RuntimeError`); robot and link
24
+ errors subclass `MenloError` and end in `Error`.
25
+ - **Nothing is guessed.** A field the robot does not report is `None`. A capability the
26
+ transport does not carry raises `UnsupportedError`. A joint table carries its provenance
27
+ (`robots.py`). A protocol version mismatch is `ProtocolMismatchError`, not a warning.
28
+ - **Describe what the code does.** No roadmap, no "yet", no review history in this repo.
29
+ Behaviour that depends on the edge or firmware is stated as the fact it is.
30
+ - **Tests fail without the fix.** Every behavioural change ships a test that goes red when
31
+ the change is reverted. The fake edge in `tests/conftest.py` speaks the real wire; if you
32
+ change the wire, update the fake AND run `make integration` against the real connector.
33
+ - **Bindings are a dependency.** `asimov-protocol>=1.2.1rc1,<2` from PyPI; `menlo.asimov._proto`
34
+ imports it lazily. Never copy generated `_pb2` files into this repo.
35
+
36
+ ## Dependencies
37
+
38
+ Runtime core: `protobuf` only — `pip install menlo-sdk` with no extra must drive a robot
39
+ (the UDP lane). The media lane adds one extra, `[livekit]`, and every `livekit` import in
40
+ the SDK is lazy and lives in `transport/_livekit_client.py`; nothing else may import it,
41
+ and `robot.connect("udp")` must never reach it. Optional at call time, never at import time:
42
+ numpy, Pillow and OpenCV are named in an error, never depended on.
43
+
44
+ Dev: ruff, mypy (strict), pytest. Simulator for live tests:
45
+ `menlo-studio up --container --sdk`. A LiveKit server for `make livekit`:
46
+ `livekit-server --dev`.
@@ -0,0 +1,127 @@
1
+ # Changelog
2
+
3
+ All notable changes to menlo-sdk. Pre-1.0: minor versions may change the API.
4
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## 0.1.0rc2 — 2026-09-23
7
+
8
+ ### Changed
9
+ - `asimov-protocol` is a declared dependency (`>=1.2.1rc1,<2`, from PyPI) instead of a tree
10
+ vendored into the wheel. One installed copy of the bindings per process; the `_vendor/`
11
+ directory, `scripts/vendor_protocol.sh`, `make vendor-protocol` / `check-vendor` and the
12
+ vendored-bindings CI job are gone.
13
+ - The distribution is `menlo-sdk` (repository `menloresearch/menlo-sdk`), published to PyPI on
14
+ `v*` tags. The import is `menlo`, one subpackage per robot: the Asimov biped is
15
+ `menlo.asimov` (`from menlo.asimov import Robot, Mode`). The console script is `menlo`
16
+ (`menlo login`), the store is `~/.menlo/robots.toml` (`$MENLO_HOME`), the environment
17
+ variables are `MENLO_MANAGER_URL`, `MENLO_CREDENTIAL`, `MENLO_ROBOT`, `MENLO_PERSIST`, and
18
+ the error base class is `MenloError`. Between releases `__version__` carries the next
19
+ version with a `.dev0` suffix; see `RELEASING.md`.
20
+
21
+ ### Fixed
22
+ - A velocity held by `set_velocity` is released when the robot itself ends the drive: a
23
+ fault-DAMP (critical alert while DAMPed) drops the latch so the keepalive stops re-sending
24
+ it, and a firmware restart (sequence counter reset) drops it and fences off a running
25
+ `goto` / trajectory re-send. Nothing is sent in its place; a `MOVE` at zero would ask a
26
+ DAMPed or booting robot to change mode.
27
+ - `close()` from inside a state callback on the `livekit` lane no longer stalls 5 s on the
28
+ client's own event loop and then drops the queued zero-velocity packet and the room
29
+ leave; the client now leaves asynchronously and stops its loop once that is done.
30
+ - `connect(require_state=False)`: state that arrives while the transport is still opening
31
+ (a LiveKit media wait) now goes through the late handshake instead of being stored raw,
32
+ so a protocol mismatch is raised on read rather than returning a frozen sample.
33
+ - `connect(persist=True)`: a store that cannot be written (read-only or full `$MENLO_HOME`,
34
+ a name that belongs to another manager) closes the session it just opened before the
35
+ error propagates, instead of leaving the keepalive and the transport running.
36
+ - `RobotStore.put` refuses to replace an entry with one for a different manager unless the
37
+ caller chose the name (`menlo login --name`, `persist(name=)`): a manager answering
38
+ another robot's room can no longer take over that robot's saved URL and credential.
39
+ - A DEL byte (U+007F) in a room or robot name no longer produces a `robots.toml` that
40
+ `tomllib` rejects.
41
+
42
+ ### Added
43
+ - Zero-config connect: `Robot()` with no config resolves one from `MENLO_MANAGER_URL` +
44
+ `MENLO_CREDENTIAL`, else from `~/.menlo/robots.toml` (`$MENLO_HOME`; `MENLO_ROBOT`
45
+ picks a named entry), else raises `ConnectError` naming both; `connect()` with no mode
46
+ takes the config's one lane. `menlo.asimov.store`: `RobotStore`, `StoredRobot`; the file is
47
+ 0600 in a 0700 directory. `connect(persist=True)` / `MENLO_PERSIST=1` save a working URL
48
+ and credential after a successful connect, keyed by the serial in the robot's room.
49
+ - The `menlo` console script: `menlo login <manager-url> [--credential]` validates a
50
+ credential by minting a token exactly as `connect()` does, then saves it; `menlo robots`,
51
+ `menlo use <name>`, `menlo logout <name>`.
52
+ - `connect(require_state=False)`: the media lane without waiting for the firmware.
53
+ `robot.state`, `robot.info` and the motion verbs raise `NotConnectedError` until the robot
54
+ reports, then the handshake completes on its own; a late protocol mismatch is raised
55
+ where it is read.
56
+ - `set_velocity(..., duration=, wait=True)` blocks until the hold has ended and its zero
57
+ has gone out (or another verb superseded it); `close()` during a waited hold raises
58
+ `NotConnectedError`. `close()` documents that it cuts an unexpired hold short.
59
+ - `State.yaw`; `Frame.to_jpeg(quality=85) -> bytes` (Pillow, named in the `ImportError`
60
+ when absent; JPEG frames pass through).
61
+ - `ManagerConfig`: the URL needs neither scheme nor port (`192.168.22.32`, `http://host`,
62
+ `http://host:8080`); a loopback LiveKit URL minted by the manager (`ws://localhost:7880`,
63
+ the robot's own view) is rewritten to the manager's host; a session with no `label` gets
64
+ `<host>-<6 random hex>` so two sessions on one credential never share an identity.
65
+ `ManagerConfig.host`, `menlo.asimov.connection.default_label()`,
66
+ `ConnectionConfig.from_environment()`, `ConnectionConfig.only_mode()`;
67
+ `LiveKitTransport.room` / `HybridTransport.room`.
68
+ - `docs/SKILL.md`: the agent-facing reference for writing a script against the SDK.
69
+ - `ConnectionConfig(udp=UdpConfig(...), livekit=LiveKitConfig(...) | ManagerConfig(...))`
70
+ describes a robot's lanes, one typed class each; `Robot(cfg)` binds without touching the
71
+ network; `robot.connect("udp" | "hybrid" | "livekit", timeout=, media_timeout=,
72
+ connect_timeout=)` attaches and returns the robot; `close()` then `connect()` again switches
73
+ lanes on the same `Robot`. `cfg.available_modes()` says what a config can reach; a mode
74
+ the config cannot carry is a `ConnectError` naming the missing slot, before any I/O.
75
+ - `ManagerConfig(url, credential, label=)`: the SDK asks the robot's manager
76
+ (`POST /api/livekit/token`) for the LiveKit URL, the room and a fresh join token on every
77
+ connect, so a user or agent never holds a LiveKit token. `LiveKitConfig(url, room, token)`
78
+ is for people running their own SFU.
79
+ - The UDP lane: `UdpTransport` (`RobotCommand` → udp/8850, `RobotState` ← udp/8851);
80
+ `Robot(transport)` + `open()` for any `Transport`.
81
+ - Verbs `set_velocity(vx, vy, vyaw, duration=)`, `stop()`, `stand()`, `damp()`,
82
+ `trajectory(positions, kp=, kd=)`, `goto(positions, duration=, hz=, wait=)`; each returns a
83
+ `Sent` with the encoded command, the clamp flag and an outcome handle.
84
+ - Outcomes `Applied | Refused(reason: Refusal) | Unknown`; `Sent.wait_outcome()`,
85
+ `Sent.require()`. The UDP lane carries no verdicts; every outcome there is `Unknown`.
86
+ - Typed `State`: mode, joints by firmware name, gravity, gyro, quaternion and euler angles,
87
+ alerts with names and timestamps, battery (`Battery`, `BatteryProtection`), `faulted`,
88
+ `age_s`; `RobotInfo` with `capabilities`; `robot.has()` / `robot.require()`.
89
+ - Waits `wait_for(Mode)`, `wait_until(pred, timeout=, stale_after=)` with typed exits
90
+ `WaitTimeoutError`, `StateStaleError`, `RobotFaultedError`, `CommandRefusedError`.
91
+ - Media API `robot.camera`, `robot.microphone`, `robot.speaker` (`Frame`, `AudioChunk`)
92
+ through the `Transport` seam; `UnsupportedError` on a transport that does not carry them.
93
+ - Two more lanes, both first-class: `"hybrid"` (UDP control + LiveKit media) and
94
+ `"livekit"` (commands and
95
+ state as bare `RobotCommand`/`RobotState`: reliable data packets on the `commands` topic in,
96
+ frames of a data track named `state` out (ordered; `State.edge_timestamp_us` is the frame's
97
+ `user_timestamp`, the edge's receive clock) —
98
+ the same protobufs the UDP lane sends, no envelope, no type tag). `HybridTransport` and
99
+ `LiveKitTransport` underneath; `robot.py` does not know which wire it is on.
100
+ - LiveKit is an EXTRA (`pip install "menlo-sdk[livekit]"`): the core still depends on
101
+ protobuf alone, every `livekit` import is lazy inside `transport/_livekit_client.py`, and
102
+ `connect("udp")` never reaches it.
103
+ - `Camera.photo(timeout=)` returns ONE fresh `Frame`; `Camera.capture_clip(seconds,
104
+ audio=True)` returns a `Clip` with `save_wav()` (stdlib `wave`), `frames_as_numpy()`,
105
+ `save_frames()` (Pillow) and `save_mp4()` (OpenCV) — the last three raise `ImportError`
106
+ naming the package rather than adding a dependency. LiveKit video is converted from I420
107
+ to `rgb8`, so `Frame.to_numpy()` works.
108
+ - `Transport.silence_hint`: the transport, not `Robot`, says what to check when a connect
109
+ hears nothing on its wire.
110
+ - No `identity` parameter on the LiveKit lanes: a participant's identity is a claim inside
111
+ the access token and the server ignores what a client says about it, so the SDK reads it
112
+ back (`transport.identity`, and `endpoint` reads `room@url as <identity>`) instead of
113
+ accepting an argument it could not honour.
114
+ - Callbacks `on_state`, `on_alert`, `on_mode_change`, `on_refused`, `on_link_lost`,
115
+ `on_controller_change`.
116
+ - `robot.record(path)` JSON-lines recording and `menlo.asimov.recording.load()`.
117
+ - Capability honesty on the room lanes: `camera`/`microphone` are claimed only once the
118
+ matching track is actually subscribed, and dropped when the room goes; a room with no
119
+ video raises `UnsupportedError` instead of yielding nothing.
120
+ - Liveness: 10 Hz hold with a generation fence and bounded `duration`; `LinkLostError`
121
+ after `link_timeout` seconds of silence, with a zero velocity sent; `close()` zeroes a
122
+ held velocity; reopen with `close()` + `open()`.
123
+ - State-stream hygiene: samples that do not match the robot are dropped, an older datagram
124
+ never overwrites a newer sample (sequence compared modulo 2^32), optional `state_source`
125
+ allowlist.
126
+ - Generated `asimov.io` bindings vendored at asimov-protocol v1.1.0 with `protobuf` as the
127
+ only runtime dependency; an installed identical `asimov-protocol` is preferred.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for looking. This SDK is small on purpose; changes that keep it small are welcome.
4
+
5
+ - Read `AGENTS.md` first: it is the rulebook (wire verbs, no guessing, tests that fail
6
+ without the fix).
7
+ - `make sync && make check` must pass: ruff, mypy `--strict`, and the unit suite, which
8
+ drives a fake edge over the real wire and needs no robot.
9
+ - Every behavioural change ships a test that fails on the previous code. If you change the
10
+ wire, update `tests/conftest.py` and run `make integration` against the real connector.
11
+ - Public API changes go in `CHANGELOG.md` under the unreleased version. Cutting a release
12
+ (version numbers, rc, tags, PyPI) is described in `RELEASING.md`.
13
+ - Commit messages describe the change and why; the body should let a reader reconstruct
14
+ the reasoning without the pull request.
15
+ - The `asimov.io` bindings come from the `asimov-protocol` package on PyPI
16
+ (`asimov-protocol>=1.2.1rc1,<2` in `pyproject.toml`). Move the floor when the SDK starts using
17
+ something a newer protocol MINOR added; the `<2` bound moves only with the wire directory.
18
+
19
+ Open a pull request against `main`. A review from a maintainer is required before merge.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Menlo Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,33 @@
1
+ # menlo-sdk — developer entry points. `uv` is the only tool assumed.
2
+ .PHONY: sync lint fmt typecheck test integration live livekit check
3
+
4
+ sync: ## create/refresh .venv from uv.lock
5
+ uv sync --all-groups
6
+
7
+ lint: ## ruff, the way CI runs it
8
+ uv run ruff check . && uv run ruff format --check .
9
+
10
+ fmt: ## fix what ruff can fix
11
+ uv run ruff check --fix . && uv run ruff format .
12
+
13
+ typecheck:
14
+ uv run mypy
15
+
16
+ test: ## unit tests only (no robot, no edge checkout, no livekit)
17
+ uv run pytest -m "not integration and not live and not livekit"
18
+
19
+ integration: ## the real asimov-edge UdpConnector in-process; needs ASIMOV_EDGE_SRC=<edge>/src
20
+ uv run pytest -m integration
21
+
22
+ live: ## a robot or `menlo-studio up --container --sdk`; needs MENLO_SDK_LIVE_HOST
23
+ uv run pytest -m live -s
24
+
25
+ livekit: ## real livekit.rtc vs `livekit-server --dev`. Needs MENLO_SDK_LIVEKIT_URL and
26
+ ## TWO tokens for one room (an identity is a claim inside the JWT, so one
27
+ ## token is one participant): MENLO_SDK_LIVEKIT_TOKEN (identity `sdk`) and
28
+ ## MENLO_SDK_LIVEKIT_EDGE_TOKEN (identity `fake-edge`). See the test module.
29
+ uv run --extra livekit pytest -m livekit -s
30
+
31
+
32
+
33
+ check: lint typecheck test
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: menlo-sdk
3
+ Version: 0.1.0rc2
4
+ Summary: Drive an Asimov robot from Python: one Robot, pluggable transports.
5
+ Project-URL: Homepage, https://github.com/menloresearch/menlo-sdk
6
+ Project-URL: Documentation, https://github.com/menloresearch/menlo-sdk/blob/main/docs/REFERENCE.md
7
+ Project-URL: Repository, https://github.com/menloresearch/menlo-sdk
8
+ Project-URL: Issues, https://github.com/menloresearch/menlo-sdk/issues
9
+ Project-URL: Changelog, https://github.com/menloresearch/menlo-sdk/blob/main/CHANGELOG.md
10
+ Author: Menlo Research
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: asimov,humanoid,robotics,sdk
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: asimov-protocol<2,>=1.2.1rc1
23
+ Requires-Dist: protobuf>=5.29.3
24
+ Provides-Extra: livekit
25
+ Requires-Dist: livekit<2,>=1.1.4; extra == 'livekit'
26
+ Description-Content-Type: text/markdown
27
+
28
+ <p align="center">
29
+ <a href="https://menlo.ai"><img src="https://docs.menlo.ai/menlo-logo.svg" alt="Menlo" width="160"></a>
30
+ </p>
31
+
32
+ # menlo-sdk
33
+
34
+ Drive an Asimov robot from Python. One `Robot`, five verbs, waits that read the robot's own
35
+ report, and camera, microphone and speaker on the same connection.
36
+
37
+ ```bash
38
+ pip install menlo-sdk # UDP on the LAN; Python 3.12+
39
+ pip install "menlo-sdk[livekit]" # + video, audio and remote connections
40
+ ```
41
+
42
+ ## Quickstart
43
+
44
+ Once per machine, save a robot. The credential is minted on the robot
45
+ (`asimovctl sdk-token create --role control`, or its web UI's SDK page):
46
+
47
+ ```bash
48
+ menlo login http://asimov.local --credential <credential>
49
+ ```
50
+
51
+ Then a script is just:
52
+
53
+ ```python
54
+ from menlo.asimov import Mode, Robot
55
+
56
+ with Robot().connect() as robot: # the robot you logged in to
57
+ if robot.state.mode is Mode.DAMP: # wake up: STAND is the only way out of DAMP
58
+ robot.stand()
59
+ robot.wait_for(Mode.STAND, timeout=15)
60
+ robot.set_velocity(vx=0.25, duration=4.0, wait=True) # walk 0.25 m/s for 4 s, then zero
61
+ robot.wait_for(Mode.MOVE) # ends in MOVE at zero velocity: standing still
62
+ print(robot.state.joint("L_Knee").pos, robot.state.battery)
63
+ print(robot.camera.photo().to_jpeg()[:4]) # needs [livekit] and Pillow
64
+ ```
65
+
66
+ `Robot()` finds the robot from `MENLO_MANAGER_URL` + `MENLO_CREDENTIAL`, else from
67
+ `~/.menlo/robots.toml` (`menlo robots`, `menlo use <name>`, `menlo logout <name>`).
68
+ Or say where it is: `Robot(ConnectionConfig(udp=UdpConfig("asimov.local"))).connect("udp")`.
69
+
70
+ ## The verbs
71
+
72
+ | Verb | What it does |
73
+ |---|---|
74
+ | `set_velocity(vx, vy, vyaw, duration=, wait=)` | walk; held at 10 Hz until superseded, `stop()`, or `duration` |
75
+ | `stop()` | zero velocity; the robot keeps balancing in MOVE |
76
+ | `stand()` | stiffen into the standing pose; the wake-up verb, no balance loop |
77
+ | `damp()` | motors compliant now; a standing robot folds. The emergency verb |
78
+ | `goto(positions, duration=)` / `trajectory(positions)` | joint targets, radians, policy off |
79
+
80
+ Every verb returns a `Sent` at once. `wait_for(Mode.X)` and `wait_until(pred)` block on the
81
+ robot's report and raise `RobotFaultedError`, `StateStaleError` or `WaitTimeoutError`
82
+ instead of guessing. `robot.state` is the latest sample: mode, joints, IMU, alerts, battery.
83
+
84
+ ## Three lanes, one API
85
+
86
+ | `connect(mode)` | control + state | video + audio | when |
87
+ |---|---|---|---|
88
+ | `"udp"` | UDP 8850 / 8851 | — | on the robot's LAN, no camera needed |
89
+ | `"hybrid"` | UDP | LiveKit | on the LAN, with camera |
90
+ | `"livekit"` | LiveKit | LiveKit | from anywhere the robot's manager is reachable |
91
+
92
+ `ManagerConfig(url, credential)` asks the robot's manager for the room and a fresh join
93
+ token on every connect; the SDK never holds a LiveKit secret.
94
+
95
+ ## Read before you let go of the robot
96
+
97
+ - **A walk ends in MOVE at zero velocity, not in `stand()`.** STAND is a stiffen with no
98
+ balance loop; asking a free-standing biped to stiffen after walking tips it over.
99
+ - **The SDK sends zero when your script stops.** `close()`, the end of a `with` block, a
100
+ lost link and a fault-DAMP all release a held velocity. The edge DAMPs on its own about
101
+ two seconds after the last command it heard.
102
+ - **`damp()` folds the robot.** It is never implied by anything else.
103
+ - Speeds are clamped client-side (0.6 m/s, 1.5 rad/s by default) and the clamp is visible
104
+ on `Sent.clamped`.
105
+
106
+ ## More
107
+
108
+ - [Reference](https://github.com/menloresearch/menlo-sdk/blob/main/docs/REFERENCE.md): every
109
+ option, the safety model in full, the wire, errors, development.
110
+ - [SKILL.md](https://github.com/menloresearch/menlo-sdk/blob/main/docs/SKILL.md): the
111
+ two-page brief for an agent writing a script against this SDK.
112
+ - [Examples](https://github.com/menloresearch/menlo-sdk/tree/main/examples) and the
113
+ [changelog](https://github.com/menloresearch/menlo-sdk/blob/main/CHANGELOG.md).
114
+ - Versions follow PEP 440; pre-releases (`0.1.0rc1`) install only with `pip install --pre`.
115
+ How releases are cut: [RELEASING.md](https://github.com/menloresearch/menlo-sdk/blob/main/RELEASING.md).
116
+
117
+ ## License
118
+
119
+ MIT. The wire types come from [`asimov-protocol`](https://pypi.org/project/asimov-protocol/) (MIT).