quackd-rosbridge 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # secrets
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # runs are artifacts, not source (the hero GIF lives in docs/assets on purpose)
7
+ runs/
8
+
9
+ # MuJoCo writes this into the working directory, with no way to redirect it, the first time
10
+ # the physics goes non-finite. `test_a_world_that_mujoco_has_reset_under_us_refuses_to_carry_on`
11
+ # makes that happen on purpose, so every test run produces one.
12
+ MUJOCO_LOG.TXT
13
+
14
+ # python
15
+ __pycache__/
16
+ *.py[cod]
17
+ *.egg-info/
18
+ build/
19
+ dist/
20
+ .venv/
21
+ .venv*/
22
+ .mypy_cache/
23
+ .ruff_cache/
24
+ .pytest_cache/
25
+ .coverage
26
+ htmlcov/
27
+
28
+ # editors / os
29
+ .vscode/
30
+ .idea/
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # upstream assets, never vendored (docs/licenses.md). A real microduck:mujoco run leaves
35
+ # upstream's CC BY-SA-NC model in ~/.quackd/cache, and a debugging copy next to the checkout
36
+ # is one `git add -A` from being permanent in a public history.
37
+ *.onnx
38
+ *.pt
39
+ *.stl
40
+ robot_walk.xml
41
+ *.stackdump
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: quackd-rosbridge
3
+ Version: 0.10.0
4
+ Summary: Any wheeled base that speaks rosbridge, as a quackd robot. Install it as quackd[rosbridge].
5
+ Project-URL: Homepage, https://github.com/rokbenko/quackd
6
+ Project-URL: Documentation, https://github.com/rokbenko/quackd/blob/main/docs/adapters/rosbridge.md
7
+ Author: Rok Benko
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: quackd<0.11,>=0.10
11
+ Provides-Extra: sdk
12
+ Requires-Dist: roslibpy<3,>=2; extra == 'sdk'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # quackd-rosbridge
16
+
17
+ The `rosbridge` robot adapter for [quackd](https://github.com/rokbenko/quackd). Install it through
18
+ quackd rather than directly:
19
+
20
+ ```bash
21
+ uv pip install "quackd[rosbridge]"
22
+ ```
23
+
24
+ Every upstream name it relies on is read from upstream source at a pinned commit and listed in
25
+ `upstream_api.py`. What it does, what it refuses and why:
26
+ [docs/adapters/rosbridge.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/rosbridge.md).
@@ -0,0 +1,12 @@
1
+ # quackd-rosbridge
2
+
3
+ The `rosbridge` robot adapter for [quackd](https://github.com/rokbenko/quackd). Install it through
4
+ quackd rather than directly:
5
+
6
+ ```bash
7
+ uv pip install "quackd[rosbridge]"
8
+ ```
9
+
10
+ Every upstream name it relies on is read from upstream source at a pinned commit and listed in
11
+ `upstream_api.py`. What it does, what it refuses and why:
12
+ [docs/adapters/rosbridge.md](https://github.com/rokbenko/quackd/blob/main/docs/adapters/rosbridge.md).
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quackd-rosbridge"
7
+ dynamic = ["version"]
8
+ description = "Any wheeled base that speaks rosbridge, as a quackd robot. Install it as quackd[rosbridge]."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Rok Benko" }]
13
+ dependencies = ["quackd>=0.10,<0.11"]
14
+
15
+ [project.optional-dependencies]
16
+ sdk = ["roslibpy>=2,<3"]
17
+
18
+ [project.entry-points."quackd.adapters"]
19
+ rosbridge = "quackd_rosbridge"
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/rokbenko/quackd"
23
+ Documentation = "https://github.com/rokbenko/quackd/blob/main/docs/adapters/rosbridge.md"
24
+
25
+ [tool.hatch.version]
26
+ path = "src/quackd_rosbridge/__init__.py"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/quackd_rosbridge"]
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ include = ["src", "README.md", "pyproject.toml"]
33
+
34
+ [tool.uv.sources]
35
+ quackd = { workspace = true }
@@ -0,0 +1,317 @@
1
+ """The rosbridge adapter: any wheeled base that takes a Twist over rosbridge.
2
+
3
+ The first robot in quackd that is not a specific product: a ROS 2 base reachable through
4
+ `rosbridge_server`. Its manifest is small and honest: one intent (`twist`), odometry,
5
+ optionally a compressed image topic, and therefore `move`, `stop`, `report_state`, plus
6
+ `observe`, `go_to`, `search_scan` and `approach_and` only when a camera topic is given.
7
+ No `say`, no `gaze`. Two backends: `mock` (offline kinematics with deadman semantics) and
8
+ `ws` (roslibpy behind `quackd[rosbridge]`, never run against a bridge by us).
9
+
10
+ The name says nothing about the body, so this is the one adapter whose datasheet is not a
11
+ constant: at connect it asks the bridge for the topic list and the robot's own description,
12
+ and a mass and a count of moving joints come back from the URDF. What is not in a URDF, a
13
+ payload above all, stays unknown, and `introspect` asks again (ADR-0032).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import AsyncIterator, Callable, Sequence
19
+ from typing import Any
20
+
21
+ from PIL import Image
22
+
23
+ from quackd.adapters.base import RestResult, one_camera_url, refuse_rest_pose
24
+ from quackd.adapters.manifest import (
25
+ Datasheet,
26
+ Frame,
27
+ Health,
28
+ RobotManifest,
29
+ SafetyAuthority,
30
+ verb_spec,
31
+ )
32
+ from quackd.transport.base import (
33
+ Ack,
34
+ DuckState,
35
+ DuckTransport,
36
+ HeartbeatError,
37
+ Intent,
38
+ TransportError,
39
+ )
40
+ from quackd.verbs.core import CORE
41
+ from quackd.verbs.registry import Precondition, Verb
42
+ from quackd_rosbridge.introspection import (
43
+ Introspection,
44
+ datasheet_from_introspection,
45
+ )
46
+ from quackd_rosbridge.verbs import rosbridge_verbs
47
+
48
+ OWN = rosbridge_verbs()
49
+ """The one verb that is this adapter's own: everything else here is a core verb."""
50
+
51
+
52
+ __version__ = "0.10.0"
53
+ """Kept in step with quackd's own version by scripts/set_version.py. It lives here
54
+ rather than being read from the core, because this file is all an adapter's sdist
55
+ contains."""
56
+
57
+ BACKENDS = ("mock", "ws")
58
+ DEFAULT_ID = "base-01"
59
+ MAX_VX = 0.3
60
+ MAX_WZ = 1.0
61
+ BLURB = (
62
+ "a small wheeled base driven over rosbridge (a ROS 2 robot that takes velocity "
63
+ "commands and reports odometry)"
64
+ )
65
+
66
+ DATASHEET = Datasheet(
67
+ manipulator="none",
68
+ cannot=[
69
+ "carry, push or hold anything through quackd: this adapter commands a velocity and "
70
+ "nothing else",
71
+ ],
72
+ notes=[
73
+ "rosbridge is a software bridge: the name says nothing about the body under it. What is "
74
+ "unknown here is unknown, not zero",
75
+ ],
76
+ )
77
+ _MOVE_DESCRIPTION = (
78
+ "Drive with a velocity for a duration: vx forward m/s, wz rad/s (+ = left). The base's "
79
+ "own driver moves; quackd re-sends the command while the verb runs."
80
+ )
81
+
82
+
83
+ def rosbridge_manifest(
84
+ backend: str,
85
+ robot_id: str | None = None,
86
+ *,
87
+ camera: bool = False,
88
+ max_vx: float = MAX_VX,
89
+ max_wz: float = MAX_WZ,
90
+ cmd_vel: str = "/cmd_vel",
91
+ odom: str = "/odom",
92
+ image: str | None = None,
93
+ roslibpy_version: str | None = None,
94
+ datasheet: Datasheet | None = None,
95
+ ) -> RobotManifest:
96
+ """The base as data. `camera` is whether an image topic is configured, and `datasheet` is
97
+ what the bridge said about the body when it was asked (`introspection.py`); without one,
98
+ the static sheet says nothing is known, which is the honest answer for a name."""
99
+ verbs = [
100
+ verb_spec(CORE["report_state"], core=True),
101
+ verb_spec(CORE["stop"], core=True),
102
+ verb_spec(CORE["move"], core=True, description=_MOVE_DESCRIPTION),
103
+ verb_spec(OWN["introspect"], core=False),
104
+ ]
105
+ if camera:
106
+ verbs = [
107
+ verb_spec(CORE["observe"], core=True),
108
+ *verbs,
109
+ verb_spec(CORE["go_to"], core=True),
110
+ verb_spec(CORE["search_scan"], core=True),
111
+ verb_spec(CORE["approach_and"], core=True),
112
+ ]
113
+ sensors: list[Any] = ["odometry"] + (["camera"] if camera else [])
114
+ return RobotManifest(
115
+ id=robot_id or DEFAULT_ID,
116
+ vendor="ros",
117
+ model="rosbridge-base",
118
+ embodiment="wheeled",
119
+ mobility="wheeled",
120
+ intents=["twist"],
121
+ sensors=sensors,
122
+ verbs=verbs,
123
+ preconditions={},
124
+ # no deadman was verified anywhere: quackd re-sends and zeroes, and says so
125
+ safety_authority=SafetyAuthority(native="none", deadman=False, heartbeat_hz=2.0),
126
+ frame=Frame(reference="base", note="Twist in the base frame; odometry in its odom frame"),
127
+ limits={"max_vx": max_vx, "max_vy": 0.0, "max_wz": max_wz},
128
+ backend=backend,
129
+ blurb=BLURB,
130
+ datasheet=datasheet if datasheet is not None else DATASHEET,
131
+ extras={
132
+ "ros": "2",
133
+ "cmd_vel": cmd_vel,
134
+ "odom": odom,
135
+ "image": image,
136
+ "roslibpy_version": roslibpy_version,
137
+ },
138
+ )
139
+
140
+
141
+ class RosbridgeAdapter:
142
+ """A `RobotAdapter` over the mock or the ws backend."""
143
+
144
+ name = "rosbridge"
145
+
146
+ def __init__(self, transport: DuckTransport, *, robot_id: str | None = None) -> None:
147
+ self.transport = transport
148
+ self.backend = transport.name
149
+ self.robot_id = robot_id or DEFAULT_ID
150
+ self.manifest: RobotManifest | None = None
151
+
152
+ async def connect(self) -> RobotManifest:
153
+ await self.transport.connect()
154
+ endpoint = getattr(self.transport, "endpoint", None)
155
+ self.manifest = rosbridge_manifest(
156
+ self.backend,
157
+ self.robot_id,
158
+ camera=bool(getattr(self.transport, "camera_available", False)),
159
+ cmd_vel=getattr(endpoint, "cmd_vel", "/cmd_vel"),
160
+ odom=getattr(endpoint, "odom", "/odom"),
161
+ image=getattr(endpoint, "image", None),
162
+ roslibpy_version=getattr(self.transport, "roslibpy_version", None),
163
+ datasheet=datasheet_from_introspection(
164
+ getattr(self.transport, "introspection", None), base=DATASHEET
165
+ ),
166
+ )
167
+ return self.manifest
168
+
169
+ async def introspect(self) -> Introspection:
170
+ """Re-read the bridge and refresh the datasheet in place, so a pilot that asks again
171
+ is judging against what the robot says now rather than what it said at connect."""
172
+ reread = getattr(self.transport, "introspect", None)
173
+ if reread is None:
174
+ raise TransportError(f"the {self.backend} backend cannot re-read a description")
175
+ intro: Introspection = await reread()
176
+ if self.manifest is not None:
177
+ self.manifest.datasheet = datasheet_from_introspection(intro, base=DATASHEET)
178
+ return intro
179
+
180
+ async def disconnect(self) -> None:
181
+ await self.transport.close()
182
+
183
+ async def close(self) -> None:
184
+ await self.disconnect()
185
+
186
+ async def get_state(self) -> DuckState:
187
+ return await self.transport.get_state()
188
+
189
+ async def get_frame(self) -> Image.Image | None:
190
+ return await self.transport.get_frame()
191
+
192
+ async def send_intent(self, intent: Intent) -> Ack:
193
+ return await self.transport.send_intent(intent)
194
+
195
+ async def health(self) -> Health:
196
+ try:
197
+ await self.transport.heartbeat()
198
+ except HeartbeatError as e:
199
+ return Health(ok=False, reason=str(e))
200
+ state = await self.transport.get_state()
201
+ return Health(ok=True, battery_percent=None, extras={"odom": state.extras.get("odom")})
202
+
203
+ async def heartbeat(self) -> None:
204
+ await self.transport.heartbeat()
205
+
206
+ async def stop(self) -> None:
207
+ await self.transport.stop()
208
+
209
+ async def go_to_rest(self) -> RestResult:
210
+ """A wheeled base holds no pose: this adapter commands a velocity, so zeroing it in
211
+ `stop()` is the whole of coming to rest and there is nothing further to drive to."""
212
+ return RestResult.none()
213
+
214
+ def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
215
+ return self.transport.subscribe(topic)
216
+
217
+ def now(self) -> float:
218
+ return self.transport.now()
219
+
220
+ async def sleep(self, seconds: float) -> None:
221
+ await self.transport.sleep(seconds)
222
+
223
+ def preconditions(self) -> dict[str, Precondition]:
224
+ return {}
225
+
226
+ def implementations(self) -> dict[str, Verb]:
227
+ return dict(OWN) # one verb of its own: asking the bridge what the body is
228
+
229
+ @property
230
+ def mobility(self) -> str:
231
+ return "wheeled"
232
+
233
+ @property
234
+ def post_sleep(self) -> Callable[[], None] | None:
235
+ return getattr(self.transport, "post_sleep", None)
236
+
237
+ @post_sleep.setter
238
+ def post_sleep(self, hook: Callable[[], None] | None) -> None:
239
+ self.transport.post_sleep = hook # type: ignore[attr-defined]
240
+
241
+
242
+ # ── what the factory calls ──────────────────────────────────────────────────────────────
243
+
244
+
245
+ def describe(backend: str, robot_id: str | None = None) -> RobotManifest:
246
+ """Static: the mock serves a frame and a canned description, so what it says here is what
247
+ a connected run reads. The ws backend knows nothing until it has asked a bridge: whether
248
+ there is a camera, and what the body is."""
249
+ if backend == "mock":
250
+ from quackd_rosbridge.mock import mock_introspection
251
+
252
+ return rosbridge_manifest(
253
+ backend,
254
+ robot_id,
255
+ camera=True,
256
+ datasheet=datasheet_from_introspection(mock_introspection(), base=DATASHEET),
257
+ )
258
+ return rosbridge_manifest(backend, robot_id, camera=False)
259
+
260
+
261
+ def implementations() -> dict[str, Verb]:
262
+ return dict(OWN)
263
+
264
+
265
+ def conditions() -> dict[str, Precondition]:
266
+ return {}
267
+
268
+
269
+ def make(
270
+ backend: str,
271
+ *,
272
+ robot_id: str | None = None,
273
+ seed: int | None = None,
274
+ address: str | None = None,
275
+ live: bool = False,
276
+ camera_url: str | Sequence[str] | None = None,
277
+ token: str | None = None,
278
+ rest_pose: dict[str, float] | None = None,
279
+ ) -> RosbridgeAdapter:
280
+ refuse_rest_pose("rosbridge", rest_pose)
281
+ # The image topic arrives in --address, so the url itself has nowhere to go here. It is
282
+ # still collapsed, because a second camera is a body this adapter cannot drive and saying
283
+ # so beats opening the first and dropping the rest without a word.
284
+ _url = one_camera_url(camera_url, spec=f"rosbridge:{backend}")
285
+ if backend == "mock":
286
+ from quackd_rosbridge.mock import RosbridgeMock
287
+
288
+ return RosbridgeAdapter(RosbridgeMock(), robot_id=robot_id)
289
+ if backend == "ws":
290
+ from quackd_rosbridge.ws import RosbridgeWs
291
+
292
+ return RosbridgeAdapter(RosbridgeWs(address=address), robot_id=robot_id)
293
+ raise ValueError(f"unknown rosbridge backend {backend!r}; choose one of {BACKENDS}")
294
+
295
+
296
+ __all__ = [
297
+ "BACKENDS",
298
+ "DEFAULT_ID",
299
+ "RosbridgeAdapter",
300
+ "conditions",
301
+ "describe",
302
+ "implementations",
303
+ "make",
304
+ "rosbridge_manifest",
305
+ ]
306
+
307
+
308
+ # What this adapter reads from upstream, for `quackd doctor`. Declared here rather than in a
309
+ # table in the core, because the list belongs to whoever wrote the adapter (ADR-0022). The
310
+ # import is deferred so that naming the upstream costs nothing until doctor asks.
311
+ def _upstream_rows() -> tuple[tuple[str, object, str, str], ...]:
312
+ from quackd_rosbridge import upstream_api
313
+
314
+ return (("rosbridge", upstream_api, "docs/adapters/rosbridge.md", "a bridge (the ws backend)"),)
315
+
316
+
317
+ UPSTREAMS = _upstream_rows()