quackd-moss 0.22.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_moss-0.22.0/.gitignore +48 -0
- quackd_moss-0.22.0/PKG-INFO +27 -0
- quackd_moss-0.22.0/README.md +15 -0
- quackd_moss-0.22.0/pyproject.toml +33 -0
- quackd_moss-0.22.0/src/quackd_moss/__init__.py +567 -0
- quackd_moss-0.22.0/src/quackd_moss/cockpit.py +1509 -0
- quackd_moss-0.22.0/src/quackd_moss/mock.py +1252 -0
- quackd_moss-0.22.0/src/quackd_moss/upstream_api.py +1095 -0
- quackd_moss-0.22.0/src/quackd_moss/verbs.py +823 -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,27 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quackd-moss
|
|
3
|
+
Version: 0.22.0
|
|
4
|
+
Summary: The MOSS litter-picking rover adapter for quackd. Install it as quackd[moss].
|
|
5
|
+
Project-URL: Homepage, https://github.com/rokbenko/quackd
|
|
6
|
+
Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapters/moss/README.md
|
|
7
|
+
Author: Rok Benko
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: quackd<0.23,>=0.22.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# quackd-moss
|
|
14
|
+
|
|
15
|
+
The `moss` robot adapter for [quackd](https://github.com/rokbenko/quackd): MOSS, the small tracked
|
|
16
|
+
litter-picking rover by Laurent Genoud (Show Robotics). Install it through quackd rather than
|
|
17
|
+
directly:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv pip install "quackd[moss]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It speaks MOSS's own cockpit over HTTP and, for what the cockpit cannot do, a small bridge quackd
|
|
24
|
+
ships for the rover. It imports nothing of MOSS's or dimOS's. Every upstream name it relies on is
|
|
25
|
+
read from upstream source at a pinned commit and listed in `upstream_api.py`. It has never been run
|
|
26
|
+
against a rover. What it does, what it refuses and why:
|
|
27
|
+
[docs/adapters/moss/README.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/moss/README.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# quackd-moss
|
|
2
|
+
|
|
3
|
+
The `moss` robot adapter for [quackd](https://github.com/rokbenko/quackd): MOSS, the small tracked
|
|
4
|
+
litter-picking rover by Laurent Genoud (Show Robotics). Install it through quackd rather than
|
|
5
|
+
directly:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv pip install "quackd[moss]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It speaks MOSS's own cockpit over HTTP and, for what the cockpit cannot do, a small bridge quackd
|
|
12
|
+
ships for the rover. It imports nothing of MOSS's or dimOS's. Every upstream name it relies on is
|
|
13
|
+
read from upstream source at a pinned commit and listed in `upstream_api.py`. It has never been run
|
|
14
|
+
against a rover. What it does, what it refuses and why:
|
|
15
|
+
[docs/adapters/moss/README.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/moss/README.md).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quackd-moss"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "The MOSS litter-picking rover adapter for quackd. Install it as quackd[moss]."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Rok Benko" }]
|
|
13
|
+
dependencies = ["quackd>=0.22.0,<0.23"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[project.entry-points."quackd.adapters"]
|
|
17
|
+
moss = "quackd_moss"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/rokbenko/quackd"
|
|
21
|
+
Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapters/moss/README.md"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
path = "src/quackd_moss/__init__.py"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/quackd_moss"]
|
|
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,567 @@
|
|
|
1
|
+
"""The MOSS adapter: a small tracked litter-picking rover with an arm and a bin on top.
|
|
2
|
+
|
|
3
|
+
MOSS is Laurent Genoud's (Show Robotics) open rover, mostly 3D printed: two tracks, an SO-101
|
|
4
|
+
derived arm with a parallel gripper widened to 90 mm, a RealSense nose camera, a wrist camera
|
|
5
|
+
and a 2D lidar, on a Jetson or a Raspberry Pi. Its software is `moss_dimos`, a set of blueprints
|
|
6
|
+
for Dimensional's dimOS. The maker's own status line says the rover drives and demonstrates pick
|
|
7
|
+
and drop motions, and that autonomous litter picking and a field test are not yet demonstrated.
|
|
8
|
+
|
|
9
|
+
quackd imports neither MOSS nor dimOS. Two backends:
|
|
10
|
+
|
|
11
|
+
- `mock`, offline, never kinder than the rover (`mock.py`).
|
|
12
|
+
- `cockpit`, the real rover: MOSS's own cockpit over HTTP for state, the pose, the battery,
|
|
13
|
+
goals for its planner, the explorer and the soft stop, and quackd's own bridge on the rover
|
|
14
|
+
(`bridge/moss/`) for what the cockpit cannot do: a deadman twist, frames that say when they
|
|
15
|
+
were taken, the maker's arm cycle and the litter mission's stop (`cockpit.py`). Never run
|
|
16
|
+
against a rover by us.
|
|
17
|
+
|
|
18
|
+
The manifest is narrowed at connect by what answered: the stack running (moss-nav, moss-explore
|
|
19
|
+
or moss-litter, anything else refused), whether the bridge answers, whether the arm's
|
|
20
|
+
coordinator answers, and on moss-litter whether the mission answers. `describe()` declares
|
|
21
|
+
every stack's verbs together, because a task is validated against the static manifest before
|
|
22
|
+
anything connects.
|
|
23
|
+
|
|
24
|
+
No core verb is overridden. The bridge shapes every twist on the rover, where the timing is
|
|
25
|
+
real: arcs are kept tight enough that the inner track never reverses, and a turn on the spot is
|
|
26
|
+
sent as the maker's own pulses, because this body cannot turn slowly in place.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
33
|
+
from typing import Any
|
|
34
|
+
from urllib.parse import parse_qs, urlsplit
|
|
35
|
+
|
|
36
|
+
from PIL import Image
|
|
37
|
+
|
|
38
|
+
from quackd.adapters.base import AdapterError, RestResult, camera_urls, refuse_rest_pose
|
|
39
|
+
from quackd.adapters.manifest import (
|
|
40
|
+
Datasheet,
|
|
41
|
+
Figure,
|
|
42
|
+
Frame,
|
|
43
|
+
Health,
|
|
44
|
+
RobotManifest,
|
|
45
|
+
SafetyAuthority,
|
|
46
|
+
verb_spec,
|
|
47
|
+
)
|
|
48
|
+
from quackd.transport.base import (
|
|
49
|
+
Ack,
|
|
50
|
+
CameraFrame,
|
|
51
|
+
DuckState,
|
|
52
|
+
DuckTransport,
|
|
53
|
+
HeartbeatError,
|
|
54
|
+
Intent,
|
|
55
|
+
frames_of,
|
|
56
|
+
)
|
|
57
|
+
from quackd.verbs.core import CORE
|
|
58
|
+
from quackd.verbs.registry import Precondition, Verb
|
|
59
|
+
from quackd_moss import upstream_api as up
|
|
60
|
+
from quackd_moss.verbs import (
|
|
61
|
+
ARM_GIVE_UP_S,
|
|
62
|
+
MAX_VX,
|
|
63
|
+
MAX_VY,
|
|
64
|
+
MAX_WZ,
|
|
65
|
+
STOP_VERB_TIMEOUT_S,
|
|
66
|
+
VERB_MARGIN_S,
|
|
67
|
+
moss_conditions,
|
|
68
|
+
moss_verbs,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__version__ = "0.22.0"
|
|
72
|
+
"""Kept in step with quackd's own version by scripts/set_version.py. It lives here
|
|
73
|
+
rather than being read from the core, because this file is all an adapter's sdist
|
|
74
|
+
contains."""
|
|
75
|
+
|
|
76
|
+
BACKENDS = ("mock", "cockpit")
|
|
77
|
+
DEFAULT_ID = "moss-01"
|
|
78
|
+
|
|
79
|
+
BASE_STACKS = (up.NAV_STACK, up.EXPLORE_STACK)
|
|
80
|
+
"""The stacks quackd drives the base on: the pad's channel is quackd's twist's channel."""
|
|
81
|
+
LITTER_STACK = up.LITTER_STACK
|
|
82
|
+
STACKS = (*BASE_STACKS, LITTER_STACK)
|
|
83
|
+
"""Every stack quackd connects to. moss-gamepad and moss-base are the pad and the bare base,
|
|
84
|
+
with no odometry and no cockpit state worth reading, and are refused by name."""
|
|
85
|
+
|
|
86
|
+
HEARTBEAT_HZ = 2.0
|
|
87
|
+
"""quackd's own heartbeat runs every half second, and that is what feeds the bridge's session
|
|
88
|
+
keepalive (`cockpit.py`)."""
|
|
89
|
+
|
|
90
|
+
BLURB = (
|
|
91
|
+
"a small tracked litter-picking rover, with an arm and a parallel gripper that drop what "
|
|
92
|
+
"they pick into a bin on top"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
DATASHEET = Datasheet(
|
|
96
|
+
mass_kg=Figure(
|
|
97
|
+
value=4.2,
|
|
98
|
+
confidence="official",
|
|
99
|
+
source="the maker, in issue 2 of the MOSS repository",
|
|
100
|
+
note=(
|
|
101
|
+
"the V0.3 prototype complete, while the arm and gripper weigh 768 g on their own "
|
|
102
|
+
"and the V0.6 hull has not been weighed"
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
dof=Figure(
|
|
106
|
+
value=6,
|
|
107
|
+
confidence="official",
|
|
108
|
+
source="the MOSS BOM",
|
|
109
|
+
note="five ST3215 servos on the arm and a CF35-12 on the gripper, with the two tracks on "
|
|
110
|
+
"top of that",
|
|
111
|
+
),
|
|
112
|
+
manipulator="gripper",
|
|
113
|
+
arms=1,
|
|
114
|
+
tethered=False,
|
|
115
|
+
terrain="indoor_flat",
|
|
116
|
+
not_rated=["stairs", "steps", "kerbs", "slopes"],
|
|
117
|
+
cannot=[
|
|
118
|
+
"slide sideways: it runs on two tracks",
|
|
119
|
+
"turn slowly on the spot: every turn in place runs both tracks at full power",
|
|
120
|
+
"reach for an object: the jaws close at one fixed point in front of the rover, so the "
|
|
121
|
+
"body has to bring the object there",
|
|
122
|
+
],
|
|
123
|
+
notes=[
|
|
124
|
+
"0.32 m/s at full power, measured by its maker",
|
|
125
|
+
"A 3S battery: its BMS cuts at 10.2 V, and the maker's launcher refuses to start under "
|
|
126
|
+
"10.8 V",
|
|
127
|
+
"The jaws open to 90 mm",
|
|
128
|
+
"The maker's own warning: a prototype with a lithium battery and no certified "
|
|
129
|
+
"protection, to be put on blocks for its first run",
|
|
130
|
+
"Built for streets and beaches, but neither autonomous picking nor a field test has been "
|
|
131
|
+
"demonstrated",
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
"""Payload, reach, endurance and height are not published: the maker calls the battery bay
|
|
135
|
+
packaging space and not a claim about capacity or runtime (`upstream_api.NOT_A_RUNTIME_CLAIM`)."""
|
|
136
|
+
|
|
137
|
+
_MOVE_DESCRIPTION = (
|
|
138
|
+
"Drive with a velocity for a duration: vx forward m/s (negative is back), wz rad/s "
|
|
139
|
+
"(+ = left). There is no vy on tracks. A turn on the spot goes out as MOSS's own full power "
|
|
140
|
+
"pulses and an arc is kept tight enough that the inner track never reverses, so the rover "
|
|
141
|
+
"turns slower than asked and never faster."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
_ASSUMPTIONS_ALWAYS = [
|
|
145
|
+
"a stop takes the firmware's own ramp, which the bridge reads from the stack: with MOSS's "
|
|
146
|
+
"launcher that is half a second from full power",
|
|
147
|
+
"how far the maker's turn pulses turn the rover is unmeasured, so search_scan's angles are "
|
|
148
|
+
"what it asked for, not what the rover turned",
|
|
149
|
+
"the lidar odometry may lag a full power pivot, and drive_to's arrival is the planner's own "
|
|
150
|
+
"word rather than a pose quackd checked",
|
|
151
|
+
"the nose lens is uncalibrated unless the stack publishes its camera_info",
|
|
152
|
+
]
|
|
153
|
+
_ASSUMPTION_NO_ARM = (
|
|
154
|
+
f"with no {up.ARM_STACK} run nothing holds the arm and nothing reports it, so quackd drives "
|
|
155
|
+
"the "
|
|
156
|
+
"base as if it were folded"
|
|
157
|
+
)
|
|
158
|
+
_ASSUMPTION_NO_CYCLE = (
|
|
159
|
+
"the arm's coordinator answers and the arm is watched, but the bridge found none of the "
|
|
160
|
+
f"maker's cycle to play ({up.ARM_PLAY.name}), so no arm verb is offered"
|
|
161
|
+
)
|
|
162
|
+
_ASSUMPTION_NO_BRIDGE = (
|
|
163
|
+
"without quackd's bridge nothing on the rover halts what quackd started when it goes "
|
|
164
|
+
"quiet: MOSS finishes a goal, at most 2 m, and explores on to its own radius"
|
|
165
|
+
)
|
|
166
|
+
_ASSUMPTION_LITTER = (
|
|
167
|
+
"the litter mission acts on its own from the moment the stack starts, and every stop quackd "
|
|
168
|
+
"sends ends it until the stack restarts, so collect_litter runs once per stack start"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def split_address(address: str | None) -> tuple[str, int, int]:
|
|
173
|
+
"""`http://host:8904?bridge=9876` -> (host, cockpit port, bridge port).
|
|
174
|
+
|
|
175
|
+
The bridge's port rides in the query, as the XLeRobot's `?obs=` does, because it is on the
|
|
176
|
+
same rover and nothing else in the address could name it. The token never goes here:
|
|
177
|
+
addresses are printed and land in transcripts."""
|
|
178
|
+
from quackd_moss.cockpit import BRIDGE_PORT, COCKPIT_PORT, DEFAULT_HOST
|
|
179
|
+
|
|
180
|
+
if not address:
|
|
181
|
+
return DEFAULT_HOST, COCKPIT_PORT, BRIDGE_PORT
|
|
182
|
+
parts = urlsplit(address if "://" in address else f"http://{address}")
|
|
183
|
+
host = parts.hostname or DEFAULT_HOST
|
|
184
|
+
cockpit = parts.port or COCKPIT_PORT
|
|
185
|
+
wanted = parse_qs(parts.query).get("bridge", [""])[0]
|
|
186
|
+
return host, cockpit, int(wanted) if wanted.isdigit() else BRIDGE_PORT
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def moss_manifest(
|
|
190
|
+
backend: str,
|
|
191
|
+
robot_id: str | None = None,
|
|
192
|
+
*,
|
|
193
|
+
stack: str | None = None,
|
|
194
|
+
bridge: bool = True,
|
|
195
|
+
arm: bool = True,
|
|
196
|
+
mission: bool = True,
|
|
197
|
+
camera: bool = True,
|
|
198
|
+
cameras: Sequence[str] = (up.NOSE, up.WRIST),
|
|
199
|
+
fov_deg: float | None = None,
|
|
200
|
+
extras: dict[str, Any] | None = None,
|
|
201
|
+
arm_seen: bool | None = None,
|
|
202
|
+
) -> RobotManifest:
|
|
203
|
+
"""The rover as data. `stack` None is every stack's verbs together, which is what
|
|
204
|
+
`describe()` declares and `connect()` narrows from. `arm` is whether the arm verbs can
|
|
205
|
+
run, the coordinator and the maker's cycle both, and `arm_seen` whether the coordinator
|
|
206
|
+
answers at all, which is what lets a precondition see the arm (None: the same as arm)."""
|
|
207
|
+
own = moss_verbs()
|
|
208
|
+
stacks = STACKS if stack is None else (stack,)
|
|
209
|
+
on_base = any(s in BASE_STACKS for s in stacks)
|
|
210
|
+
planner = up.EXPLORE_STACK in stacks
|
|
211
|
+
litter = LITTER_STACK in stacks
|
|
212
|
+
twists = bridge and on_base
|
|
213
|
+
arm_verbs = bridge and arm and on_base
|
|
214
|
+
collect = bridge and mission and litter
|
|
215
|
+
|
|
216
|
+
verbs = [
|
|
217
|
+
verb_spec(CORE["report_state"], core=True),
|
|
218
|
+
# a timeout that covers the transport's own wait, so a slow stop is never cut short
|
|
219
|
+
verb_spec(CORE["stop"], core=True, timeout_s=STOP_VERB_TIMEOUT_S),
|
|
220
|
+
]
|
|
221
|
+
preconditions: dict[str, list[str]] = {}
|
|
222
|
+
if camera:
|
|
223
|
+
verbs.insert(0, verb_spec(CORE["observe"], core=True))
|
|
224
|
+
drive_gates = ["cockpit_fresh", "bridge_up", "battery_ok", "arm_folded", "arm_idle"]
|
|
225
|
+
if twists:
|
|
226
|
+
verbs.append(verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION))
|
|
227
|
+
preconditions["move"] = list(drive_gates)
|
|
228
|
+
if camera:
|
|
229
|
+
verbs += [
|
|
230
|
+
verb_spec(CORE["go_to"], core=True),
|
|
231
|
+
verb_spec(CORE["search_scan"], core=True),
|
|
232
|
+
]
|
|
233
|
+
preconditions["go_to"] = list(drive_gates)
|
|
234
|
+
preconditions["search_scan"] = list(drive_gates)
|
|
235
|
+
plan_gates = [
|
|
236
|
+
"cockpit_fresh",
|
|
237
|
+
"pose_fresh",
|
|
238
|
+
"battery_ok",
|
|
239
|
+
"not_stalled",
|
|
240
|
+
"arm_folded",
|
|
241
|
+
"arm_idle",
|
|
242
|
+
"explore_stack",
|
|
243
|
+
]
|
|
244
|
+
if planner:
|
|
245
|
+
verbs += [verb_spec(own["drive_to"], core=False), verb_spec(own["explore"], core=False)]
|
|
246
|
+
preconditions["drive_to"] = list(plan_gates)
|
|
247
|
+
preconditions["explore"] = list(plan_gates)
|
|
248
|
+
if arm_verbs:
|
|
249
|
+
verbs += [
|
|
250
|
+
verb_spec(own["pick_and_drop"], core=False),
|
|
251
|
+
verb_spec(own["stow_arm"], core=False),
|
|
252
|
+
]
|
|
253
|
+
arm_gates = ["cockpit_fresh", "bridge_up", "battery_ok", "arm_idle"]
|
|
254
|
+
preconditions["pick_and_drop"] = list(arm_gates)
|
|
255
|
+
preconditions["stow_arm"] = list(arm_gates)
|
|
256
|
+
if twists and camera:
|
|
257
|
+
# go_to, then whatever `then` names, the 37 s arm cycle included
|
|
258
|
+
timeout = float(CORE["go_to"].timeout_s) + ARM_GIVE_UP_S + 2 * VERB_MARGIN_S
|
|
259
|
+
verbs.append(verb_spec(CORE["approach_and"], core=True, timeout_s=timeout))
|
|
260
|
+
preconditions["approach_and"] = list(drive_gates)
|
|
261
|
+
if collect:
|
|
262
|
+
verbs.append(verb_spec(own["collect_litter"], core=False))
|
|
263
|
+
preconditions["collect_litter"] = [
|
|
264
|
+
"cockpit_fresh",
|
|
265
|
+
"pose_fresh",
|
|
266
|
+
"battery_ok",
|
|
267
|
+
"bridge_up",
|
|
268
|
+
"mission_up",
|
|
269
|
+
# the mission drives off at once, so the arm must be in its drive pose first
|
|
270
|
+
"arm_folded",
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
intents: list[Any] = []
|
|
274
|
+
if twists:
|
|
275
|
+
intents.append("twist")
|
|
276
|
+
if planner:
|
|
277
|
+
intents.append("pose")
|
|
278
|
+
if planner or arm_verbs or collect:
|
|
279
|
+
intents.append("skill")
|
|
280
|
+
seen = bridge and (arm if arm_seen is None else arm_seen)
|
|
281
|
+
sensors: list[Any] = (["camera"] if camera else []) + ["odometry", "battery"]
|
|
282
|
+
if seen:
|
|
283
|
+
sensors.append("joint_state")
|
|
284
|
+
|
|
285
|
+
assumptions = list(_ASSUMPTIONS_ALWAYS)
|
|
286
|
+
if not seen:
|
|
287
|
+
assumptions.append(_ASSUMPTION_NO_ARM)
|
|
288
|
+
elif not arm:
|
|
289
|
+
assumptions.append(_ASSUMPTION_NO_CYCLE)
|
|
290
|
+
if not bridge:
|
|
291
|
+
assumptions.append(_ASSUMPTION_NO_BRIDGE)
|
|
292
|
+
if litter:
|
|
293
|
+
assumptions.append(_ASSUMPTION_LITTER)
|
|
294
|
+
scope = (
|
|
295
|
+
"the bridge's own twist deadman, and three missed keepalives after this session goes "
|
|
296
|
+
"quiet, the arm cycle it started and whatever it started through the cockpit: a goal, "
|
|
297
|
+
"an exploration, the litter mission"
|
|
298
|
+
if bridge
|
|
299
|
+
else "none: nothing on the rover halts a goal or an exploration quackd started"
|
|
300
|
+
)
|
|
301
|
+
limits: dict[str, float] = {"max_vx": MAX_VX, "max_vy": MAX_VY, "max_wz": MAX_WZ}
|
|
302
|
+
if fov_deg is not None:
|
|
303
|
+
limits["camera_fov_deg"] = float(fov_deg)
|
|
304
|
+
return RobotManifest(
|
|
305
|
+
id=robot_id or DEFAULT_ID,
|
|
306
|
+
vendor="Show Robotics",
|
|
307
|
+
model="MOSS V0.6",
|
|
308
|
+
embodiment="wheeled",
|
|
309
|
+
mobility="wheeled",
|
|
310
|
+
intents=intents,
|
|
311
|
+
sensors=sensors,
|
|
312
|
+
verbs=verbs,
|
|
313
|
+
preconditions=preconditions,
|
|
314
|
+
safety_authority=SafetyAuthority(native="none", deadman=bridge, heartbeat_hz=HEARTBEAT_HZ),
|
|
315
|
+
frame=Frame(
|
|
316
|
+
reference="base",
|
|
317
|
+
note=(
|
|
318
|
+
"tracks: +x forward, +wz turns left (twist_to_tracks), and vy is dropped because "
|
|
319
|
+
"a tracked base cannot slide. The pose is the lidar odometry's, in the frame the "
|
|
320
|
+
"stack started in, and drive_to's world goals are in that frame."
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
limits=limits,
|
|
324
|
+
backend=backend,
|
|
325
|
+
blurb=BLURB,
|
|
326
|
+
datasheet=DATASHEET,
|
|
327
|
+
extras={
|
|
328
|
+
"blueprint": stack,
|
|
329
|
+
"stacks": list(stacks),
|
|
330
|
+
"cameras": list(cameras) if camera else [],
|
|
331
|
+
"deadman_scope": scope,
|
|
332
|
+
"speech": "none",
|
|
333
|
+
"assumptions": assumptions,
|
|
334
|
+
**(extras or {}),
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class MossAdapter:
|
|
340
|
+
"""A `RobotAdapter` over the mock or the cockpit backend."""
|
|
341
|
+
|
|
342
|
+
name = "moss"
|
|
343
|
+
|
|
344
|
+
def __init__(self, transport: DuckTransport, *, robot_id: str | None = None) -> None:
|
|
345
|
+
self.transport = transport
|
|
346
|
+
self.backend = transport.name
|
|
347
|
+
self.robot_id = robot_id or DEFAULT_ID
|
|
348
|
+
self.manifest: RobotManifest | None = None
|
|
349
|
+
|
|
350
|
+
async def connect(self) -> RobotManifest:
|
|
351
|
+
await self.transport.connect()
|
|
352
|
+
t = self.transport
|
|
353
|
+
# what answered, never what a flag claimed
|
|
354
|
+
self.manifest = moss_manifest(
|
|
355
|
+
self.backend,
|
|
356
|
+
self.robot_id,
|
|
357
|
+
stack=str(getattr(t, "stack", None) or "") or None,
|
|
358
|
+
bridge=bool(getattr(t, "bridge_up", False)),
|
|
359
|
+
arm=bool(getattr(t, "arm_up", False)),
|
|
360
|
+
mission=bool(getattr(t, "mission_up", False)),
|
|
361
|
+
camera=bool(getattr(t, "camera_available", False)),
|
|
362
|
+
cameras=tuple(getattr(t, "camera_keys", ()) or ()),
|
|
363
|
+
fov_deg=getattr(t, "fov_deg", None),
|
|
364
|
+
extras=dict(getattr(t, "manifest_extras", {}) or {}),
|
|
365
|
+
arm_seen=getattr(t, "arm_seen", None),
|
|
366
|
+
)
|
|
367
|
+
return self.manifest
|
|
368
|
+
|
|
369
|
+
async def disconnect(self) -> None:
|
|
370
|
+
await self.transport.close()
|
|
371
|
+
|
|
372
|
+
async def close(self) -> None:
|
|
373
|
+
await self.disconnect()
|
|
374
|
+
|
|
375
|
+
async def get_state(self) -> DuckState:
|
|
376
|
+
return await self.transport.get_state()
|
|
377
|
+
|
|
378
|
+
async def get_frame(self) -> Image.Image | None:
|
|
379
|
+
return await self.transport.get_frame()
|
|
380
|
+
|
|
381
|
+
async def get_frames(self) -> list[CameraFrame]:
|
|
382
|
+
return await frames_of(self.transport)
|
|
383
|
+
|
|
384
|
+
def frame_taken_at(self) -> float | None:
|
|
385
|
+
taken = getattr(self.transport, "frame_taken_at", None)
|
|
386
|
+
return taken() if callable(taken) else None
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def frame_period_s(self) -> float | None:
|
|
390
|
+
return getattr(self.transport, "frame_period_s", None)
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def camera_keys(self) -> list[str]:
|
|
394
|
+
return list(getattr(self.transport, "camera_keys", ()) or ())
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def camera_error(self) -> str | None:
|
|
398
|
+
return getattr(self.transport, "camera_error", None)
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def stop_error(self) -> str | None:
|
|
402
|
+
"""Why the last stop did not reach every surface, read by the core `stop` verb."""
|
|
403
|
+
return getattr(self.transport, "stop_error", None)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def close_note(self) -> str | None:
|
|
407
|
+
return getattr(self.transport, "close_note", None)
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def arm_job_id(self) -> str | None:
|
|
411
|
+
return getattr(self.transport, "arm_job_id", None)
|
|
412
|
+
|
|
413
|
+
async def send_intent(self, intent: Intent) -> Ack:
|
|
414
|
+
return await self.transport.send_intent(intent)
|
|
415
|
+
|
|
416
|
+
async def health(self) -> Health:
|
|
417
|
+
"""The heartbeat's own checks. A probe such as `quackd doctor` holds a session of its own
|
|
418
|
+
on the bridge while it is connected, and a session that started nothing halts nothing
|
|
419
|
+
when it ends or expires, on moss-litter as anywhere."""
|
|
420
|
+
try:
|
|
421
|
+
await self.transport.heartbeat()
|
|
422
|
+
except HeartbeatError as e:
|
|
423
|
+
return Health(ok=False, reason=str(e))
|
|
424
|
+
state = await self.transport.get_state()
|
|
425
|
+
return Health(
|
|
426
|
+
ok=True,
|
|
427
|
+
battery_percent=state.battery_percent,
|
|
428
|
+
extras={
|
|
429
|
+
"stack": state.extras.get("stack"),
|
|
430
|
+
"battery_v": state.extras.get("battery_v"),
|
|
431
|
+
"telemetry_age_s": state.extras.get("telemetry_age_s"),
|
|
432
|
+
"bridge_up": state.extras.get("bridge_up"),
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
async def heartbeat(self) -> None:
|
|
437
|
+
await self.transport.heartbeat()
|
|
438
|
+
|
|
439
|
+
async def stop(self) -> None:
|
|
440
|
+
await self.transport.stop()
|
|
441
|
+
|
|
442
|
+
async def go_to_rest(self) -> RestResult:
|
|
443
|
+
"""Not a body quackd parks: make() refuses a rest pose for it. The arm's drive pose is
|
|
444
|
+
reached through `stow_arm`, a verb a person confirms, never by a teardown."""
|
|
445
|
+
return RestResult.none()
|
|
446
|
+
|
|
447
|
+
def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
|
|
448
|
+
return self.transport.subscribe(topic)
|
|
449
|
+
|
|
450
|
+
def now(self) -> float:
|
|
451
|
+
return self.transport.now()
|
|
452
|
+
|
|
453
|
+
async def sleep(self, seconds: float) -> None:
|
|
454
|
+
await self.transport.sleep(seconds)
|
|
455
|
+
|
|
456
|
+
def preconditions(self) -> dict[str, Precondition]:
|
|
457
|
+
return moss_conditions()
|
|
458
|
+
|
|
459
|
+
def implementations(self) -> dict[str, Verb]:
|
|
460
|
+
return moss_verbs()
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def mobility(self) -> str:
|
|
464
|
+
return "wheeled"
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def world(self) -> Any:
|
|
468
|
+
return getattr(self.transport, "world", None)
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def post_sleep(self) -> Callable[[], None] | None:
|
|
472
|
+
return getattr(self.transport, "post_sleep", None)
|
|
473
|
+
|
|
474
|
+
@post_sleep.setter
|
|
475
|
+
def post_sleep(self, hook: Callable[[], None] | None) -> None:
|
|
476
|
+
self.transport.post_sleep = hook # type: ignore[attr-defined]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ── what the factory calls ──────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
|
|
483
|
+
"""Static: every stack's verbs together, the bridge, the arm and the mission answering.
|
|
484
|
+
`connect()` narrows it to what is really there."""
|
|
485
|
+
return moss_manifest(backend, robot_id)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def implementations() -> dict[str, Verb]:
|
|
489
|
+
return moss_verbs()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def conditions() -> dict[str, Precondition]:
|
|
493
|
+
return moss_conditions()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def make(
|
|
497
|
+
backend: str,
|
|
498
|
+
*,
|
|
499
|
+
robot_id: str | None = None,
|
|
500
|
+
seed: int | None = None,
|
|
501
|
+
address: str | None = None,
|
|
502
|
+
live: bool = False,
|
|
503
|
+
camera_url: str | Sequence[str] | None = None,
|
|
504
|
+
token: str | None = None,
|
|
505
|
+
rest_pose: dict[str, float] | None = None,
|
|
506
|
+
) -> MossAdapter:
|
|
507
|
+
refuse_rest_pose("moss", rest_pose)
|
|
508
|
+
if camera_urls(camera_url):
|
|
509
|
+
raise AdapterError(
|
|
510
|
+
f"moss:{backend} takes no --camera-url: the nose and wrist cameras come from the "
|
|
511
|
+
"rover itself, through quackd's bridge or the cockpit's own stream"
|
|
512
|
+
)
|
|
513
|
+
if backend == "mock":
|
|
514
|
+
from quackd_moss.mock import MossMock
|
|
515
|
+
|
|
516
|
+
return MossAdapter(MossMock.from_address(address), robot_id=robot_id)
|
|
517
|
+
if backend == "cockpit":
|
|
518
|
+
from quackd_moss.cockpit import TOKEN_ENV, MossCockpit
|
|
519
|
+
|
|
520
|
+
host, cockpit_port, bridge_port = split_address(address)
|
|
521
|
+
return MossAdapter(
|
|
522
|
+
MossCockpit(
|
|
523
|
+
host=host,
|
|
524
|
+
cockpit_port=cockpit_port,
|
|
525
|
+
bridge_port=bridge_port,
|
|
526
|
+
token=token or os.environ.get(TOKEN_ENV) or None,
|
|
527
|
+
),
|
|
528
|
+
robot_id=robot_id,
|
|
529
|
+
)
|
|
530
|
+
raise ValueError(f"unknown moss backend {backend!r}: choose one of {BACKENDS}")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
__all__ = [
|
|
534
|
+
"BACKENDS",
|
|
535
|
+
"BASE_STACKS",
|
|
536
|
+
"DATASHEET",
|
|
537
|
+
"DEFAULT_ID",
|
|
538
|
+
"HEARTBEAT_HZ",
|
|
539
|
+
"LITTER_STACK",
|
|
540
|
+
"STACKS",
|
|
541
|
+
"MossAdapter",
|
|
542
|
+
"conditions",
|
|
543
|
+
"describe",
|
|
544
|
+
"implementations",
|
|
545
|
+
"make",
|
|
546
|
+
"moss_manifest",
|
|
547
|
+
"split_address",
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# What this adapter reads from upstream, for `quackd doctor`. Declared here rather than in a
|
|
552
|
+
# table in the core, because the list belongs to whoever wrote the adapter (ADR-0022). The
|
|
553
|
+
# import is deferred so that naming the upstream costs nothing until doctor asks.
|
|
554
|
+
def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
|
|
555
|
+
from quackd_moss import upstream_api
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
(
|
|
559
|
+
"moss",
|
|
560
|
+
upstream_api,
|
|
561
|
+
"docs/adapters/moss/README.md",
|
|
562
|
+
"a rover (the cockpit backend and quackd's bridge)",
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
UPSTREAMS = _upstream_rows()
|