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.
@@ -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()