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.
- quackd_rosbridge-0.10.0/.gitignore +41 -0
- quackd_rosbridge-0.10.0/PKG-INFO +26 -0
- quackd_rosbridge-0.10.0/README.md +12 -0
- quackd_rosbridge-0.10.0/pyproject.toml +35 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/__init__.py +317 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/introspection.py +312 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/mock.py +233 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/upstream_api.py +413 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/verbs.py +47 -0
- quackd_rosbridge-0.10.0/src/quackd_rosbridge/ws.py +502 -0
|
@@ -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()
|