quackd-huebot 0.21.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,48 @@
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
+ # the crash dump Git Bash leaves on Windows
34
+ *.stackdump
35
+
36
+ # upstream assets, never vendored (docs/reference/licenses.md). A real microduck:mujoco run leaves
37
+ # upstream's CC BY-SA-NC model in ~/.quackd/cache, and a debugging copy next to the checkout
38
+ # is one `git add -A` from being permanent in a public history. The SO-101's model from
39
+ # SO-ARM100 is Apache-2.0, and it stays upstream all the same.
40
+ *.onnx
41
+ *.pt
42
+ *.stl
43
+ robot_walk.xml
44
+ so101_*.xml
45
+ # A learned policy's weights are the user's download under their own licence
46
+ # (docs/guides/policies.md), and `quackd policy serve` fetches them into the Hugging Face cache,
47
+ # never into the checkout.
48
+ *.safetensors
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: quackd-huebot
3
+ Version: 0.21.0
4
+ Summary: The Huebot adapter for quackd. Install it as quackd[huebot].
5
+ Project-URL: Homepage, https://github.com/rokbenko/quackd
6
+ Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapters/huebot/README.md
7
+ Author: Rok Benko
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: quackd<0.22,>=0.21.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # quackd-huebot
14
+
15
+ The [Huebot](https://ce-net.com/huebot) adapter for [quackd](https://github.com/rokbenko/quackd).
16
+ Install it through quackd rather than directly:
17
+
18
+ ```bash
19
+ uv pip install "quackd[huebot]"
20
+ ```
21
+
22
+ No Huebot has run quackd. Huebot's own API is closed, so `huebot:jsonrpc` speaks the JSON-RPC of the
23
+ Microduck's `robotd` to a shim its maker is writing for the robot, and every name it relies on is
24
+ listed in `upstream_api.py`. What the shim has to answer, and what quackd refuses:
25
+ [docs/adapters/huebot/README.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/huebot/README.md).
@@ -0,0 +1,13 @@
1
+ # quackd-huebot
2
+
3
+ The [Huebot](https://ce-net.com/huebot) adapter for [quackd](https://github.com/rokbenko/quackd).
4
+ Install it through quackd rather than directly:
5
+
6
+ ```bash
7
+ uv pip install "quackd[huebot]"
8
+ ```
9
+
10
+ No Huebot has run quackd. Huebot's own API is closed, so `huebot:jsonrpc` speaks the JSON-RPC of the
11
+ Microduck's `robotd` to a shim its maker is writing for the robot, and every name it relies on is
12
+ listed in `upstream_api.py`. What the shim has to answer, and what quackd refuses:
13
+ [docs/adapters/huebot/README.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/huebot/README.md).
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quackd-huebot"
7
+ dynamic = ["version"]
8
+ description = "The Huebot adapter for quackd. Install it as quackd[huebot]."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Rok Benko" }]
13
+ dependencies = ["quackd>=0.21.0,<0.22"]
14
+
15
+ [project.entry-points."quackd.adapters"]
16
+ huebot = "quackd_huebot"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/rokbenko/quackd"
20
+ Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapters/huebot/README.md"
21
+
22
+ [tool.hatch.version]
23
+ path = "src/quackd_huebot/__init__.py"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/quackd_huebot"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["src", "README.md", "pyproject.toml"]
30
+
31
+ [tool.uv.sources]
32
+ quackd = { workspace = true }
@@ -0,0 +1,384 @@
1
+ """The Huebot adapter: a small biped from Leif Rydenfalk, over the shim its maker is writing.
2
+
3
+ Huebot does not run the Microduck runtime, and its own API is closed. Its maker is writing a shim
4
+ for the robot that is to answer the same calls the Microduck's `robotd` answers, the same way, so
5
+ this adapter is the Microduck's, copied and renamed rather than imported, since the two robots may
6
+ diverge: the same protocol, the same version gate, the same hold on a walk the robot does not take,
7
+ and the same reliance on the robot's own deadman. What it leaves out is what Huebot's shim does not
8
+ answer yet, `robot.look`, so there is no `gaze` and no `watch`, and the Microduck's simulators,
9
+ whose model and gait are not Huebot's. It has no `grab` either, until its maker says what
10
+ `ground_pick` does on Huebot. Two backends: `mock`, the core's scripted body, and `jsonrpc`, the
11
+ shim, which nobody has run (docs/adapters/huebot/README.md).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncIterator, Callable, Collection, Sequence
17
+ from typing import Any
18
+
19
+ from PIL import Image
20
+
21
+ from quackd.adapters.base import RestResult, one_camera_url, refuse_rest_pose
22
+ from quackd.adapters.manifest import (
23
+ Datasheet,
24
+ Figure,
25
+ Frame,
26
+ Health,
27
+ RobotManifest,
28
+ SafetyAuthority,
29
+ Sensor,
30
+ verb_spec,
31
+ )
32
+ from quackd.transport.base import Ack, DuckState, DuckTransport, HeartbeatError, Intent
33
+ from quackd.verbs.core import CORE
34
+ from quackd.verbs.registry import Precondition, Verb
35
+ from quackd_huebot.verbs import HUEBOT_VERBS, SKILLS_OF, huebot_conditions
36
+
37
+ __version__ = "0.21.0"
38
+ """Kept in step with quackd's own version by scripts/set_version.py. It lives here
39
+ rather than being read from the core, because this file is all an adapter's sdist
40
+ contains."""
41
+
42
+ BACKENDS = ("mock", "jsonrpc")
43
+
44
+ DEADMAN_SEEN = False
45
+ """Whether a Huebot has passed step 3 of docs/adapters/huebot/hardware-checklist.md, which is
46
+ where its legs are seen to stop once `robot.move` stops arriving, on a stand. Until one has, the
47
+ description of a real Huebot claims no deadman, as the Open Duck's bridge and the ToddlerBot's
48
+ daemon claim none they have not reported, and quackd relies on the shim's all the same, as the
49
+ deadman's UNVERIFIED ref in `upstream_api.py` says: nothing else stops a walk whose link died.
50
+ The mock is not a robot, and claims what every offline backend claims."""
51
+
52
+ _MOVE_DESCRIPTION = (
53
+ "Walk with a velocity for a duration. Use small values; the robot is 25 cm tall."
54
+ )
55
+ _GO_TO_DESCRIPTION = (
56
+ "Walk toward a detected target and stop at a distance. Closes the loop on the camera itself."
57
+ )
58
+ _SEARCH_SCAN_DESCRIPTION = "Rotate in steps, looking for a target. Returns where it was seen."
59
+ # The core's wording offers `kick, grab`, and Huebot has no `grab`. Its `then` parameter still
60
+ # says so, since a parameter's description is the core's and no manifest can change it.
61
+ _APPROACH_AND_DESCRIPTION = "walk_to a target, then run another verb (e.g. kick)."
62
+ BLURB = "a small biped robot (25 cm)"
63
+
64
+ _MAKER_PAGE = "ce-net.com/huebot, read 2026-10-20"
65
+ """Where every figure below comes from: the maker's own page, on the day it was read."""
66
+
67
+ DATASHEET = Datasheet(
68
+ height_m=Figure(value=0.25, confidence="official", source=_MAKER_PAGE),
69
+ dof=Figure(
70
+ value=15,
71
+ confidence="official",
72
+ source=_MAKER_PAGE,
73
+ note="Feetech HD-1910 bus servos on one serial bus",
74
+ ),
75
+ manipulator="none",
76
+ tethered=None,
77
+ cannot=[
78
+ "carry or push anything: its maker's page lists no arms",
79
+ "climb or descend a step",
80
+ ],
81
+ notes=[
82
+ "The speed caps are quackd's own clamp on the twists it sends, not a measured hardware "
83
+ "maximum: nobody has published one for Huebot",
84
+ "Its maker said on 2026-10-15 that it stands and steps, and that the walk's balance is "
85
+ "still being tuned",
86
+ "Its head turns on four servos, its maker wrote on 2026-10-15, and quackd turns none of "
87
+ "them: robot.look is not on Huebot's side yet",
88
+ "It has a speaker and a 13 by 8 LED face, its maker's page says. quack and say send "
89
+ "robot.sound, which its maker has not named among the shim's calls, and quackd does not "
90
+ "drive the face",
91
+ "Its time-of-flight sensor is an 8 by 8 grid, which quackd does not read. The maker's "
92
+ "page lists its battery as coming",
93
+ ],
94
+ )
95
+
96
+
97
+ def offered_skill_verbs(skills: Collection[str] | None) -> list[str]:
98
+ """The verbs that send `robot.do` a Huebot offers: every one while nothing has said which
99
+ skills it has, which is the description `validate` reads and the mock, and on a connected
100
+ robot each one whose every skill its shim listed (`SKILLS_OF`)."""
101
+ return [
102
+ verb
103
+ for verb, needs in SKILLS_OF.items()
104
+ if skills is None or all(skill in skills for skill in needs)
105
+ ]
106
+
107
+
108
+ def huebot_manifest(
109
+ backend: str,
110
+ robot_id: str = "huebot",
111
+ *,
112
+ camera: bool = True,
113
+ skills: Collection[str] | None = None,
114
+ ) -> RobotManifest:
115
+ """Huebot as data. Static: no connection needed (validate, announce, doctor).
116
+
117
+ `camera` is what a description cannot settle on its own. The shim's socket carries no
118
+ picture, so on `jsonrpc` a frame exists only when `--camera-url` names something serving
119
+ one, and a frame has arrived from it. Advertising `observe` either way promises the pilot
120
+ vision it may not have.
121
+
122
+ `skills` is what the shim's `robot.subscribe` answer listed, on a connected robot, and None
123
+ everywhere else. Each verb that sends `robot.do` is declared only when every skill it sends
124
+ is among them, so a skill Huebot does not have is never a verb the pilot is offered.
125
+
126
+ No lens goes in the limits: the maker publishes none, so a detector measures through a
127
+ guess, says so, and `--fov-deg` gives it the real one."""
128
+ skill_verbs = offered_skill_verbs(skills)
129
+ core = [
130
+ verb_spec(CORE["report_state"], core=True),
131
+ verb_spec(CORE["stop"], core=True),
132
+ verb_spec(HUEBOT_VERBS["say"], core=True),
133
+ verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION),
134
+ ]
135
+ if camera:
136
+ core += [
137
+ verb_spec(CORE["observe"], core=True),
138
+ verb_spec(CORE["go_to"], core=True, description=_GO_TO_DESCRIPTION),
139
+ verb_spec(CORE["search_scan"], core=True, description=_SEARCH_SCAN_DESCRIPTION),
140
+ verb_spec(CORE["approach_and"], core=True, description=_APPROACH_AND_DESCRIPTION),
141
+ ]
142
+ extensions = [
143
+ verb_spec(v, core=False)
144
+ for n, v in HUEBOT_VERBS.items()
145
+ if n != "say" and (n not in SKILLS_OF or n in skill_verbs)
146
+ ]
147
+ sensors: list[Sensor] = ["tof", "microphone"]
148
+ if camera:
149
+ sensors.insert(0, "camera")
150
+ # A precondition is read once, when the verb starts, and what stops a robot picked up
151
+ # halfway through one is the transport's hold on `robot.move`.
152
+ gated: dict[str, list[str]] = {
153
+ "move": ["standing"],
154
+ "kick": ["standing"],
155
+ "sit": ["not_fallen"],
156
+ "stand": ["not_fallen"],
157
+ }
158
+ if camera:
159
+ gated |= {verb: ["standing"] for verb in ("go_to", "search_scan", "approach_and")}
160
+ declared = {spec.name for spec in core + extensions}
161
+ # The claim is about hardware, and no Huebot has shown its deadman yet (`DEADMAN_SEEN`).
162
+ deadman = backend == "mock" or DEADMAN_SEEN
163
+ preconditions = {verb: names for verb, names in gated.items() if verb in declared}
164
+ return RobotManifest(
165
+ id=robot_id,
166
+ vendor="leif-rydenfalk",
167
+ model="huebot",
168
+ embodiment="biped",
169
+ mobility="legged",
170
+ intents=["twist", "skill", "sound"],
171
+ sensors=sensors,
172
+ verbs=core + extensions,
173
+ preconditions=preconditions,
174
+ safety_authority=SafetyAuthority(
175
+ native="robotd_deadman" if deadman else "none", deadman=deadman, heartbeat_hz=2.0
176
+ ),
177
+ frame=Frame(reference="body"),
178
+ limits={"max_vx": 0.15, "max_vy": 0.1, "max_wz": 0.75},
179
+ backend=backend,
180
+ blurb=BLURB,
181
+ datasheet=DATASHEET,
182
+ )
183
+
184
+
185
+ def _shim_line(shim: dict[str, Any]) -> str:
186
+ """`API v41, build 0.16.1, revision d818af5`: the version, the build and the revision the
187
+ shim answered `hello` with. The protocol lets the last two be null, so either can be
188
+ missing."""
189
+ build = shim.get("daemon_version") or "none named"
190
+ revision = shim.get("revision") or "none"
191
+ return f"API v{shim.get('api_version')}, build {build}, revision {revision}"
192
+
193
+
194
+ class HuebotAdapter:
195
+ """A `RobotAdapter` over either Huebot transport."""
196
+
197
+ name = "huebot"
198
+
199
+ def __init__(self, transport: DuckTransport, *, robot_id: str = "huebot") -> None:
200
+ self.transport = transport
201
+ self.backend = transport.name
202
+ self.robot_id = robot_id
203
+ self.manifest: RobotManifest | None = None
204
+
205
+ # ── protocol ────────────────────────────────────────────────────────────────────
206
+
207
+ async def connect(self) -> RobotManifest:
208
+ await self.transport.connect()
209
+ # The mock always has a camera. A real Huebot has one only if a frame actually arrived:
210
+ # a URL that was typed but never answered is not a camera, and advertising `observe` on
211
+ # the strength of one is how a pilot gets told it can see.
212
+ if hasattr(self.transport, "camera_working"):
213
+ camera = bool(self.transport.camera_working)
214
+ else:
215
+ camera = getattr(self.transport, "camera_url", True) is not None
216
+ # The skills the shim listed, which the verbs that send `robot.do` are offered for. The
217
+ # mock has no list, and is described whole.
218
+ listed = getattr(self.transport, "skills", None)
219
+ skills = tuple(str(s) for s in listed) if isinstance(listed, Sequence) else None
220
+ self.manifest = huebot_manifest(self.backend, self.robot_id, camera=camera, skills=skills)
221
+ return self.manifest
222
+
223
+ async def disconnect(self) -> None:
224
+ await self.transport.close()
225
+
226
+ async def close(self) -> None:
227
+ await self.disconnect()
228
+
229
+ async def get_state(self) -> DuckState:
230
+ return await self.transport.get_state()
231
+
232
+ async def get_frame(self) -> Image.Image | None:
233
+ return await self.transport.get_frame()
234
+
235
+ async def send_intent(self, intent: Intent) -> Ack:
236
+ return await self.transport.send_intent(intent)
237
+
238
+ async def health(self) -> Health:
239
+ try:
240
+ await self.transport.heartbeat()
241
+ except HeartbeatError as e:
242
+ return Health(ok=False, reason=str(e))
243
+ state = await self.transport.get_state()
244
+ extras: dict[str, Any] = {"policy": state.policy, "posture": state.posture}
245
+ # Which shim answered, a row in doctor's health block, so what a robot runs is on the
246
+ # screen at the checklist's go/no-go gate. Only a real robot says; the mock has none.
247
+ if isinstance(shim := state.extras.get("shim"), dict):
248
+ extras["shim"] = _shim_line(shim)
249
+ return Health(ok=True, battery_percent=state.battery_percent, extras=extras)
250
+
251
+ async def heartbeat(self) -> None:
252
+ await self.transport.heartbeat()
253
+
254
+ async def stop(self) -> None:
255
+ await self.transport.stop()
256
+
257
+ async def go_to_rest(self) -> RestResult:
258
+ """A rest pose is a joint-angle map for an arm, and this body takes postures through
259
+ its own verbs (`sit`, `stand`), so there is nothing here to drive it to."""
260
+ return RestResult.none()
261
+
262
+ @property
263
+ def stop_error(self) -> str | None:
264
+ """Why the last `stop` did not reach the robot, which `stop` in `verbs/core.py` reads off
265
+ the object it was handed, this adapter."""
266
+ return getattr(self.transport, "stop_error", None)
267
+
268
+ @property
269
+ def camera_error(self) -> str | None:
270
+ """Why the camera has no frame to hand out just now, when the transport knows, which
271
+ `observe`, `go_to` and `search_scan` read off this adapter when a frame does not come."""
272
+ error = getattr(self.transport, "camera_error", None)
273
+ return str(error) if error else None
274
+
275
+ @property
276
+ def head_fade_s(self) -> float:
277
+ """How long `go_to` and `search_scan` wait after centring the head, which they read off
278
+ this adapter: 0, on every backend. The Microduck's robotd glances around on its own and
279
+ takes a second and a half to let go of a glance once a look arrives. Huebot is sent no
280
+ look at all, since its shim does not answer `robot.look` yet, so there is nothing to
281
+ wait out, and the look-ahead in both verbs goes on at once."""
282
+ return 0.0
283
+
284
+ @property
285
+ def frames_upright(self) -> bool:
286
+ """Whether every frame this robot hands out is upright and known fresh, which `watch`
287
+ needs, and Huebot has no `watch`: no camera quackd reads on it says either, so False on
288
+ `jsonrpc`, and the mock's scripted frames say so of themselves."""
289
+ return getattr(self.transport, "frames_upright", False) is True
290
+
291
+ @property
292
+ def twist_shown_since(self) -> Callable[[float], bool] | None:
293
+ """The transport's own `twist_shown_since` on `jsonrpc`, and None on the mock, which says
294
+ nothing of the kind. `search_scan` reads it off the object it was handed, this adapter
295
+ (`quackd.transport.base.twist_shown_since_of`), to count the turns it says it made by
296
+ the ones the shim's state showed it took, since nothing answers a `robot.move`. A method
297
+ here that answered for the mock would read as a body that took none of its turns."""
298
+ shown = getattr(self.transport, "twist_shown_since", None)
299
+ return shown if callable(shown) else None
300
+
301
+ def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
302
+ return self.transport.subscribe(topic)
303
+
304
+ def now(self) -> float:
305
+ return self.transport.now()
306
+
307
+ async def sleep(self, seconds: float) -> None:
308
+ await self.transport.sleep(seconds)
309
+
310
+ def preconditions(self) -> dict[str, Precondition]:
311
+ return huebot_conditions()
312
+
313
+ def implementations(self) -> dict[str, Verb]:
314
+ return dict(HUEBOT_VERBS)
315
+
316
+
317
+ # ── what the factory calls (the same four names on every adapter package) ───────────────
318
+
319
+
320
+ def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
321
+ return huebot_manifest(backend, robot_id or "huebot")
322
+
323
+
324
+ def implementations() -> dict[str, Verb]:
325
+ return dict(HUEBOT_VERBS)
326
+
327
+
328
+ def conditions() -> dict[str, Precondition]:
329
+ return huebot_conditions()
330
+
331
+
332
+ def make(
333
+ backend: str,
334
+ *,
335
+ robot_id: str | None = None,
336
+ seed: int | None = None,
337
+ address: str | None = None,
338
+ live: bool = False,
339
+ camera_url: str | Sequence[str] | None = None,
340
+ token: str | None = None,
341
+ rest_pose: dict[str, float] | None = None,
342
+ ) -> HuebotAdapter:
343
+ refuse_rest_pose("huebot", rest_pose)
344
+ url = one_camera_url(camera_url, spec=f"huebot:{backend}")
345
+ # `token`, `seed` and `live` are accepted and unused, deliberately: the factory calls every
346
+ # adapter's `make` with the same keywords. The protocol has no authentication at all, so the
347
+ # shim is reached over ssh or on the robot itself, never through an open port.
348
+ from quackd_huebot.transports.factory import make_transport
349
+
350
+ transport = make_transport(backend, address=address, camera_url=url)
351
+ return HuebotAdapter(transport, robot_id=robot_id or "huebot")
352
+
353
+
354
+ __all__ = [
355
+ "BACKENDS",
356
+ "HUEBOT_VERBS",
357
+ "HuebotAdapter",
358
+ "conditions",
359
+ "describe",
360
+ "huebot_conditions",
361
+ "huebot_manifest",
362
+ "implementations",
363
+ "make",
364
+ "offered_skill_verbs",
365
+ ]
366
+
367
+
368
+ # What this adapter reads from upstream, for `quackd doctor`: the protocol Huebot's shim answers,
369
+ # read in the Microduck's source, beside what its maker says of Huebot. Declared here rather
370
+ # than in a table in the core (ADR-0022).
371
+ def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
372
+ from quackd_huebot import upstream_api as shim
373
+
374
+ return (
375
+ (
376
+ "huebot",
377
+ shim,
378
+ "docs/adapters/huebot/README.md",
379
+ "a Huebot's shim (the jsonrpc backend)",
380
+ ),
381
+ )
382
+
383
+
384
+ UPSTREAMS = _upstream_rows()
@@ -0,0 +1,6 @@
1
+ """Huebot's transports: the shim on the robot, and the factory that picks one.
2
+
3
+ `mock` is not here. It is the core's, which every adapter's mock is. What is here is what only a
4
+ Huebot has, the shim its maker is writing, which is to answer the Microduck's JSON-RPC, and the
5
+ factory.
6
+ """
@@ -0,0 +1,39 @@
1
+ """A backend name → a `DuckTransport`.
2
+
3
+ Two, and the first is the mock, because a Huebot runs only where its maker's shim answers. The
4
+ robot-facing transport is reachable only by name, which is how the "no UNVERIFIED upstream call
5
+ without `--robot huebot:jsonrpc`" promise is kept, and it has no default address: the shim is
6
+ wherever its maker put it, so `--address` says where, and building it without one is refused.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from quackd.adapters.base import AdapterError
12
+ from quackd.transport.base import DuckTransport, TransportError
13
+
14
+ TRANSPORT_NAMES = ("mock", "jsonrpc")
15
+
16
+
17
+ def make_transport(
18
+ name: str,
19
+ *,
20
+ address: str | None = None,
21
+ camera_url: str | None = None,
22
+ ) -> DuckTransport:
23
+ name = name.lower()
24
+ if name == "mock":
25
+ from quackd.transport.mock import MockTransport
26
+
27
+ return MockTransport()
28
+ if name == "jsonrpc":
29
+ from quackd_huebot.transports.jsonrpc_unix import NO_ADDRESS, JsonRpcUnixTransport
30
+
31
+ # Refused here, as the body is built, and not at its connect: a run and a pilot flock
32
+ # build every body before they write anything, so the refusal leaves nothing on disk.
33
+ # A description builds no transport, so `validate` and `doctor` answer without one. It
34
+ # is an adapter's refusal, which is a TransportError too, because `quackd robot release`
35
+ # and `rest-pose` build a robot catching only that, and anything else was a traceback.
36
+ if not address:
37
+ raise AdapterError(NO_ADDRESS)
38
+ return JsonRpcUnixTransport(address=address, camera_url=camera_url)
39
+ raise TransportError(f"unknown transport {name!r}; choose one of {', '.join(TRANSPORT_NAMES)}")