quackd-toddlerbot 0.10.0__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.
- quackd_toddlerbot-0.10.0/.gitignore +41 -0
- quackd_toddlerbot-0.10.0/PKG-INFO +24 -0
- quackd_toddlerbot-0.10.0/README.md +12 -0
- quackd_toddlerbot-0.10.0/pyproject.toml +33 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/__init__.py +487 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/bridge.py +475 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/mock.py +275 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/sim2d.py +138 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/upstream_api.py +678 -0
- quackd_toddlerbot-0.10.0/src/quackd_toddlerbot/verbs.py +421 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# runs are artifacts, not source (the hero GIF lives in docs/assets on purpose)
|
|
7
|
+
runs/
|
|
8
|
+
|
|
9
|
+
# MuJoCo writes this into the working directory, with no way to redirect it, the first time
|
|
10
|
+
# the physics goes non-finite. `test_a_world_that_mujoco_has_reset_under_us_refuses_to_carry_on`
|
|
11
|
+
# makes that happen on purpose, so every test run produces one.
|
|
12
|
+
MUJOCO_LOG.TXT
|
|
13
|
+
|
|
14
|
+
# python
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*.egg-info/
|
|
18
|
+
build/
|
|
19
|
+
dist/
|
|
20
|
+
.venv/
|
|
21
|
+
.venv*/
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
.ruff_cache/
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
.coverage
|
|
26
|
+
htmlcov/
|
|
27
|
+
|
|
28
|
+
# editors / os
|
|
29
|
+
.vscode/
|
|
30
|
+
.idea/
|
|
31
|
+
.DS_Store
|
|
32
|
+
Thumbs.db
|
|
33
|
+
|
|
34
|
+
# upstream assets, never vendored (docs/licenses.md). A real microduck:mujoco run leaves
|
|
35
|
+
# upstream's CC BY-SA-NC model in ~/.quackd/cache, and a debugging copy next to the checkout
|
|
36
|
+
# is one `git add -A` from being permanent in a public history.
|
|
37
|
+
*.onnx
|
|
38
|
+
*.pt
|
|
39
|
+
*.stl
|
|
40
|
+
robot_walk.xml
|
|
41
|
+
*.stackdump
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quackd-toddlerbot
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: The ToddlerBot humanoid adapter for quackd. Install it as quackd[toddlerbot].
|
|
5
|
+
Project-URL: Homepage, https://github.com/rokbenko/quackd
|
|
6
|
+
Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapters/toddlerbot.md
|
|
7
|
+
Author: Rok Benko
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: quackd<0.11,>=0.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# quackd-toddlerbot
|
|
14
|
+
|
|
15
|
+
The `toddlerbot` robot adapter for [quackd](https://github.com/rokbenko/quackd). Install it through
|
|
16
|
+
quackd rather than directly:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv pip install "quackd[toddlerbot]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every upstream name it relies on is read from upstream source at a pinned commit and listed in
|
|
23
|
+
`upstream_api.py`. What it does, what it refuses and why:
|
|
24
|
+
[docs/adapters/toddlerbot.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/toddlerbot.md).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# quackd-toddlerbot
|
|
2
|
+
|
|
3
|
+
The `toddlerbot` robot adapter for [quackd](https://github.com/rokbenko/quackd). Install it through
|
|
4
|
+
quackd rather than directly:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uv pip install "quackd[toddlerbot]"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Every upstream name it relies on is read from upstream source at a pinned commit and listed in
|
|
11
|
+
`upstream_api.py`. What it does, what it refuses and why:
|
|
12
|
+
[docs/adapters/toddlerbot.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/toddlerbot.md).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quackd-toddlerbot"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "The ToddlerBot humanoid adapter for quackd. Install it as quackd[toddlerbot]."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Rok Benko" }]
|
|
13
|
+
dependencies = ["quackd>=0.10,<0.11"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[project.entry-points."quackd.adapters"]
|
|
17
|
+
toddlerbot = "quackd_toddlerbot"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/rokbenko/quackd"
|
|
21
|
+
Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapters/toddlerbot.md"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
path = "src/quackd_toddlerbot/__init__.py"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/quackd_toddlerbot"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["src", "README.md", "pyproject.toml"]
|
|
31
|
+
|
|
32
|
+
[tool.uv.sources]
|
|
33
|
+
quackd = { workspace = true }
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""The ToddlerBot adapter: a small humanoid, and a long list of things it will not be asked.
|
|
2
|
+
|
|
3
|
+
The first full humanoid in quackd. It has legs and arms on one body, which makes it the Open
|
|
4
|
+
Duck and the LeRobot arm at once, and it is the third robot whose robot side quackd ships,
|
|
5
|
+
because upstream has no network API of any kind: no socket, no daemon, no IPC, just a Python
|
|
6
|
+
library that opens serial ports in-process (ADR-0028).
|
|
7
|
+
|
|
8
|
+
Three capabilities are decided at connect rather than declared up front, because all three
|
|
9
|
+
depend on what the owner actually has:
|
|
10
|
+
|
|
11
|
+
- **a camera**, which is not in upstream's observation contract at all, so the daemon owns it;
|
|
12
|
+
- **a neck**, which every shipped build has but the teleop leader does not;
|
|
13
|
+
- **a walk policy**, which is an ONNX checkpoint from a wandb artifact that upstream does not
|
|
14
|
+
publish and does not check in. Without one there is no locomotion at all, so `move`, `go_to`
|
|
15
|
+
and `approach_and` do not exist rather than being gated off.
|
|
16
|
+
|
|
17
|
+
`say` is absent for good: the speaker plays audio and nothing at this pin synthesises speech.
|
|
18
|
+
A battery is absent too, so a battery abort can never fire here.
|
|
19
|
+
|
|
20
|
+
Backends: `mock` and `sim2d` run offline; `bridge` talks to the daemon quackd ships in
|
|
21
|
+
`bridge/toddlerbot/`, which is the only thing that ever touches a motor.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from PIL import Image
|
|
30
|
+
|
|
31
|
+
from quackd.adapters.base import RestResult, one_camera_url, refuse_rest_pose
|
|
32
|
+
from quackd.adapters.manifest import (
|
|
33
|
+
Datasheet,
|
|
34
|
+
Figure,
|
|
35
|
+
Frame,
|
|
36
|
+
Health,
|
|
37
|
+
RobotManifest,
|
|
38
|
+
SafetyAuthority,
|
|
39
|
+
verb_spec,
|
|
40
|
+
)
|
|
41
|
+
from quackd.transport.base import Ack, DuckState, DuckTransport, HeartbeatError, Intent
|
|
42
|
+
from quackd.verbs.core import CORE
|
|
43
|
+
from quackd.verbs.registry import Precondition, Verb
|
|
44
|
+
from quackd_toddlerbot.verbs import (
|
|
45
|
+
MOTIONS,
|
|
46
|
+
toddlerbot_conditions,
|
|
47
|
+
toddlerbot_verbs,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
CORE_OVERRIDES = frozenset({"search_scan"})
|
|
51
|
+
"""Core verbs this robot implements itself. Declared as core, run as ours."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__version__ = "0.10.0"
|
|
55
|
+
"""Kept in step with quackd's own version by scripts/set_version.py. It lives here
|
|
56
|
+
rather than being read from the core, because this file is all an adapter's sdist
|
|
57
|
+
contains."""
|
|
58
|
+
|
|
59
|
+
BACKENDS = ("mock", "sim2d", "bridge")
|
|
60
|
+
DEFAULT_ID = "toddlerbot"
|
|
61
|
+
|
|
62
|
+
ROBOTS: tuple[str, ...] = (
|
|
63
|
+
"toddlerbot_2xc",
|
|
64
|
+
"toddlerbot_2xc_gripper",
|
|
65
|
+
"toddlerbot_2xm",
|
|
66
|
+
"toddlerbot_2xm_gripper",
|
|
67
|
+
)
|
|
68
|
+
"""The builds quackd will drive. Upstream also ships `teleop_leader`, which is the fourteen
|
|
69
|
+
motor arm a human holds, not a robot to pilot."""
|
|
70
|
+
DEFAULT_ROBOT = "toddlerbot_2xc"
|
|
71
|
+
|
|
72
|
+
# The walk policy's gin file at this pin says forward -0.2 to +0.3, which is asymmetric, and
|
|
73
|
+
# sideways +/- 0.1. quackd's schema caps vy at 0.2 and wz at 1.5, and `limits` may only
|
|
74
|
+
# narrow, so the robot binds on vx and vy and the schema binds on nothing here. The range
|
|
75
|
+
# actually enforced comes from the checkpoint, so the daemon reports it and connect() uses it.
|
|
76
|
+
MAX_VX = 0.2
|
|
77
|
+
MAX_VY = 0.1
|
|
78
|
+
MAX_WZ = 1.0
|
|
79
|
+
|
|
80
|
+
CONTROL_HZ = 50.0
|
|
81
|
+
HEARTBEAT_HZ = 5.0
|
|
82
|
+
"""The daemon owns the fifty hertz loop and its own deadman. quackd's heartbeat only has to
|
|
83
|
+
notice that the daemon stopped answering, so it runs an order of magnitude slower."""
|
|
84
|
+
STALE_LIMIT_MS = 1000.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reported_motions(transport: object) -> tuple[str, ...]:
|
|
88
|
+
"""What the daemon said it loaded, or quackd's own list where nothing reports.
|
|
89
|
+
|
|
90
|
+
`mock` and `sim2d` do not carry the attribute at all and get the full list. A
|
|
91
|
+
`bridge` that reports an empty list gets an empty list, and `perform` goes with
|
|
92
|
+
it."""
|
|
93
|
+
reported = getattr(transport, "motions", None)
|
|
94
|
+
return MOTIONS if reported is None else tuple(reported)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _can_actually_walk(envelope: dict[str, float]) -> bool:
|
|
98
|
+
"""Whether a reported envelope leaves any axis with room to move on."""
|
|
99
|
+
return any(abs(float(envelope.get(k, 0.0))) > 1e-3 for k in ("max_vx", "max_vy", "max_wz"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _limits(envelope: dict[str, float] | None = None) -> dict[str, float]:
|
|
103
|
+
"""The velocity envelope, from the checkpoint that is loaded where there is one.
|
|
104
|
+
|
|
105
|
+
`limits` may only ever narrow relative to the schema, so a checkpoint trained
|
|
106
|
+
wider than quackd's own caps is clamped to them rather than believed. With no
|
|
107
|
+
daemon to ask, these are the walk gin file's numbers at the pin, which are what
|
|
108
|
+
upstream trained its own checkpoint on and no promise about anybody else's."""
|
|
109
|
+
e = envelope or {}
|
|
110
|
+
return {
|
|
111
|
+
"max_vx": min(float(e.get("max_vx", MAX_VX)), MAX_VX),
|
|
112
|
+
"max_vy": min(float(e.get("max_vy", MAX_VY)), MAX_VY),
|
|
113
|
+
"max_wz": min(float(e.get("max_wz", MAX_WZ)), MAX_WZ),
|
|
114
|
+
"control_hz": CONTROL_HZ,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
BLURB = (
|
|
119
|
+
"a small open source humanoid about 56 cm tall, with two arms, two legs, a two joint "
|
|
120
|
+
"neck and thirty servos, which cannot get up by itself if it falls"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
DATASHEET = Datasheet(
|
|
124
|
+
mass_kg=Figure(value=3.4, confidence="official", source="arXiv:2502.00893"),
|
|
125
|
+
height_m=Figure(value=0.56, confidence="official", source="arXiv:2502.00893"),
|
|
126
|
+
dof=Figure(value=30, confidence="official", source="arXiv:2502.00893"),
|
|
127
|
+
payload_kg=Figure(
|
|
128
|
+
value=1.484,
|
|
129
|
+
confidence="official",
|
|
130
|
+
source="arXiv:2502.00893",
|
|
131
|
+
note="the whole body with both arms, 40 percent of its own weight; per arm is not "
|
|
132
|
+
"published",
|
|
133
|
+
),
|
|
134
|
+
endurance_min=Figure(
|
|
135
|
+
value=19, confidence="official", source="the ToddlerBot project site", note="about"
|
|
136
|
+
),
|
|
137
|
+
manipulator="arms",
|
|
138
|
+
arms=2,
|
|
139
|
+
tethered=False,
|
|
140
|
+
terrain="indoor_flat",
|
|
141
|
+
not_rated=["stairs", "steps", "slopes"],
|
|
142
|
+
cannot=[
|
|
143
|
+
"get back on its feet after a fall: there is no recovery policy, so a fall ends the run "
|
|
144
|
+
"and needs a human",
|
|
145
|
+
"carry more than about 1.5 kg with both arms together, or an unknown weight in one",
|
|
146
|
+
"keep going for more than about twenty minutes: past that the servos heat up and "
|
|
147
|
+
"balance suffers",
|
|
148
|
+
],
|
|
149
|
+
notes=[
|
|
150
|
+
"It holds a thing by closing both arms on it, up to 0.27 by 0.24 by 0.31 m; a parallel "
|
|
151
|
+
"gripper exists only on the gripper builds, which the daemon reports at connect",
|
|
152
|
+
"Stereo fisheye cameras, an IMU, microphones and a speaker in the reference build",
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
_MOVE_DESCRIPTION = (
|
|
156
|
+
"Walk with a velocity for a duration: vx forward m/s (backwards is slower than forwards "
|
|
157
|
+
"on this robot), vy left m/s, wz rad/s (+ = left). The robot's own learned gait does the "
|
|
158
|
+
"walking. It cannot get up if it falls, so ask for less than you think."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def toddlerbot_manifest(
|
|
163
|
+
backend: str,
|
|
164
|
+
robot_id: str | None = None,
|
|
165
|
+
*,
|
|
166
|
+
camera: bool = False,
|
|
167
|
+
neck: bool = True,
|
|
168
|
+
gripper: bool = False,
|
|
169
|
+
walk: bool = False,
|
|
170
|
+
robot: str = DEFAULT_ROBOT,
|
|
171
|
+
deadman: bool = False,
|
|
172
|
+
motors: int = 30,
|
|
173
|
+
motions: tuple[str, ...] = MOTIONS,
|
|
174
|
+
envelope: dict[str, float] | None = None,
|
|
175
|
+
) -> RobotManifest:
|
|
176
|
+
"""The robot as data. Every flag is what the daemon reported, not what a config claimed."""
|
|
177
|
+
# A checkpoint whose envelope is zero on every axis is a checkpoint that cannot move
|
|
178
|
+
# the robot: `limits` would clamp every velocity to nothing while `move`, `go_to` and
|
|
179
|
+
# `approach_and` went on being offered and going on accepting. That is the same claim
|
|
180
|
+
# without a body behind it that a missing checkpoint is, so it is treated the same way.
|
|
181
|
+
if walk and envelope is not None and not _can_actually_walk(envelope):
|
|
182
|
+
walk = False
|
|
183
|
+
own = toddlerbot_verbs(neck=neck, gripper=gripper, motions=motions)
|
|
184
|
+
verbs = [
|
|
185
|
+
verb_spec(CORE["report_state"], core=True),
|
|
186
|
+
verb_spec(CORE["stop"], core=True),
|
|
187
|
+
# `search_scan` is quackd's own implementation of a core verb, not an extension
|
|
188
|
+
# of this robot's: it is declared below as core, where the camera gates it, and
|
|
189
|
+
# the registry takes the head-only implementation from `implementations()`.
|
|
190
|
+
*[verb_spec(v, core=False) for n, v in own.items() if n not in CORE_OVERRIDES],
|
|
191
|
+
]
|
|
192
|
+
preconditions: dict[str, list[str]] = {"stand": ["link_fresh", "calibrated"]}
|
|
193
|
+
if motions:
|
|
194
|
+
preconditions["perform"] = ["link_fresh", "calibrated", "not_fallen"]
|
|
195
|
+
if neck:
|
|
196
|
+
preconditions["look"] = ["link_fresh", "not_fallen"]
|
|
197
|
+
if gripper:
|
|
198
|
+
preconditions["grip"] = ["link_fresh", "calibrated"]
|
|
199
|
+
if camera:
|
|
200
|
+
verbs.insert(0, verb_spec(CORE["observe"], core=True))
|
|
201
|
+
# Locomotion is not gated off when there is no checkpoint, it does not exist: `move`,
|
|
202
|
+
# `go_to` and `approach_and` all need the twist intent, and there is no walk without one.
|
|
203
|
+
if walk:
|
|
204
|
+
verbs.append(verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION))
|
|
205
|
+
preconditions["move"] = ["link_fresh", "calibrated", "not_fallen"]
|
|
206
|
+
if camera:
|
|
207
|
+
verbs += [
|
|
208
|
+
verb_spec(CORE["go_to"], core=True),
|
|
209
|
+
verb_spec(CORE["approach_and"], core=True),
|
|
210
|
+
]
|
|
211
|
+
preconditions |= {
|
|
212
|
+
"go_to": ["link_fresh", "calibrated", "not_fallen"],
|
|
213
|
+
"approach_and": ["link_fresh", "calibrated", "not_fallen"],
|
|
214
|
+
}
|
|
215
|
+
# search_scan needs a camera and either twist or gaze. The neck is the gaze, and turning a
|
|
216
|
+
# humanoid on the spot to look around is not something to do without a fall recovery.
|
|
217
|
+
if camera and neck:
|
|
218
|
+
verbs.append(verb_spec(CORE["search_scan"], core=True))
|
|
219
|
+
preconditions["search_scan"] = ["link_fresh", "not_fallen"]
|
|
220
|
+
|
|
221
|
+
intents: list[Any] = ["skill"]
|
|
222
|
+
if neck:
|
|
223
|
+
intents.append("gaze")
|
|
224
|
+
if gripper:
|
|
225
|
+
intents.append("gripper")
|
|
226
|
+
if walk:
|
|
227
|
+
intents.append("twist")
|
|
228
|
+
sensors: list[Any] = ["imu", "joint_state"] + (["camera"] if camera else [])
|
|
229
|
+
return RobotManifest(
|
|
230
|
+
id=robot_id or DEFAULT_ID,
|
|
231
|
+
vendor="hshi74",
|
|
232
|
+
model=robot,
|
|
233
|
+
embodiment="humanoid",
|
|
234
|
+
mobility="legged" if walk else "none",
|
|
235
|
+
intents=intents,
|
|
236
|
+
sensors=sensors,
|
|
237
|
+
verbs=verbs,
|
|
238
|
+
preconditions=preconditions,
|
|
239
|
+
# There is no watchdog, no timeout, no e-stop and no deadman anywhere upstream, and
|
|
240
|
+
# silence on this robot means "hold the last target forever" rather than "stop". The
|
|
241
|
+
# only deadman that can exist is the one quackd's own daemon runs, so `deadman` is
|
|
242
|
+
# true wherever something is actually running one: the offline doubles emulate
|
|
243
|
+
# it, and `:bridge` is false until the daemon has answered the handshake and
|
|
244
|
+
# said otherwise, because before that there is nothing on the other end.
|
|
245
|
+
safety_authority=SafetyAuthority(native="none", deadman=deadman, heartbeat_hz=HEARTBEAT_HZ),
|
|
246
|
+
frame=Frame(
|
|
247
|
+
reference="body",
|
|
248
|
+
note=(
|
|
249
|
+
"+x forward, joint space in radians. There is no odometry and no pose on "
|
|
250
|
+
"hardware: the observation carries motor positions and an orientation, and "
|
|
251
|
+
"nothing else. Orientation comes from an IMU quaternion, scalar first."
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
limits=_limits(envelope),
|
|
255
|
+
backend=backend,
|
|
256
|
+
blurb=BLURB,
|
|
257
|
+
datasheet=DATASHEET,
|
|
258
|
+
extras={
|
|
259
|
+
"robot": robot,
|
|
260
|
+
"motors": motors,
|
|
261
|
+
"motions": list(motions),
|
|
262
|
+
"walk_policy": walk,
|
|
263
|
+
"neck": neck,
|
|
264
|
+
"gripper": gripper,
|
|
265
|
+
"speech": "none",
|
|
266
|
+
"no_recovery": (
|
|
267
|
+
"there is no get-up policy for this body, so a fall ends the run and needs a human"
|
|
268
|
+
),
|
|
269
|
+
"no_battery": "nothing reports a battery to Python, so a battery abort cannot fire",
|
|
270
|
+
"deadman_scope": "the daemon slews to a safe pose and holds it; it never goes limp",
|
|
271
|
+
"assumptions": [
|
|
272
|
+
"a fall is detected from the gravity vector past a tilt threshold that nobody "
|
|
273
|
+
"has calibrated against a real robot",
|
|
274
|
+
"which neck motor is yaw and which is pitch is inferred from the motor names",
|
|
275
|
+
"holding is what quackd commanded, never what the robot felt",
|
|
276
|
+
"the safe pose is upstream's default pose, and whether it is safe to reach "
|
|
277
|
+
"from a crawling or prone start is untested",
|
|
278
|
+
"no odometry exists, so go_to closes the loop on the camera alone",
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ToddlerBotAdapter:
|
|
285
|
+
"""A `RobotAdapter` over the mock, sim2d or bridge backend."""
|
|
286
|
+
|
|
287
|
+
name = "toddlerbot"
|
|
288
|
+
|
|
289
|
+
def __init__(self, transport: DuckTransport, *, robot_id: str | None = None) -> None:
|
|
290
|
+
self.transport = transport
|
|
291
|
+
self.backend = transport.name
|
|
292
|
+
self.robot_id = robot_id or DEFAULT_ID
|
|
293
|
+
self.manifest: RobotManifest | None = None
|
|
294
|
+
|
|
295
|
+
async def connect(self) -> RobotManifest:
|
|
296
|
+
await self.transport.connect()
|
|
297
|
+
# Everything below is what the daemon reported about the robot in front of it: which
|
|
298
|
+
# build, whether a neck and grippers are on it, whether a camera answered, and whether
|
|
299
|
+
# a walk checkpoint is staged. None of it is taken from configuration.
|
|
300
|
+
self.manifest = toddlerbot_manifest(
|
|
301
|
+
self.backend,
|
|
302
|
+
self.robot_id,
|
|
303
|
+
camera=bool(getattr(self.transport, "camera_available", False)),
|
|
304
|
+
neck=bool(getattr(self.transport, "neck_available", True)),
|
|
305
|
+
gripper=bool(getattr(self.transport, "gripper_available", False)),
|
|
306
|
+
walk=bool(getattr(self.transport, "walk_available", False)),
|
|
307
|
+
robot=str(getattr(self.transport, "robot_name", DEFAULT_ROBOT)),
|
|
308
|
+
deadman=bool(getattr(self.transport, "deadman", False)),
|
|
309
|
+
motors=int(getattr(self.transport, "motors", 30)),
|
|
310
|
+
# An empty list means the daemon loaded none, which is a different thing
|
|
311
|
+
# from a backend that does not report them at all. `or MOTIONS` treated
|
|
312
|
+
# the two as the same and re-advertised five motions the robot refused.
|
|
313
|
+
motions=_reported_motions(self.transport),
|
|
314
|
+
envelope=getattr(self.transport, "walk_envelope", None),
|
|
315
|
+
)
|
|
316
|
+
return self.manifest
|
|
317
|
+
|
|
318
|
+
async def disconnect(self) -> None:
|
|
319
|
+
await self.transport.close()
|
|
320
|
+
|
|
321
|
+
async def close(self) -> None:
|
|
322
|
+
await self.disconnect()
|
|
323
|
+
|
|
324
|
+
async def get_state(self) -> DuckState:
|
|
325
|
+
return await self.transport.get_state()
|
|
326
|
+
|
|
327
|
+
async def get_frame(self) -> Image.Image | None:
|
|
328
|
+
return await self.transport.get_frame()
|
|
329
|
+
|
|
330
|
+
async def send_intent(self, intent: Intent) -> Ack:
|
|
331
|
+
return await self.transport.send_intent(intent)
|
|
332
|
+
|
|
333
|
+
async def health(self) -> Health:
|
|
334
|
+
try:
|
|
335
|
+
await self.transport.heartbeat()
|
|
336
|
+
except HeartbeatError as e:
|
|
337
|
+
return Health(ok=False, reason=str(e))
|
|
338
|
+
state = await self.transport.get_state()
|
|
339
|
+
return Health(
|
|
340
|
+
ok=not state.fallen,
|
|
341
|
+
reason="the robot has fallen and needs a human" if state.fallen else None,
|
|
342
|
+
# nothing reports a battery to Python on this robot
|
|
343
|
+
battery_percent=None,
|
|
344
|
+
extras={
|
|
345
|
+
"posture": state.posture,
|
|
346
|
+
"stale_ms": state.extras.get("stale_ms"),
|
|
347
|
+
"loop_hz": state.extras.get("loop_hz"),
|
|
348
|
+
"calibrated": state.extras.get("calibrated"),
|
|
349
|
+
"deadman_tripped": state.extras.get("deadman_tripped"),
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async def heartbeat(self) -> None:
|
|
354
|
+
await self.transport.heartbeat()
|
|
355
|
+
|
|
356
|
+
async def stop(self) -> None:
|
|
357
|
+
await self.transport.stop()
|
|
358
|
+
|
|
359
|
+
async def go_to_rest(self) -> RestResult:
|
|
360
|
+
"""Nothing records a rest pose for this body: the only pose it goes to on its own is
|
|
361
|
+
the daemon's safe pose on a deadman, and quackd never asks for that one."""
|
|
362
|
+
return RestResult.none()
|
|
363
|
+
|
|
364
|
+
def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
|
|
365
|
+
return self.transport.subscribe(topic)
|
|
366
|
+
|
|
367
|
+
def now(self) -> float:
|
|
368
|
+
return self.transport.now()
|
|
369
|
+
|
|
370
|
+
async def sleep(self, seconds: float) -> None:
|
|
371
|
+
await self.transport.sleep(seconds)
|
|
372
|
+
|
|
373
|
+
def preconditions(self) -> dict[str, Precondition]:
|
|
374
|
+
return toddlerbot_conditions()
|
|
375
|
+
|
|
376
|
+
def implementations(self) -> dict[str, Verb]:
|
|
377
|
+
return toddlerbot_verbs(neck=True, gripper=True)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def mobility(self) -> str:
|
|
381
|
+
return str(self.manifest.mobility) if self.manifest else "legged"
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def world(self) -> Any:
|
|
385
|
+
"""The simulated world, for the GIF recorder. None on the backends that have none."""
|
|
386
|
+
return getattr(self.transport, "world", None)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def duck_index(self) -> int:
|
|
390
|
+
return int(getattr(self.transport, "duck_index", 0))
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def post_sleep(self) -> Callable[[], None] | None:
|
|
394
|
+
return getattr(self.transport, "post_sleep", None)
|
|
395
|
+
|
|
396
|
+
@post_sleep.setter
|
|
397
|
+
def post_sleep(self, hook: Callable[[], None] | None) -> None:
|
|
398
|
+
self.transport.post_sleep = hook # type: ignore[attr-defined]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ── what the factory calls ──────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
|
|
405
|
+
"""Static: the offline backends describe a fully built robot with a staged walk policy;
|
|
406
|
+
the bridge claims nothing until the daemon has said what is actually there."""
|
|
407
|
+
offline = backend in ("mock", "sim2d")
|
|
408
|
+
return toddlerbot_manifest(
|
|
409
|
+
backend,
|
|
410
|
+
robot_id,
|
|
411
|
+
camera=offline,
|
|
412
|
+
neck=offline,
|
|
413
|
+
gripper=False,
|
|
414
|
+
walk=offline,
|
|
415
|
+
deadman=offline,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def implementations() -> dict[str, Verb]:
|
|
420
|
+
return toddlerbot_verbs(neck=True, gripper=True)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def conditions() -> dict[str, Precondition]:
|
|
424
|
+
return toddlerbot_conditions()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def make(
|
|
428
|
+
backend: str,
|
|
429
|
+
*,
|
|
430
|
+
robot_id: str | None = None,
|
|
431
|
+
seed: int | None = None,
|
|
432
|
+
address: str | None = None,
|
|
433
|
+
live: bool = False,
|
|
434
|
+
camera_url: str | Sequence[str] | None = None,
|
|
435
|
+
token: str | None = None,
|
|
436
|
+
rest_pose: dict[str, float] | None = None,
|
|
437
|
+
) -> ToddlerBotAdapter:
|
|
438
|
+
refuse_rest_pose("toddlerbot", rest_pose)
|
|
439
|
+
# The daemon owns the camera and reports it at connect, so no backend here has a url to
|
|
440
|
+
# open. The value is still collapsed rather than ignored, so a second camera is refused
|
|
441
|
+
# instead of being taken and dropped.
|
|
442
|
+
_url = one_camera_url(camera_url, spec=f"toddlerbot:{backend}")
|
|
443
|
+
if backend == "mock":
|
|
444
|
+
from quackd_toddlerbot.mock import ToddlerBotMock
|
|
445
|
+
|
|
446
|
+
return ToddlerBotAdapter(ToddlerBotMock(), robot_id=robot_id)
|
|
447
|
+
if backend == "sim2d":
|
|
448
|
+
from quackd_toddlerbot.sim2d import ToddlerBotSim2D
|
|
449
|
+
|
|
450
|
+
return ToddlerBotAdapter(ToddlerBotSim2D(seed=seed or 0), robot_id=robot_id)
|
|
451
|
+
if backend == "bridge":
|
|
452
|
+
from quackd_toddlerbot.bridge import ToddlerBotBridge
|
|
453
|
+
|
|
454
|
+
return ToddlerBotAdapter(ToddlerBotBridge(address=address, token=token), robot_id=robot_id)
|
|
455
|
+
raise ValueError(f"unknown toddlerbot backend {backend!r}; choose one of {BACKENDS}")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
__all__ = [
|
|
459
|
+
"BACKENDS",
|
|
460
|
+
"CONTROL_HZ",
|
|
461
|
+
"DEFAULT_ID",
|
|
462
|
+
"DEFAULT_ROBOT",
|
|
463
|
+
"HEARTBEAT_HZ",
|
|
464
|
+
"MAX_VX",
|
|
465
|
+
"MAX_VY",
|
|
466
|
+
"MAX_WZ",
|
|
467
|
+
"ROBOTS",
|
|
468
|
+
"STALE_LIMIT_MS",
|
|
469
|
+
"ToddlerBotAdapter",
|
|
470
|
+
"conditions",
|
|
471
|
+
"describe",
|
|
472
|
+
"implementations",
|
|
473
|
+
"make",
|
|
474
|
+
"toddlerbot_manifest",
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# What this adapter reads from upstream, for `quackd doctor`. Declared here rather than in a
|
|
479
|
+
# table in the core, because the list belongs to whoever wrote the adapter (ADR-0022). The
|
|
480
|
+
# import is deferred so that naming the upstream costs nothing until doctor asks.
|
|
481
|
+
def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
|
|
482
|
+
from quackd_toddlerbot import upstream_api
|
|
483
|
+
|
|
484
|
+
return (("toddlerbot", upstream_api, "docs/adapters/toddlerbot.md", "a humanoid (the bridge)"),)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
UPSTREAMS = _upstream_rows()
|