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.
- quackd_huebot-0.21.0/.gitignore +48 -0
- quackd_huebot-0.21.0/PKG-INFO +25 -0
- quackd_huebot-0.21.0/README.md +13 -0
- quackd_huebot-0.21.0/pyproject.toml +32 -0
- quackd_huebot-0.21.0/src/quackd_huebot/__init__.py +384 -0
- quackd_huebot-0.21.0/src/quackd_huebot/transports/__init__.py +6 -0
- quackd_huebot-0.21.0/src/quackd_huebot/transports/factory.py +39 -0
- quackd_huebot-0.21.0/src/quackd_huebot/transports/jsonrpc_unix.py +1820 -0
- quackd_huebot-0.21.0/src/quackd_huebot/upstream_api.py +457 -0
- quackd_huebot-0.21.0/src/quackd_huebot/verbs.py +380 -0
|
@@ -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)}")
|