quackd-open-duck 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-open-duck
3
+ Version: 0.10.0
4
+ Summary: The Open Duck Mini v2 adapter for quackd. Install it as quackd[open_duck].
5
+ Project-URL: Homepage, https://github.com/rokbenko/quackd
6
+ Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapters/open_duck.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-open_duck
14
+
15
+ The `open_duck` 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[open_duck]"
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/open_duck.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/open_duck.md).
@@ -0,0 +1,12 @@
1
+ # quackd-open_duck
2
+
3
+ The `open_duck` 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[open_duck]"
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/open_duck.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/open_duck.md).
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quackd-open-duck"
7
+ dynamic = ["version"]
8
+ description = "The Open Duck Mini v2 adapter for quackd. Install it as quackd[open_duck]."
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
+ open_duck = "quackd_open_duck"
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/rokbenko/quackd"
21
+ Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapters/open_duck.md"
22
+
23
+ [tool.hatch.version]
24
+ path = "src/quackd_open_duck/__init__.py"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/quackd_open_duck"]
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,452 @@
1
+ """The Open Duck Mini v2 adapter: an open-hardware biped that walks on its own ONNX policy.
2
+
3
+ Three backends behind one manifest: `sim2d` (a duck in the cartoon world), `mock` (scripted,
4
+ for tests) and `bridge` (a real duck through the quackd bridge daemon on its Raspberry Pi,
5
+ EXPERIMENTAL and never run on hardware by us).
6
+
7
+ This is the first robot quackd supports whose upstream has **no network control API at all**.
8
+ The runtime reads a local pygame gamepad and runs a 50 Hz ONNX walk policy; the only socket
9
+ in it is an IMU check. So quackd ships a small daemon that runs on the robot, replaces the
10
+ gamepad as the walk loop's command source, and speaks a protocol quackd itself defines. Every
11
+ name the daemon borrows from the robot's own runtime is a VERIFIED or UNVERIFIED ref in
12
+ `upstream_api.py` (ADR-0022, ADR-0024).
13
+
14
+ What this duck cannot do matters as much as what it can. It has no beak, no gripper, no kick
15
+ policy, no sit policy and, critically, no get-up-after-fall policy. Those verbs are not gated
16
+ here, they are simply never declared, so they do not exist anywhere in quackd for this robot.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import AsyncIterator, Callable, Sequence
22
+ from typing import Any
23
+
24
+ from PIL import Image
25
+
26
+ from quackd.adapters.base import RestResult, one_camera_url, refuse_rest_pose
27
+ from quackd.adapters.manifest import (
28
+ Datasheet,
29
+ Figure,
30
+ Frame,
31
+ Health,
32
+ RobotManifest,
33
+ SafetyAuthority,
34
+ verb_spec,
35
+ )
36
+ from quackd.transport.base import Ack, DuckState, DuckTransport, HeartbeatError, Intent
37
+ from quackd.verbs.core import CORE
38
+ from quackd.verbs.registry import Precondition, Verb
39
+ from quackd_open_duck.verbs import (
40
+ GAZE_YAW_DEG,
41
+ HEAD_PITCH_RANGE,
42
+ HEAD_ROLL_RANGE,
43
+ HEAD_YAW_RANGE,
44
+ MAX_VX,
45
+ MAX_VY,
46
+ MAX_WZ,
47
+ NECK_PITCH_RANGE,
48
+ open_duck_conditions,
49
+ open_duck_verbs,
50
+ )
51
+
52
+ __version__ = "0.10.0"
53
+ """Kept in step with quackd's own version by scripts/set_version.py. It lives here
54
+ rather than being read from the core, because this file is all an adapter's sdist
55
+ contains."""
56
+
57
+ BACKENDS = ("sim2d", "mock", "bridge")
58
+ DEFAULT_ID = "open-duck-01"
59
+ CONTROL_HZ = 50
60
+
61
+ BLURB = (
62
+ "a 3D-printed biped duck robot about 42 cm tall that walks on its own 50 Hz policy "
63
+ "(an Open Duck Mini v2). It is slow, it cannot pick anything up, and it cannot get "
64
+ "back up on its own if it falls"
65
+ )
66
+
67
+ DATASHEET = Datasheet(
68
+ height_m=Figure(value=0.42, confidence="official", source="the Open Duck Mini v2 repository"),
69
+ dof=Figure(
70
+ value=14,
71
+ confidence="estimate",
72
+ source="the BOM and the runtime's joint list",
73
+ note="STS3215 servos, which the BOM states",
74
+ ),
75
+ manipulator="none",
76
+ tethered=False,
77
+ terrain="indoor_flat",
78
+ not_rated=["stairs", "steps", "slopes"],
79
+ cannot=[
80
+ "pick up, push or carry anything: there is no arm, no gripper and no beak",
81
+ "get back on its feet after a fall: there is no recovery policy, so a fall ends the run "
82
+ "and needs a human",
83
+ ],
84
+ notes=[
85
+ "Mass, battery runtime, walking speed, slope and step height are not published",
86
+ "The base build senses with an IMU and foot contacts; a camera, a microphone and a "
87
+ "speaker are optional add-ons the bridge reports at connect",
88
+ ],
89
+ )
90
+ _MOVE_DESCRIPTION = (
91
+ "Walk with a velocity for a duration. This robot is deliberately slow: forward speed is "
92
+ "clamped near 0.15 m/s and the turn rate near 1 rad/s, and its own walk policy makes the "
93
+ "gait. Use short durations."
94
+ )
95
+ _SEARCH_SCAN_DESCRIPTION = (
96
+ "Look around for a target by turning in place in steps. This robot has legs, so this "
97
+ "WALKS: it turns the whole body, it does not sweep the head. Returns where it was seen."
98
+ )
99
+ _GO_TO_DESCRIPTION = (
100
+ "Walk toward a detected target and stop at a distance. Closes the loop on the camera "
101
+ "itself. Slow: expect this to take a while."
102
+ )
103
+ # the core wording offers `kick, grab`, which this robot does not have at all
104
+ _APPROACH_AND_DESCRIPTION = (
105
+ "Walk to a target, then run another verb there (say, quack, express, observe)."
106
+ )
107
+
108
+
109
+ def open_duck_manifest(
110
+ backend: str,
111
+ robot_id: str | None = None,
112
+ *,
113
+ camera: bool = True,
114
+ speaker: bool = True,
115
+ antennas: bool = True,
116
+ microphone: bool = False,
117
+ head: bool = True,
118
+ bridge_version: str | None = None,
119
+ runtime_commit: str | None = None,
120
+ deadman: bool = True,
121
+ deadman_ms: int | None = None,
122
+ ) -> RobotManifest:
123
+ """The Open Duck Mini v2 as data.
124
+
125
+ The static manifest describes a fully built duck: a camera, a speaker and antennas. A
126
+ real one is whatever its owner soldered, so the `bridge` backend narrows this at
127
+ `connect()` from the `expression_features` flags in the robot's own `duck_config.json`
128
+ and from whether the daemon was started with head control enabled."""
129
+ own = open_duck_verbs()
130
+ verbs = [
131
+ verb_spec(CORE["report_state"], core=True),
132
+ verb_spec(CORE["stop"], core=True),
133
+ verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION),
134
+ ]
135
+ preconditions: dict[str, list[str]] = {"move": ["not_fallen", "policy_running"]}
136
+ if camera:
137
+ verbs += [
138
+ verb_spec(CORE["observe"], core=True),
139
+ verb_spec(CORE["go_to"], core=True, description=_GO_TO_DESCRIPTION),
140
+ verb_spec(CORE["search_scan"], core=True, description=_SEARCH_SCAN_DESCRIPTION),
141
+ verb_spec(CORE["approach_and"], core=True, description=_APPROACH_AND_DESCRIPTION),
142
+ ]
143
+ for name in ("go_to", "search_scan", "approach_and"):
144
+ preconditions[name] = ["not_fallen", "policy_running"]
145
+ if speaker:
146
+ verbs += [verb_spec(own["say"], core=True), verb_spec(own["quack"], core=False)]
147
+ if head:
148
+ verbs.append(verb_spec(own["gaze"], core=False))
149
+ preconditions["gaze"] = ["not_fallen"]
150
+ if antennas:
151
+ verbs.append(verb_spec(own["express"], core=False))
152
+ preconditions["express"] = ["not_fallen"]
153
+
154
+ intents: list[Any] = ["twist"]
155
+ if head:
156
+ intents.append("gaze")
157
+ if speaker:
158
+ intents.append("sound")
159
+ if antennas:
160
+ intents.append("skill")
161
+
162
+ sensors: list[Any] = ["imu", "joint_state"]
163
+ if camera:
164
+ sensors.append("camera")
165
+ if microphone:
166
+ sensors.append("microphone")
167
+
168
+ return RobotManifest(
169
+ id=robot_id or DEFAULT_ID,
170
+ vendor="apirrone",
171
+ model="open-duck-mini-v2",
172
+ embodiment="biped",
173
+ mobility="legged",
174
+ intents=intents,
175
+ sensors=sensors,
176
+ verbs=verbs,
177
+ preconditions=preconditions,
178
+ # The robot's own runtime has no deadman: its only command source is a local pygame
179
+ # pad, which is never silent. The deadman is quackd's own bridge daemon, which zeroes
180
+ # the velocity inside the 50 Hz loop when commands stop, so `native` stays `none`:
181
+ # the authority is our code, not a robot feature (ADR-0024). Whether there is one at
182
+ # all now comes from what the bridge reported at connect rather than from this
183
+ # literal: the command-TTL note in upstream_api says the manifest claims a deadman
184
+ # only when a bridge says it has one, and it used to claim it regardless.
185
+ safety_authority=SafetyAuthority(native="none", deadman=deadman, heartbeat_hz=2.0),
186
+ frame=Frame(
187
+ reference="body",
188
+ note="trunk frame: vx forward, vy left, +vyaw left. The head is four joints "
189
+ "(neck_pitch, head_pitch, head_yaw, head_roll) and barely turns",
190
+ ),
191
+ limits={
192
+ "max_vx": MAX_VX,
193
+ "max_vy": MAX_VY,
194
+ "max_wz": MAX_WZ,
195
+ "gaze_yaw_deg": round(GAZE_YAW_DEG, 1),
196
+ },
197
+ backend=backend,
198
+ blurb=BLURB,
199
+ datasheet=DATASHEET,
200
+ extras={
201
+ # no text to speech anywhere in the runtime: say() plays one of the duck's sounds
202
+ "speech": "sounds",
203
+ "control_hz": CONTROL_HZ,
204
+ "policy": "onnx-walk",
205
+ "joints": 14,
206
+ "head_dof": ["neck_pitch", "head_pitch", "head_yaw", "head_roll"],
207
+ "head_ranges_rad": {
208
+ "neck_pitch": list(NECK_PITCH_RANGE),
209
+ "head_pitch": list(HEAD_PITCH_RANGE),
210
+ "head_yaw": list(HEAD_YAW_RANGE),
211
+ "head_roll": list(HEAD_ROLL_RANGE),
212
+ },
213
+ "head_enabled": head,
214
+ "expression_features": {
215
+ "camera": camera,
216
+ "speaker": speaker,
217
+ "antennas": antennas,
218
+ "microphone": microphone,
219
+ },
220
+ # the two facts that shape every task written for this robot
221
+ "no_recovery": "a fallen Open Duck Mini v2 has no get-up policy and needs a human",
222
+ "no_battery": "nothing in the runtime reports a battery, so battery aborts cannot fire",
223
+ "bridge_version": bridge_version,
224
+ "runtime_commit": runtime_commit,
225
+ "deadman_ms": deadman_ms,
226
+ },
227
+ )
228
+
229
+
230
+ class OpenDuckAdapter:
231
+ """A `RobotAdapter` over one of the three Open Duck Mini v2 backends."""
232
+
233
+ name = "open_duck"
234
+
235
+ def __init__(self, transport: DuckTransport, *, robot_id: str | None = None) -> None:
236
+ self.transport = transport
237
+ self.backend = transport.name
238
+ self.robot_id = robot_id or DEFAULT_ID
239
+ self.manifest: RobotManifest | None = None
240
+ self._features: dict[str, bool] = {}
241
+
242
+ async def connect(self) -> RobotManifest:
243
+ await self.transport.connect()
244
+ # a live duck reports what its owner actually built; sim and mock report everything
245
+ self._features = dict(getattr(self.transport, "features", None) or {})
246
+ self.manifest = open_duck_manifest(
247
+ self.backend,
248
+ self.robot_id,
249
+ camera=bool(self._features.get("camera", True)),
250
+ speaker=bool(self._features.get("speaker", True)),
251
+ antennas=bool(self._features.get("antennas", True)),
252
+ microphone=bool(self._features.get("microphone", False)),
253
+ head=bool(self._features.get("head", True)),
254
+ bridge_version=getattr(self.transport, "bridge_version", None),
255
+ runtime_commit=getattr(self.transport, "runtime_commit", None),
256
+ # sim2d and mock have no `safety` block and are unchanged; a bridge that reports
257
+ # none has not told us it has a deadman, so the manifest stops saying it does
258
+ deadman=self._deadman_claim(),
259
+ deadman_ms=getattr(self.transport, "deadman_ms", None),
260
+ )
261
+ return self.manifest
262
+
263
+ def _deadman_claim(self) -> bool:
264
+ """True only when this backend has actually said it has one.
265
+
266
+ The Open Duck's runtime has no deadman of its own — its command source is a local
267
+ pad, which is never silent — so the guarantee is entirely quackd's daemon on the Pi.
268
+ A bridge that reports no `safety` block has not told us it has one, and the manifest
269
+ must not say so on its behalf. sim2d and mock have no such block and are unchanged:
270
+ the claim is about hardware."""
271
+ if not hasattr(self.transport, "safety"):
272
+ return True
273
+ return bool(getattr(self.transport, "deadman_ms", None))
274
+
275
+ async def disconnect(self) -> None:
276
+ await self.transport.close()
277
+
278
+ async def close(self) -> None:
279
+ await self.disconnect()
280
+
281
+ async def get_state(self) -> DuckState:
282
+ return await self.transport.get_state()
283
+
284
+ async def get_frame(self) -> Image.Image | None:
285
+ return await self.transport.get_frame()
286
+
287
+ async def send_intent(self, intent: Intent) -> Ack:
288
+ return await self.transport.send_intent(intent)
289
+
290
+ async def health(self) -> Health:
291
+ failure: str | None = None
292
+ try:
293
+ await self.transport.heartbeat()
294
+ except HeartbeatError as e:
295
+ failure = str(e)
296
+ # The state is read either way. Returning `Health(ok=False, reason=...)` with no
297
+ # extras meant `doctor` printed the heartbeat's complaint and suppressed every row
298
+ # that would have explained it — so a paused policy showed only "the Pi is starved".
299
+ state = await self.transport.get_state()
300
+ return Health(
301
+ ok=failure is None,
302
+ reason=failure,
303
+ battery_percent=None, # this robot reports no battery, on any backend
304
+ extras={
305
+ "policy": state.policy,
306
+ "policy_running": state.extras.get("policy_running"),
307
+ "fall_detection": state.extras.get("fall_detection"),
308
+ "loop_hz": state.extras.get("loop_hz"),
309
+ "fallen": state.fallen,
310
+ },
311
+ )
312
+
313
+ async def heartbeat(self) -> None:
314
+ await self.transport.heartbeat()
315
+
316
+ async def stop(self) -> None:
317
+ await self.transport.stop()
318
+
319
+ async def go_to_rest(self) -> RestResult:
320
+ """The walk policy holds every joint on this duck, so there is no pose quackd can
321
+ park it at: zeroing the velocity in `stop()` is the whole of parking here."""
322
+ return RestResult.none()
323
+
324
+ @property
325
+ def stop_error(self) -> str | None:
326
+ """Forwarded from the transport, because `stop` in `verbs/core.py` reads this off
327
+ whatever object it was handed — and what it is handed is the adapter, not the
328
+ transport underneath. Without the forward the check was dead on every backend, and a
329
+ stop that never left the laptop was recorded as one that had zeroed the legs."""
330
+ return getattr(self.transport, "stop_error", None)
331
+
332
+ def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
333
+ return self.transport.subscribe(topic)
334
+
335
+ def now(self) -> float:
336
+ return self.transport.now()
337
+
338
+ async def sleep(self, seconds: float) -> None:
339
+ await self.transport.sleep(seconds)
340
+
341
+ def preconditions(self) -> dict[str, Precondition]:
342
+ return open_duck_conditions()
343
+
344
+ def implementations(self) -> dict[str, Verb]:
345
+ return open_duck_verbs()
346
+
347
+ # ── sim-only passthroughs (recorder, flock) ─────────────────────────────────────
348
+
349
+ @property
350
+ def mobility(self) -> str:
351
+ return "legged"
352
+
353
+ @property
354
+ def world(self) -> Any:
355
+ return self.transport.world # type: ignore[attr-defined]
356
+
357
+ @property
358
+ def clock(self) -> Any:
359
+ return self.transport.clock # type: ignore[attr-defined]
360
+
361
+ @property
362
+ def duck_index(self) -> int:
363
+ return int(self.transport.duck_index) # type: ignore[attr-defined]
364
+
365
+ def add_tick_hook(self, hook: Callable[[Any], None]) -> None:
366
+ self.transport.add_tick_hook(hook) # type: ignore[attr-defined]
367
+
368
+ @property
369
+ def post_sleep(self) -> Callable[[], None] | None:
370
+ return getattr(self.transport, "post_sleep", None)
371
+
372
+ @post_sleep.setter
373
+ def post_sleep(self, hook: Callable[[], None] | None) -> None:
374
+ self.transport.post_sleep = hook # type: ignore[attr-defined]
375
+
376
+
377
+ # ── what the factory calls ──────────────────────────────────────────────────────────────
378
+
379
+
380
+ def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
381
+ """Static: a fully built duck. `connect()` narrows it to what a real one reports."""
382
+ return open_duck_manifest(backend, robot_id)
383
+
384
+
385
+ def implementations() -> dict[str, Verb]:
386
+ return open_duck_verbs()
387
+
388
+
389
+ def conditions() -> dict[str, Precondition]:
390
+ return open_duck_conditions()
391
+
392
+
393
+ def make(
394
+ backend: str,
395
+ *,
396
+ robot_id: str | None = None,
397
+ seed: int | None = None,
398
+ address: str | None = None,
399
+ live: bool = False,
400
+ camera_url: str | Sequence[str] | None = None,
401
+ token: str | None = None,
402
+ rest_pose: dict[str, float] | None = None,
403
+ ) -> OpenDuckAdapter:
404
+ # both run before the branch so a rest pose this duck cannot hold and a second camera are
405
+ # refused on every backend, not only on the one that would have opened the camera
406
+ refuse_rest_pose("open_duck", rest_pose)
407
+ url = one_camera_url(camera_url, spec=f"open_duck:{backend}")
408
+ if backend == "sim2d":
409
+ from quackd_open_duck.sim2d import OpenDuckSim2D
410
+
411
+ return OpenDuckAdapter(
412
+ OpenDuckSim2D(seed if seed is not None else 0, live=live), robot_id=robot_id
413
+ )
414
+ if backend == "mock":
415
+ from quackd_open_duck.mock import OpenDuckMock
416
+
417
+ return OpenDuckAdapter(OpenDuckMock(), robot_id=robot_id)
418
+ if backend == "bridge":
419
+ from quackd_open_duck.bridge import OpenDuckBridge
420
+
421
+ return OpenDuckAdapter(
422
+ OpenDuckBridge(address=address, camera_url=url, token=token),
423
+ robot_id=robot_id,
424
+ )
425
+ raise ValueError(f"unknown open_duck backend {backend!r}; choose one of {BACKENDS}")
426
+
427
+
428
+ __all__ = [
429
+ "BACKENDS",
430
+ "CONTROL_HZ",
431
+ "DEFAULT_ID",
432
+ "OpenDuckAdapter",
433
+ "conditions",
434
+ "describe",
435
+ "implementations",
436
+ "make",
437
+ "open_duck_manifest",
438
+ ]
439
+
440
+
441
+ # What this adapter reads from upstream, for `quackd doctor`. Declared here rather than in a
442
+ # table in the core, because the list belongs to whoever wrote the adapter (ADR-0022). The
443
+ # import is deferred so that naming the upstream costs nothing until doctor asks.
444
+ def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
445
+ from quackd_open_duck import upstream_api
446
+
447
+ return (
448
+ ("open_duck", upstream_api, "docs/adapters/open_duck.md", "a duck (the bridge backend)"),
449
+ )
450
+
451
+
452
+ UPSTREAMS = _upstream_rows()