quackd-microduck 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,33 @@
1
+ Metadata-Version: 2.5
2
+ Name: quackd-microduck
3
+ Version: 0.10.0
4
+ Summary: The Microduck adapter for quackd, with its simulators. Install it as quackd[microduck].
5
+ Project-URL: Homepage, https://github.com/rokbenko/quackd
6
+ Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapter-status.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
+ Provides-Extra: camera
12
+ Requires-Dist: aiortc>=1.9; extra == 'camera'
13
+ Requires-Dist: av>=12; extra == 'camera'
14
+ Requires-Dist: websockets>=13; extra == 'camera'
15
+ Provides-Extra: mujoco
16
+ Requires-Dist: mujoco<4,>=3.12; extra == 'mujoco'
17
+ Requires-Dist: onnxruntime>=1.20; extra == 'mujoco'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # quackd-microduck
21
+
22
+ The [Microduck](https://github.com/pollen-robotics/microduck) adapter for
23
+ [quackd](https://github.com/rokbenko/quackd), and the two simulators that come with it: the
24
+ 2D cartoon arena and the MuJoCo physics backend. Install it through quackd:
25
+
26
+ ```bash
27
+ uv pip install "quackd[microduck]" # the cartoon, the mock and the real duck
28
+ uv pip install "quackd[mujoco]" # and the physics simulator
29
+ ```
30
+
31
+ No Microduck has run quackd. Every name the `jsonrpc` backend relies on is read from upstream
32
+ source at a pinned commit and listed in `upstream_api.py`. What it does and what it refuses:
33
+ [docs/adapter-status.md](https://github.com/rokbenko/quackd/blob/main/docs/adapter-status.md).
@@ -0,0 +1,14 @@
1
+ # quackd-microduck
2
+
3
+ The [Microduck](https://github.com/pollen-robotics/microduck) adapter for
4
+ [quackd](https://github.com/rokbenko/quackd), and the two simulators that come with it: the
5
+ 2D cartoon arena and the MuJoCo physics backend. Install it through quackd:
6
+
7
+ ```bash
8
+ uv pip install "quackd[microduck]" # the cartoon, the mock and the real duck
9
+ uv pip install "quackd[mujoco]" # and the physics simulator
10
+ ```
11
+
12
+ No Microduck has run quackd. Every name the `jsonrpc` backend relies on is read from upstream
13
+ source at a pinned commit and listed in `upstream_api.py`. What it does and what it refuses:
14
+ [docs/adapter-status.md](https://github.com/rokbenko/quackd/blob/main/docs/adapter-status.md).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quackd-microduck"
7
+ dynamic = ["version"]
8
+ description = "The Microduck adapter for quackd, with its simulators. Install it as quackd[microduck]."
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
+ [project.optional-dependencies]
16
+ # The physics simulator: MuJoCo on the CPU, no GPU, both imported only inside connect().
17
+ # onnxruntime is what runs the robot's own walking policy; the meshes and that policy are
18
+ # fetched at run time and never shipped here (docs/licenses.md).
19
+ mujoco = ["mujoco>=3.12,<4", "onnxruntime>=1.20"]
20
+ # The duck's camera reaches clients only as an H.264 WebRTC track from mediad, so a picture
21
+ # means being a WebRTC peer. Heavy, and needed by nobody who is not pointing quackd at a
22
+ # real Microduck.
23
+ camera = ["aiortc>=1.9", "av>=12", "websockets>=13"]
24
+
25
+ [project.entry-points."quackd.adapters"]
26
+ microduck = "quackd_microduck"
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/rokbenko/quackd"
30
+ Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapter-status.md"
31
+
32
+ [tool.hatch.version]
33
+ path = "src/quackd_microduck/__init__.py"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/quackd_microduck"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src", "README.md", "pyproject.toml"]
40
+
41
+ [tool.uv.sources]
42
+ quackd = { workspace = true }
@@ -0,0 +1,311 @@
1
+ """The Microduck adapter: every Microduck transport, wrapped, plus the manifest.
2
+
3
+ `quackd/transport/*` is untouched and becomes the Microduck backend layer. This adapter
4
+ delegates every call to one of those transports and adds what 0.4 needs: a manifest, the
5
+ named preconditions the manifest references, and the Microduck's own verbs. Wrapping
6
+ rather than moving is how "zero behaviour change" is made mechanically true (ADR-0017).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator, Callable, Sequence
12
+ from typing import Any
13
+
14
+ from PIL import Image
15
+
16
+ from quackd.adapters.base import RestResult, one_camera_url, refuse_rest_pose
17
+ from quackd.adapters.manifest import (
18
+ Datasheet,
19
+ Figure,
20
+ Frame,
21
+ Health,
22
+ RobotManifest,
23
+ SafetyAuthority,
24
+ Sensor,
25
+ verb_spec,
26
+ )
27
+ from quackd.transport.base import Ack, DuckState, DuckTransport, HeartbeatError, Intent
28
+ from quackd.verbs.core import CORE
29
+ from quackd.verbs.registry import Precondition, Verb
30
+ from quackd_microduck.verbs import MICRODUCK_VERBS, microduck_conditions
31
+
32
+ __version__ = "0.10.0"
33
+ """Kept in step with quackd's own version by scripts/set_version.py. It lives here
34
+ rather than being read from the core, because this file is all an adapter's sdist
35
+ contains."""
36
+
37
+ BACKENDS = ("sim2d", "mujoco", "mock", "jsonrpc", "websocket")
38
+
39
+ # The 0.3 descriptions of the renamed verbs, so an old duck's tool schemas are byte-identical.
40
+ _MOVE_DESCRIPTION = (
41
+ "Walk with a velocity for a duration. Use small values; the robot is 25 cm tall."
42
+ )
43
+ _GO_TO_DESCRIPTION = (
44
+ "Walk toward a detected target and stop at a distance. Closes the loop on the camera itself."
45
+ )
46
+ _SEARCH_SCAN_DESCRIPTION = "Rotate in steps, looking for a target. Returns where it was seen."
47
+ _APPROACH_AND_DESCRIPTION = "walk_to a target, then run another verb (kick, grab)."
48
+ BLURB = "a small biped duck robot (25 cm, 800 g)"
49
+
50
+ DATASHEET = Datasheet(
51
+ mass_kg=Figure(value=0.8, confidence="official", source="the Pollen Robotics README"),
52
+ height_m=Figure(value=0.25, confidence="official", source="the Pollen Robotics README"),
53
+ dof=Figure(
54
+ value=15,
55
+ confidence="official",
56
+ source="the Pollen Robotics README",
57
+ note="XL330 class servos, which is an estimate",
58
+ ),
59
+ manipulator="beak",
60
+ tethered=False,
61
+ terrain="indoor_flat",
62
+ not_rated=["stairs", "steps", "slopes"],
63
+ cannot=[
64
+ "carry, hold or push anything: the beak scoops at the floor and nothing else",
65
+ "climb or descend a step",
66
+ "hold a heading for long without a landmark: the IMU has no magnetometer, so heading "
67
+ "drifts",
68
+ ],
69
+ notes=[
70
+ "The speed caps are a software clamp in robotd, not a measured hardware maximum",
71
+ "The time-of-flight sensor is an 8 by 8 grid good from 0.12 to 3.54 m",
72
+ "It falls, and it can get itself upright again",
73
+ ],
74
+ )
75
+
76
+
77
+ def microduck_manifest(
78
+ backend: str, robot_id: str = "microduck", *, camera: bool = True
79
+ ) -> RobotManifest:
80
+ """The Microduck as data. Static: no connection needed (validate, announce, doctor).
81
+
82
+ `camera` is the one thing a description of the robot cannot settle on its own. Upstream has
83
+ no socket-level camera method at all — the camera reaches clients through `mediad`'s WebRTC
84
+ track — so on the jsonrpc backend a frame exists only when `--camera-url` names something
85
+ serving one. Advertising `observe` either way promises the pilot vision it may not have.
86
+ """
87
+ core = [
88
+ verb_spec(CORE["report_state"], core=True),
89
+ verb_spec(CORE["stop"], core=True),
90
+ verb_spec(MICRODUCK_VERBS["say"], core=True),
91
+ verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION),
92
+ ]
93
+ if camera:
94
+ core += [
95
+ verb_spec(CORE["observe"], core=True),
96
+ verb_spec(CORE["go_to"], core=True, description=_GO_TO_DESCRIPTION),
97
+ verb_spec(CORE["search_scan"], core=True, description=_SEARCH_SCAN_DESCRIPTION),
98
+ verb_spec(CORE["approach_and"], core=True, description=_APPROACH_AND_DESCRIPTION),
99
+ ]
100
+ extensions = [verb_spec(v, core=False) for n, v in MICRODUCK_VERBS.items() if n != "say"]
101
+ sensors: list[Sensor] = ["battery", "odometry", "imu", "tof"]
102
+ if camera:
103
+ sensors.insert(0, "camera")
104
+ return RobotManifest(
105
+ id=robot_id,
106
+ vendor="pollen-robotics",
107
+ model="microduck",
108
+ embodiment="biped",
109
+ mobility="legged",
110
+ intents=["twist", "skill", "gaze", "sound", "pose"],
111
+ sensors=sensors,
112
+ verbs=core + extensions,
113
+ # exactly the 0.3 attachments: walk/kick/grab need standing, sit/stand/gaze not fallen
114
+ preconditions={
115
+ "move": ["standing"],
116
+ "kick": ["standing"],
117
+ "grab": ["standing"],
118
+ "sit": ["not_fallen"],
119
+ "stand": ["not_fallen"],
120
+ "gaze": ["not_fallen"],
121
+ },
122
+ safety_authority=SafetyAuthority(native="robotd_deadman", deadman=True, heartbeat_hz=2.0),
123
+ frame=Frame(reference="body"),
124
+ limits={"max_vx": 0.3, "max_vy": 0.2, "max_wz": 1.5},
125
+ backend=backend,
126
+ blurb=BLURB,
127
+ datasheet=DATASHEET,
128
+ )
129
+
130
+
131
+ class MicroduckAdapter:
132
+ """A `RobotAdapter` over any of the Microduck transports."""
133
+
134
+ name = "microduck"
135
+
136
+ def __init__(self, transport: DuckTransport, *, robot_id: str = "microduck") -> None:
137
+ self.transport = transport
138
+ self.backend = transport.name
139
+ self.robot_id = robot_id
140
+ self.manifest: RobotManifest | None = None
141
+
142
+ # ── protocol ────────────────────────────────────────────────────────────────────
143
+
144
+ async def connect(self) -> RobotManifest:
145
+ await self.transport.connect()
146
+ # sim and mock always have a camera. A real duck has one only if a frame actually
147
+ # arrived: a URL that was typed but never answered is not a camera, and advertising
148
+ # `observe` on the strength of one is how a pilot gets told it can see.
149
+ if hasattr(self.transport, "camera_working"):
150
+ camera = bool(self.transport.camera_working)
151
+ else:
152
+ camera = getattr(self.transport, "camera_url", True) is not None
153
+ self.manifest = microduck_manifest(self.backend, self.robot_id, camera=camera)
154
+ return self.manifest
155
+
156
+ async def disconnect(self) -> None:
157
+ await self.transport.close()
158
+
159
+ async def close(self) -> None:
160
+ await self.disconnect()
161
+
162
+ async def get_state(self) -> DuckState:
163
+ return await self.transport.get_state()
164
+
165
+ async def get_frame(self) -> Image.Image | None:
166
+ return await self.transport.get_frame()
167
+
168
+ async def send_intent(self, intent: Intent) -> Ack:
169
+ return await self.transport.send_intent(intent)
170
+
171
+ async def health(self) -> Health:
172
+ try:
173
+ await self.transport.heartbeat()
174
+ except HeartbeatError as e:
175
+ return Health(ok=False, reason=str(e))
176
+ state = await self.transport.get_state()
177
+ return Health(
178
+ ok=True,
179
+ battery_percent=state.battery_percent,
180
+ extras={"policy": state.policy, "posture": state.posture},
181
+ )
182
+
183
+ async def heartbeat(self) -> None:
184
+ await self.transport.heartbeat()
185
+
186
+ async def stop(self) -> None:
187
+ await self.transport.stop()
188
+
189
+ async def go_to_rest(self) -> RestResult:
190
+ """A rest pose is a joint-angle map for an arm, and this body takes postures through
191
+ its own verbs (`sit`, `stand`), so there is nothing here to drive it to."""
192
+ return RestResult.none()
193
+
194
+ @property
195
+ def stop_error(self) -> str | None:
196
+ """`jsonrpc` has recorded this since 0.6, but nothing could read it: `stop` in
197
+ `verbs/core.py` looks for it on the object it was handed, which is this adapter."""
198
+ return getattr(self.transport, "stop_error", None)
199
+
200
+ def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
201
+ return self.transport.subscribe(topic)
202
+
203
+ def now(self) -> float:
204
+ return self.transport.now()
205
+
206
+ async def sleep(self, seconds: float) -> None:
207
+ await self.transport.sleep(seconds)
208
+
209
+ def preconditions(self) -> dict[str, Precondition]:
210
+ return microduck_conditions()
211
+
212
+ def implementations(self) -> dict[str, Verb]:
213
+ return dict(MICRODUCK_VERBS)
214
+
215
+ # ── sim-only passthroughs the flock and the recorder use today ──────────────────
216
+
217
+ @property
218
+ def world(self) -> Any:
219
+ return self.transport.world # type: ignore[attr-defined]
220
+
221
+ @property
222
+ def clock(self) -> Any:
223
+ return self.transport.clock # type: ignore[attr-defined]
224
+
225
+ @property
226
+ def duck_index(self) -> int:
227
+ return int(self.transport.duck_index) # type: ignore[attr-defined]
228
+
229
+ def add_tick_hook(self, hook: Callable[[Any], None]) -> None:
230
+ self.transport.add_tick_hook(hook) # type: ignore[attr-defined]
231
+
232
+ @property
233
+ def post_sleep(self) -> Callable[[], None] | None:
234
+ return getattr(self.transport, "post_sleep", None)
235
+
236
+ @post_sleep.setter
237
+ def post_sleep(self, hook: Callable[[], None] | None) -> None:
238
+ self.transport.post_sleep = hook # type: ignore[attr-defined]
239
+
240
+
241
+ # ── what the factory calls (the same four names on every adapter package) ───────────────
242
+
243
+
244
+ def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
245
+ return microduck_manifest(backend, robot_id or "microduck")
246
+
247
+
248
+ def implementations() -> dict[str, Verb]:
249
+ return dict(MICRODUCK_VERBS)
250
+
251
+
252
+ def conditions() -> dict[str, Precondition]:
253
+ return microduck_conditions()
254
+
255
+
256
+ def make(
257
+ backend: str,
258
+ *,
259
+ robot_id: str | None = None,
260
+ seed: int | None = None,
261
+ address: str | None = None,
262
+ live: bool = False,
263
+ camera_url: str | Sequence[str] | None = None,
264
+ token: str | None = None,
265
+ rest_pose: dict[str, float] | None = None,
266
+ ) -> MicroduckAdapter:
267
+ refuse_rest_pose("microduck", rest_pose)
268
+ url = one_camera_url(camera_url, spec=f"microduck:{backend}")
269
+ # `token` is accepted and unused, deliberately: the factory calls every adapter's `make`
270
+ # with the same four keywords, and this robot has nothing to authenticate to. `robotd`'s
271
+ # socket has no auth at all — access is filesystem permissions on /run/robotd.sock — and
272
+ # mediad's own note is that a pairing PIN which is 000000 on every robot "authenticates
273
+ # nobody". Reach both over ssh rather than trusting the network.
274
+ from quackd_microduck.transports.factory import make_transport
275
+
276
+ transport = make_transport(backend, seed=seed, address=address, live=live, camera_url=url)
277
+ return MicroduckAdapter(transport, robot_id=robot_id or "microduck")
278
+
279
+
280
+ __all__ = [
281
+ "BACKENDS",
282
+ "MICRODUCK_VERBS",
283
+ "MicroduckAdapter",
284
+ "conditions",
285
+ "describe",
286
+ "implementations",
287
+ "make",
288
+ "microduck_conditions",
289
+ "microduck_manifest",
290
+ ]
291
+
292
+
293
+ # What this adapter reads from upstream, for `quackd doctor`. Two rows: the robot's own IPC
294
+ # protocol, and the model and walking policy the physics backend fetches at run time and never
295
+ # ships. Declared here rather than in a table in the core (ADR-0022).
296
+ def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
297
+ from quackd_microduck import upstream_api as robotd
298
+ from quackd_microduck.sim3d import upstream_api as rl
299
+
300
+ return (
301
+ ("microduck", robotd, "docs/adapter-status.md", "a robotd (the jsonrpc backend)"),
302
+ (
303
+ "microduck_rl",
304
+ rl,
305
+ "docs/adr/0030-mujoco-physics-backend.md",
306
+ "a robot: the model and the policies are fetched at run time and never shipped",
307
+ ),
308
+ )
309
+
310
+
311
+ UPSTREAMS = _upstream_rows()
@@ -0,0 +1,18 @@
1
+ """The physics simulator: a MuJoCo arena the same shape as the cartoon's.
2
+
3
+ `sim2d` exists to test the agent loop, and says so; it will never tell you whether a gait
4
+ works, because it has no joints. This package is where a robot's own controllers run for
5
+ real: a rigid body world, a camera that renders what the head would see, and, once a body
6
+ brings one, the walking policy the robot ships with. The arena, the ball, the kick cone, the
7
+ deadman and the seeded spawn order are the cartoon's, deliberately, so a `.duck` that asks
8
+ for those runs unchanged against either and a seed puts the duck and the ball in the same
9
+ place in both. The one thing not carried over is the cartoon's person marker: nobody stands
10
+ in this arena, so a `.duck` about a person — `follow-me` — is a 2D task.
11
+
12
+ Nearly everything here imports `mujoco`, which is an optional extra (`quackd[mujoco]`):
13
+ nothing on the default path imports this package, and the transport that uses it imports it
14
+ inside `connect()` so `--robot microduck:mujoco` fails with the extra's name rather than a
15
+ stack. The exception is `gait.py`, which is deliberately pure arithmetic over floats, so the
16
+ rule that decides whether a duck moves or only reports moving is tested on every runner
17
+ rather than only where the extra and a filled asset cache happen to meet.
18
+ """