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.
@@ -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()