dsh-ros2-common 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/lib/index.js +8 -0
- package/lib/parse.js +110 -0
- package/lib/runner.js +168 -0
- package/lib/toolkit.js +212 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/parse.d.ts +45 -0
- package/lib/types/runner.d.ts +53 -0
- package/lib/types/toolkit.d.ts +225 -0
- package/package.json +51 -0
- package/scripts/robot_profile.py +773 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""robot_profile.py — register and load robot body profiles (generic).
|
|
3
|
+
|
|
4
|
+
Collects a new robot's body information into a structured YAML profile
|
|
5
|
+
(register) and reads it back as JSON (load / list) so skills can quickly
|
|
6
|
+
bring up renders/diagnostics for a known robot without re-discovery.
|
|
7
|
+
|
|
8
|
+
Profile location: ~/.dsh-ros2/robots/<name>.yaml (--dir overridable).
|
|
9
|
+
|
|
10
|
+
register collects:
|
|
11
|
+
- URDF: explicit --urdf path, or extracted from the live /robot_description
|
|
12
|
+
- links / joints (parsed from the URDF)
|
|
13
|
+
- image topics (camera list from the graph)
|
|
14
|
+
- MoveIt SRDF: explicit --srdf, or resolved via the package scan
|
|
15
|
+
- zero-pose semantics: read from ~/.dsh-ros2/zero-pose.yaml if calibrated
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 robot_profile.py register --name <robot> [--urdf <path>] [--srdf <path>] [--description "..."]
|
|
19
|
+
python3 robot_profile.py load --name <robot>
|
|
20
|
+
python3 robot_profile.py list
|
|
21
|
+
Output: JSON
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import glob
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
|
|
32
|
+
DEFAULT_DIR = os.path.expanduser("~/.dsh-ros2/robots")
|
|
33
|
+
ZERO_POSE = os.path.expanduser("~/.dsh-ros2/zero-pose.yaml")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ros2(*args, timeout=20):
|
|
37
|
+
try:
|
|
38
|
+
p = subprocess.run(["ros2", *args], capture_output=True, text=True, timeout=timeout)
|
|
39
|
+
return p.returncode == 0, p.stdout, p.stderr
|
|
40
|
+
except Exception: # noqa: BLE001
|
|
41
|
+
return False, "", ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_live_urdf():
|
|
45
|
+
"""Extract the URDF from the live /robot_description topic."""
|
|
46
|
+
import rclpy
|
|
47
|
+
from rclpy.node import Node
|
|
48
|
+
from std_msgs.msg import String
|
|
49
|
+
from rclpy.qos import QoSProfile, DurabilityPolicy
|
|
50
|
+
rclpy.init()
|
|
51
|
+
n = Node("robot_profile_fetch")
|
|
52
|
+
q = QoSProfile(depth=1, durability=DurabilityPolicy.TRANSIENT_LOCAL, reliability=1)
|
|
53
|
+
got = [None]
|
|
54
|
+
|
|
55
|
+
def cb(msg):
|
|
56
|
+
got[0] = msg.data
|
|
57
|
+
|
|
58
|
+
n.create_subscription(String, "/robot_description", cb, q)
|
|
59
|
+
for _ in range(50):
|
|
60
|
+
rclpy.spin_once(n, timeout_sec=0.1)
|
|
61
|
+
if got[0] is not None:
|
|
62
|
+
break
|
|
63
|
+
n.destroy_node()
|
|
64
|
+
rclpy.shutdown()
|
|
65
|
+
return got[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_urdf(urdf_xml: str) -> dict:
|
|
69
|
+
root = ET.fromstring(urdf_xml)
|
|
70
|
+
links = [l.get("name") for l in root.findall("link") if l.get("name")]
|
|
71
|
+
joints = []
|
|
72
|
+
for j in root.findall("joint"):
|
|
73
|
+
name = j.get("name")
|
|
74
|
+
if not name:
|
|
75
|
+
continue
|
|
76
|
+
entry = {
|
|
77
|
+
"name": name, "type": j.get("type", ""),
|
|
78
|
+
"parent": (j.find("parent").get("link") if j.find("parent") is not None else ""),
|
|
79
|
+
"child": (j.find("child").get("link") if j.find("child") is not None else ""),
|
|
80
|
+
}
|
|
81
|
+
# per-joint limits from the URDF <limit> element — the source of
|
|
82
|
+
# truth for motion_validator (position/velocity/effort bounds)
|
|
83
|
+
lim = j.find("limit")
|
|
84
|
+
if lim is not None:
|
|
85
|
+
def _num(key):
|
|
86
|
+
try:
|
|
87
|
+
v = float(lim.get(key))
|
|
88
|
+
return v
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
return None
|
|
91
|
+
entry["limits"] = {
|
|
92
|
+
"lower": _num("lower"), "upper": _num("upper"),
|
|
93
|
+
"velocity": _num("velocity"), "effort": _num("effort"),
|
|
94
|
+
"continuous": j.get("type") == "continuous",
|
|
95
|
+
}
|
|
96
|
+
joints.append(entry)
|
|
97
|
+
return {"links": links, "joints": joints}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_urdf_limits(urdf_xml: str) -> dict:
|
|
101
|
+
"""Per-joint velocity/effort limits from the URDF (seed values for the
|
|
102
|
+
`safety` section; downstream may recalibrate)."""
|
|
103
|
+
root = ET.fromstring(urdf_xml)
|
|
104
|
+
limits = {"max_velocity": {}, "max_effort": {}}
|
|
105
|
+
for j in root.findall("joint"):
|
|
106
|
+
name = j.get("name")
|
|
107
|
+
lim = j.find("limit")
|
|
108
|
+
if not name or lim is None:
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
vel = float(lim.get("velocity"))
|
|
112
|
+
if vel > 0:
|
|
113
|
+
limits["max_velocity"][name] = vel
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
eff = float(lim.get("effort"))
|
|
118
|
+
if eff > 0:
|
|
119
|
+
limits["max_effort"][name] = eff
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
pass
|
|
122
|
+
return limits
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def default_safety(limits: dict) -> dict:
|
|
126
|
+
"""Generic minimal `safety` section written at registration. All values
|
|
127
|
+
are overridable afterwards via `safety set` (L2 approval at tool layer).
|
|
128
|
+
See docs/safety-handover.md §4.1 for the full schema."""
|
|
129
|
+
return {
|
|
130
|
+
"enabled": True,
|
|
131
|
+
"control_frequency": 200,
|
|
132
|
+
"checkers": ["motion", "feedback_loss", "watchdog"],
|
|
133
|
+
"lock_action": "zero_velocity", # minimal; robots may register damping/compliant
|
|
134
|
+
"lock_topic": "/safety/lock_active",
|
|
135
|
+
"feedback": {
|
|
136
|
+
"joint_state_topic": "/joint_states",
|
|
137
|
+
"torque_topic": "", # empty = torque disabled until configured
|
|
138
|
+
"timeout_ms": 100,
|
|
139
|
+
},
|
|
140
|
+
"motion": {
|
|
141
|
+
"command_topic": "", # empty = tracking/stall disabled (no command stream)
|
|
142
|
+
"tracking_error_rad": 0.05,
|
|
143
|
+
"stall": {"window_ms": 200, "min_cmd_vel": 0.02, "max_actual_vel": 0.005},
|
|
144
|
+
"hysteresis": {"min_frames": 3, "window": 5},
|
|
145
|
+
"max_velocity": limits.get("max_velocity", {}),
|
|
146
|
+
"max_acceleration": {},
|
|
147
|
+
},
|
|
148
|
+
"torque": {
|
|
149
|
+
"enabled": True,
|
|
150
|
+
"abs_limit": limits.get("max_effort", {}),
|
|
151
|
+
"dtau_limit": {},
|
|
152
|
+
"overload_ms": 500,
|
|
153
|
+
"feedforward_topic": "", # 预留:计算力矩前馈(下游接入)
|
|
154
|
+
},
|
|
155
|
+
"watchdog": {
|
|
156
|
+
"critical_topics": [], # 例: [{"topic": "/controller/status", "timeout_ms": 1000}]
|
|
157
|
+
"observed_topics": [], # 非关键:掉线仅 WARNING,不锁
|
|
158
|
+
"critical_nodes": [], # 例: ["controller_manager"]
|
|
159
|
+
"observed_nodes": [],
|
|
160
|
+
"node_scan_sec": 5.0,
|
|
161
|
+
},
|
|
162
|
+
"semantic": {
|
|
163
|
+
"enabled": True,
|
|
164
|
+
"trigger_on": ["plan_change", "tracking_error", "stall", "feedback_loss",
|
|
165
|
+
"watchdog_critical", "torque_spike", "torque_overload"],
|
|
166
|
+
},
|
|
167
|
+
"forensics": {
|
|
168
|
+
"ring_buffer_s": 5,
|
|
169
|
+
"dump_dir": "~/.dsh-ros2/safety-events",
|
|
170
|
+
},
|
|
171
|
+
# pre-execution motion validation (motion_validator, see safety-todo.md)
|
|
172
|
+
"max_state_age_ms": 500,
|
|
173
|
+
"validation_ttl_ms": 2000,
|
|
174
|
+
"workspace": {}, # 可选策略边界: {x:[lo,hi], y:[...], z:[...]}(pose 目标)
|
|
175
|
+
"execution": {"max_duration_ms": 30000},
|
|
176
|
+
"require_controller_ready": True,
|
|
177
|
+
"require_post_execution_verification": True,
|
|
178
|
+
"require_limits": False, # 未注册/无限位时:False=警告跳过;True=fail-closed
|
|
179
|
+
"estop": {"enabled": False, "path": ""}, # 仅接口,不实现(后续定义)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
KNOWN_LOCK_ACTIONS = {"zero_velocity", "damping"}
|
|
184
|
+
KNOWN_CAUSES = {"plan_change", "tracking_error", "stall", "feedback_loss",
|
|
185
|
+
"watchdog_critical", "watchdog_observed", "torque_spike",
|
|
186
|
+
"torque_overload", "semantic_unsafe"}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_safety(cfg) -> list:
|
|
190
|
+
"""Schema sanity check; returns a list of human-readable problems
|
|
191
|
+
(empty = OK). Does not enforce policy — just shape."""
|
|
192
|
+
problems = []
|
|
193
|
+
if not isinstance(cfg, dict):
|
|
194
|
+
return ["safety 段必须是对象"]
|
|
195
|
+
if cfg.get("lock_action") not in KNOWN_LOCK_ACTIONS:
|
|
196
|
+
problems.append("lock_action 应为 zero_velocity|damping,实际 {}".format(cfg.get("lock_action")))
|
|
197
|
+
try:
|
|
198
|
+
float(cfg.get("control_frequency", 200))
|
|
199
|
+
except (TypeError, ValueError):
|
|
200
|
+
problems.append("control_frequency 必须是数字")
|
|
201
|
+
fb = cfg.get("feedback") or {}
|
|
202
|
+
if not isinstance(fb.get("joint_state_topic", ""), str) or not fb.get("joint_state_topic"):
|
|
203
|
+
problems.append("feedback.joint_state_topic 必填")
|
|
204
|
+
for cause in cfg.get("semantic", {}).get("trigger_on", []):
|
|
205
|
+
if cause not in KNOWN_CAUSES:
|
|
206
|
+
problems.append("未知触发原因: {}".format(cause))
|
|
207
|
+
for key in ("critical_topics", "observed_topics"):
|
|
208
|
+
for e in cfg.get("watchdog", {}).get(key, []):
|
|
209
|
+
if not e.get("topic"):
|
|
210
|
+
problems.append("watchdog.{} 条目缺少 topic".format(key))
|
|
211
|
+
# pre-execution validation fields (motion_validator)
|
|
212
|
+
for num_key in ("max_state_age_ms", "validation_ttl_ms"):
|
|
213
|
+
try:
|
|
214
|
+
float(cfg.get(num_key, 0))
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
problems.append("{} 必须是数字".format(num_key))
|
|
217
|
+
try:
|
|
218
|
+
float((cfg.get("execution") or {}).get("max_duration_ms", 0))
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
problems.append("execution.max_duration_ms 必须是数字")
|
|
221
|
+
ws = cfg.get("workspace") or {}
|
|
222
|
+
if ws and not isinstance(ws, dict):
|
|
223
|
+
problems.append("workspace 必须是 {x:[lo,hi], y:[...], z:[...]}")
|
|
224
|
+
for axis, bounds in (ws or {}).items():
|
|
225
|
+
if not (isinstance(bounds, (list, tuple)) and len(bounds) == 2):
|
|
226
|
+
problems.append("workspace.{} 需为 [lo, hi]".format(axis))
|
|
227
|
+
return problems
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read_profile_yaml(name: str):
|
|
231
|
+
"""Read a profile file and return (raw dict, path) or raise."""
|
|
232
|
+
path = os.path.join(DEFAULT_DIR, f"{name}.yaml")
|
|
233
|
+
if not os.path.exists(path):
|
|
234
|
+
raise FileNotFoundError(f"未找到机器人档案 {name}")
|
|
235
|
+
import yaml as pyyaml
|
|
236
|
+
with open(path) as f:
|
|
237
|
+
data = pyyaml.safe_load(f) or {}
|
|
238
|
+
return data, path
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def safety_show(name: str) -> dict:
|
|
242
|
+
data, path = read_profile_yaml(name)
|
|
243
|
+
robot = data.get("robot", {})
|
|
244
|
+
safety = robot.get("safety", {})
|
|
245
|
+
problems = validate_safety(safety) if safety else ["safety 段缺失(可用 register 或 safety set 补齐)"]
|
|
246
|
+
return {"ok": True, "robot": name, "safety": safety, "problems": problems,
|
|
247
|
+
"profile_path": path}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def safety_set(name: str, key: str, value_json: str) -> dict:
|
|
251
|
+
"""Set one dotted safety key, e.g. key=feedback.torque_topic value='"..."'
|
|
252
|
+
or key=watchdog.critical_nodes value='[{"topic": "/x", "timeout_ms": 1000}]'.
|
|
253
|
+
Validates the result before writing (same merge pattern as topo_learn)."""
|
|
254
|
+
import yaml as pyyaml
|
|
255
|
+
data, path = read_profile_yaml(name)
|
|
256
|
+
robot = data.setdefault("robot", {})
|
|
257
|
+
safety = robot.setdefault("safety", default_safety({"max_velocity": {}, "max_effort": {}}))
|
|
258
|
+
try:
|
|
259
|
+
value = json.loads(value_json)
|
|
260
|
+
except json.JSONDecodeError as e:
|
|
261
|
+
return {"ok": False, "error": f"value 必须是合法 JSON: {e}"}
|
|
262
|
+
parts = key.split(".")
|
|
263
|
+
node = safety
|
|
264
|
+
for p in parts[:-1]:
|
|
265
|
+
node = node.setdefault(p, {})
|
|
266
|
+
node[parts[-1]] = value
|
|
267
|
+
problems = validate_safety(safety)
|
|
268
|
+
if problems:
|
|
269
|
+
return {"ok": False, "error": "safety 校验失败: " + "; ".join(problems)}
|
|
270
|
+
with open(path, "w") as f:
|
|
271
|
+
f.write(f"# robot body profile (written by dsh-ros2 robot_profile)\n")
|
|
272
|
+
pyyaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
|
|
273
|
+
return {"ok": True, "robot": name, "key": key, "value": value,
|
|
274
|
+
"problems": validate_safety(safety), "profile_path": path}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def find_tf_root():
|
|
278
|
+
"""Best-effort TF root from tf_static sample (child of the first edge)."""
|
|
279
|
+
ok, out, _ = ros2("topic", "echo", "/tf_static", "--once", "--field", "transforms")
|
|
280
|
+
if ok and out.strip():
|
|
281
|
+
for line in out.splitlines():
|
|
282
|
+
line = line.strip()
|
|
283
|
+
if "child_frame_id" in line:
|
|
284
|
+
return line.split(":", 1)[1].strip().strip("'\"")
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def list_image_topics():
|
|
289
|
+
ok, out, _ = ros2("topic", "list", "-t")
|
|
290
|
+
if not ok:
|
|
291
|
+
return []
|
|
292
|
+
topics = []
|
|
293
|
+
for line in out.splitlines():
|
|
294
|
+
if "sensor_msgs/msg/Image" in line or "sensor_msgs/msg/CompressedImage" in line:
|
|
295
|
+
topics.append(line.split()[0])
|
|
296
|
+
return sorted(topics)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def resolve_srdf(srdf: str):
|
|
300
|
+
"""Resolve an SRDF by path or package scan (reuse of moveit_discover logic)."""
|
|
301
|
+
if srdf:
|
|
302
|
+
return srdf
|
|
303
|
+
ok, out, _ = ros2("pkg", "list")
|
|
304
|
+
if not ok:
|
|
305
|
+
return ""
|
|
306
|
+
for pkg in sorted(out.split()):
|
|
307
|
+
ok2, prefix, _ = ros2("pkg", "prefix", pkg)
|
|
308
|
+
if not ok2:
|
|
309
|
+
continue
|
|
310
|
+
share = os.path.join(prefix.strip(), "share", pkg)
|
|
311
|
+
cands = sorted(glob.glob(os.path.join(share, "config", "*.srdf")))
|
|
312
|
+
if cands:
|
|
313
|
+
return cands[0]
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def parse_srdf_groups(srdf_path: str) -> dict:
|
|
318
|
+
root = ET.parse(srdf_path).getroot()
|
|
319
|
+
groups = {}
|
|
320
|
+
for g in root.findall("group"):
|
|
321
|
+
name = g.get("name")
|
|
322
|
+
if not name:
|
|
323
|
+
continue
|
|
324
|
+
chain = g.find("chain")
|
|
325
|
+
groups[name] = {
|
|
326
|
+
"type": g.get("type", ""),
|
|
327
|
+
"joints": [j.get("name") for j in g.findall("joint") if j.get("name")],
|
|
328
|
+
"chain_tip": chain.get("tip_link", "") if chain is not None else "",
|
|
329
|
+
}
|
|
330
|
+
return groups
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def read_zero_pose() -> dict:
|
|
334
|
+
try:
|
|
335
|
+
import yaml as pyyaml
|
|
336
|
+
with open(ZERO_POSE) as f:
|
|
337
|
+
data = pyyaml.safe_load(f) or {}
|
|
338
|
+
return data.get("zero_pose_semantics", {})
|
|
339
|
+
except Exception: # noqa: BLE001
|
|
340
|
+
return {}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── topology: 聚合层快照 + 渐进式重要节点学习(严格结构化)───────────────
|
|
344
|
+
# Trade-off: 不全量深挖(机器人复杂后冗杂),也不一无所知——snapshot 记录聚合层
|
|
345
|
+
# (节点/话题/服务清单),learn 使用中逐步记录"重要节点"的功能与连接(固定 schema)。
|
|
346
|
+
|
|
347
|
+
TOPO_SCHEMA_NODE = ["name", "role", "description", "pub", "sub", "srv", "act", "learned_at"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _profile_path(name):
|
|
351
|
+
return os.path.join(DEFAULT_DIR, f"{name}.yaml")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_profile(name):
|
|
355
|
+
import yaml as pyyaml
|
|
356
|
+
path = _profile_path(name)
|
|
357
|
+
if not os.path.exists(path):
|
|
358
|
+
return None, None
|
|
359
|
+
with open(path) as f:
|
|
360
|
+
data = pyyaml.safe_load(f) or {}
|
|
361
|
+
return data, path
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def topo_snapshot(name: str) -> dict:
|
|
365
|
+
"""聚合层快照:节点/话题/服务清单(轻量,不逐节点深挖)。"""
|
|
366
|
+
data, path = _read_profile(name)
|
|
367
|
+
if data is None:
|
|
368
|
+
return {"ok": False, "error": f"未找到档案 {name}(先 register)"}
|
|
369
|
+
nodes = []
|
|
370
|
+
ok, out, _ = ros2("node", "list")
|
|
371
|
+
if ok:
|
|
372
|
+
nodes = [l.strip() for l in out.splitlines() if l.strip()]
|
|
373
|
+
topics = []
|
|
374
|
+
ok2, out2, _ = ros2("topic", "list", "-t")
|
|
375
|
+
if ok2:
|
|
376
|
+
topics = sorted({l.split()[0] for l in out2.splitlines() if l.strip()})
|
|
377
|
+
services = []
|
|
378
|
+
ok3, out3, _ = ros2("service", "list")
|
|
379
|
+
if ok3:
|
|
380
|
+
services = sorted({l.strip() for l in out3.splitlines() if l.strip()})
|
|
381
|
+
snapshot = {
|
|
382
|
+
"nodes": nodes,
|
|
383
|
+
"topics": topics,
|
|
384
|
+
"services": services,
|
|
385
|
+
"snapshot_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
386
|
+
}
|
|
387
|
+
robot = data.setdefault("robot", {})
|
|
388
|
+
robot.setdefault("topology", {})["snapshot"] = snapshot
|
|
389
|
+
# 保留已学习节点
|
|
390
|
+
robot["topology"].setdefault("nodes", {})
|
|
391
|
+
with open(path, "w") as f:
|
|
392
|
+
f.write(f"# robot body profile (written by dsh-ros2 robot_profile)\n")
|
|
393
|
+
import yaml as pyyaml
|
|
394
|
+
pyyaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
|
|
395
|
+
return {"ok": True, "snapshot": snapshot, "learned_nodes": len(robot["topology"]["nodes"])}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def topo_learn(name: str, node: str, role: str, description: str,
|
|
399
|
+
pub: str = "", sub: str = "", srv: str = "", act: str = "") -> dict:
|
|
400
|
+
"""记录/更新一个重要节点的功能与拓扑连接(严格 schema,幂等合并)。"""
|
|
401
|
+
data, path = _read_profile(name)
|
|
402
|
+
if data is None:
|
|
403
|
+
return {"ok": False, "error": f"未找到档案 {name}(先 register)"}
|
|
404
|
+
robot = data.setdefault("robot", {})
|
|
405
|
+
topo = robot.setdefault("topology", {})
|
|
406
|
+
nodes = topo.setdefault("nodes", {})
|
|
407
|
+
entry = {
|
|
408
|
+
"name": node,
|
|
409
|
+
"role": role,
|
|
410
|
+
"description": description,
|
|
411
|
+
"pub": [t for t in pub.split(",") if t.strip()],
|
|
412
|
+
"sub": [t for t in sub.split(",") if t.strip()],
|
|
413
|
+
"srv": [t for t in srv.split(",") if t.strip()],
|
|
414
|
+
"act": [t for t in act.split(",") if t.strip()],
|
|
415
|
+
"learned_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
416
|
+
}
|
|
417
|
+
nodes[node] = entry
|
|
418
|
+
with open(path, "w") as f:
|
|
419
|
+
f.write(f"# robot body profile (written by dsh-ros2 robot_profile)\n")
|
|
420
|
+
import yaml as pyyaml
|
|
421
|
+
pyyaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
|
|
422
|
+
return {"ok": True, "node": entry, "learned_nodes": len(nodes)}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def topo_show(name: str) -> dict:
|
|
426
|
+
"""输出档案拓扑:已学习节点(含功能)+ 最近聚合快照概要。"""
|
|
427
|
+
data, path = _read_profile(name)
|
|
428
|
+
if data is None:
|
|
429
|
+
return {"ok": False, "error": f"未找到档案 {name}(先 register)"}
|
|
430
|
+
topo = data.get("robot", {}).get("topology", {})
|
|
431
|
+
snapshot = topo.get("snapshot", {})
|
|
432
|
+
return {
|
|
433
|
+
"ok": True,
|
|
434
|
+
"learned_nodes": topo.get("nodes", {}),
|
|
435
|
+
"snapshot_summary": {
|
|
436
|
+
"nodes": len(snapshot.get("nodes", [])),
|
|
437
|
+
"topics": len(snapshot.get("topics", [])),
|
|
438
|
+
"services": len(snapshot.get("services", [])),
|
|
439
|
+
"snapshot_at": snapshot.get("snapshot_at", ""),
|
|
440
|
+
},
|
|
441
|
+
"profile_path": path,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _norm_node(name: str) -> str:
|
|
446
|
+
"""节点名归一化:去空白与前导 /。"""
|
|
447
|
+
return str(name).strip().lstrip("/")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _live_node_info(node: str, timeout: int = 10):
|
|
451
|
+
"""Best-effort `ros2 node info` parse: {pub, sub, srv, act} topic lists.
|
|
452
|
+
Handles the modern output (Publishers/Subscribers/Service Servers/Action
|
|
453
|
+
Servers + `name: type` entries; client-side sections are ignored)."""
|
|
454
|
+
ok, out, _ = ros2("node", "info", node, timeout=timeout)
|
|
455
|
+
if not ok:
|
|
456
|
+
return None
|
|
457
|
+
result = {"pub": [], "sub": [], "srv": [], "act": []}
|
|
458
|
+
section = None
|
|
459
|
+
for line in out.splitlines():
|
|
460
|
+
stripped = line.strip()
|
|
461
|
+
if stripped.startswith("Publishers:"):
|
|
462
|
+
section = "pub"
|
|
463
|
+
continue
|
|
464
|
+
if stripped.startswith("Subscribers:"):
|
|
465
|
+
section = "sub"
|
|
466
|
+
continue
|
|
467
|
+
if stripped.startswith("Service Servers:"):
|
|
468
|
+
section = "srv"
|
|
469
|
+
continue
|
|
470
|
+
if stripped.startswith("Action Servers:"):
|
|
471
|
+
section = "act"
|
|
472
|
+
continue
|
|
473
|
+
if stripped.startswith("Service Clients:") or stripped.startswith("Action Clients:"):
|
|
474
|
+
section = None
|
|
475
|
+
continue
|
|
476
|
+
if section and stripped:
|
|
477
|
+
token = stripped.split(":")[0].strip().lstrip("*").strip()
|
|
478
|
+
if token:
|
|
479
|
+
result[section].append(token)
|
|
480
|
+
return result
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def topo_diagnose(name: str) -> dict:
|
|
485
|
+
"""知识增强诊断(只读,不改档案)。
|
|
486
|
+
|
|
487
|
+
载入档案知识库(已学节点 + 聚合快照),与实时 ROS2 图交叉比对:
|
|
488
|
+
- missing:已学但当前不在线的节点(控制器/发布者掉线?)——诊断重点;
|
|
489
|
+
- new:当前在线但未学习/未快照的节点(提示可 robot_topology learn);
|
|
490
|
+
- matched + drift:已学且在线节点的期望 pub/sub/srv/act vs 实时连接差异;
|
|
491
|
+
- topic_drift:聚合快照话题 vs 实时话题差异。
|
|
492
|
+
让"使用中渐进学习"的知识库真正参与诊断,而非只存不读。"""
|
|
493
|
+
data, path = _read_profile(name)
|
|
494
|
+
if data is None:
|
|
495
|
+
return {"ok": False, "error": f"未找到档案 {name}(先 register)"}
|
|
496
|
+
topo = data.get("robot", {}).get("topology", {})
|
|
497
|
+
learned = topo.get("nodes", {}) or {}
|
|
498
|
+
snapshot = topo.get("snapshot", {}) or {}
|
|
499
|
+
|
|
500
|
+
ok, out, _ = ros2("node", "list")
|
|
501
|
+
live_nodes = [_norm_node(l) for l in out.splitlines() if l.strip()] if ok else []
|
|
502
|
+
live_set = set(live_nodes)
|
|
503
|
+
known_set = set(_norm_node(n) for n in learned) | set(_norm_node(n) for n in snapshot.get("nodes", []))
|
|
504
|
+
|
|
505
|
+
missing = [
|
|
506
|
+
{"name": n, "role": e.get("role", ""), "description": e.get("description", ""),
|
|
507
|
+
"learned_at": e.get("learned_at", "")}
|
|
508
|
+
for n, e in learned.items() if _norm_node(n) not in live_set
|
|
509
|
+
]
|
|
510
|
+
new_nodes = [n for n in live_nodes if n not in known_set]
|
|
511
|
+
|
|
512
|
+
matched, drift_count = [], 0
|
|
513
|
+
for n, e in learned.items():
|
|
514
|
+
norm = _norm_node(n)
|
|
515
|
+
if norm not in live_set:
|
|
516
|
+
continue
|
|
517
|
+
# ros2 node info requires the leading '/' (e.g. /tt_talker)
|
|
518
|
+
info = _live_node_info("/" + norm)
|
|
519
|
+
if info is None:
|
|
520
|
+
matched.append({"name": n, "role": e.get("role", ""), "description": e.get("description", ""),
|
|
521
|
+
"live": None, "drift": {"error": "node info 不可用"}})
|
|
522
|
+
continue
|
|
523
|
+
drift = {}
|
|
524
|
+
for kind in ("pub", "sub", "srv", "act"):
|
|
525
|
+
expected = set(e.get(kind, []) or [])
|
|
526
|
+
actual = set(info[kind])
|
|
527
|
+
d_missing, d_new = sorted(expected - actual), sorted(actual - expected)
|
|
528
|
+
if d_missing or d_new:
|
|
529
|
+
drift[kind] = {"missing": d_missing, "new": d_new}
|
|
530
|
+
drift_count += 1
|
|
531
|
+
matched.append({"name": n, "role": e.get("role", ""), "description": e.get("description", ""),
|
|
532
|
+
"live": info, "drift": drift})
|
|
533
|
+
|
|
534
|
+
snap_topics = set(snapshot.get("topics", []) or [])
|
|
535
|
+
topic_drift = {}
|
|
536
|
+
ok_t, out_t, _ = ros2("topic", "list", "-t")
|
|
537
|
+
if ok_t:
|
|
538
|
+
live_topics = sorted({l.split()[0] for l in out_t.splitlines() if l.strip()})
|
|
539
|
+
live_topic_set = set(live_topics)
|
|
540
|
+
if snap_topics:
|
|
541
|
+
topic_drift = {"missing": sorted(snap_topics - live_topic_set),
|
|
542
|
+
"new": sorted(live_topic_set - snap_topics)}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
"ok": True,
|
|
546
|
+
"knowledge": {
|
|
547
|
+
"learned_nodes": {n: {k: e.get(k) for k in ("role", "description")} for n, e in learned.items()},
|
|
548
|
+
"learned_count": len(learned),
|
|
549
|
+
"snapshot_summary": {"nodes": len(snapshot.get("nodes", []) or []),
|
|
550
|
+
"topics": len(snap_topics),
|
|
551
|
+
"snapshot_at": snapshot.get("snapshot_at", "")},
|
|
552
|
+
},
|
|
553
|
+
"live": {"nodes": live_nodes, "count": len(live_nodes)},
|
|
554
|
+
"missing": missing,
|
|
555
|
+
"new": new_nodes,
|
|
556
|
+
"matched": matched,
|
|
557
|
+
"topic_drift": topic_drift,
|
|
558
|
+
"summary": {"learned": len(learned), "live": len(live_nodes),
|
|
559
|
+
"missing": len(missing), "new": len(new_nodes), "drift": drift_count},
|
|
560
|
+
"profile_path": path,
|
|
561
|
+
"hint": "诊断顺序:先看 missing(已学节点掉线)→ new(新节点,可 learn 记录角色)→ drift(期望 vs 实际连接变化)→ topic_drift(聚合层话题变化)。",
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
SEARCH_FIELDS = ("name", "role", "description", "pub", "sub", "srv", "act")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def topo_search(name: str, query: str = "", field: str = "all", topic: str = "") -> dict:
|
|
569
|
+
"""知识库检索(只读,供 Agent 高效取参考信息)。
|
|
570
|
+
|
|
571
|
+
两种检索方式:
|
|
572
|
+
- 按 topic 反查:哪些已学节点连接了该话题(pub/sub/srv/act 任一包含),
|
|
573
|
+
附带角色/描述——"谁在发 /joint_states?";
|
|
574
|
+
- 按关键字匹配:query 命中 name/role/description/pub/sub/srv/act
|
|
575
|
+
(field 限定单个字段,默认 all)。
|
|
576
|
+
大小写不敏感子串匹配;返回结构化命中,供 debug 参考。"""
|
|
577
|
+
data, path = _read_profile(name)
|
|
578
|
+
if data is None:
|
|
579
|
+
return {"ok": False, "error": f"未找到档案 {name}(先 register)"}
|
|
580
|
+
learned = data.get("robot", {}).get("topology", {}).get("nodes", {}) or {}
|
|
581
|
+
if not learned:
|
|
582
|
+
return {"ok": True, "query": query, "field": field, "topic": topic,
|
|
583
|
+
"matches": [], "count": 0,
|
|
584
|
+
"note": "知识库为空——先用 robot_topology learn/snapshot 记录节点", "profile_path": path}
|
|
585
|
+
|
|
586
|
+
matches = []
|
|
587
|
+
if topic:
|
|
588
|
+
needle = topic.lower()
|
|
589
|
+
for n, e in learned.items():
|
|
590
|
+
hit = None
|
|
591
|
+
for k in ("pub", "sub", "srv", "act"):
|
|
592
|
+
if any(needle in t.lower() for t in (e.get(k) or [])):
|
|
593
|
+
hit = k
|
|
594
|
+
break
|
|
595
|
+
if hit:
|
|
596
|
+
matches.append({"name": n, "role": e.get("role", ""), "description": e.get("description", ""),
|
|
597
|
+
"pub": e.get("pub", []), "sub": e.get("sub", []), "srv": e.get("srv", []),
|
|
598
|
+
"act": e.get("act", []), "learned_at": e.get("learned_at", ""),
|
|
599
|
+
"matched": f"{hit}包含 {topic}"})
|
|
600
|
+
else:
|
|
601
|
+
needle = (query or "").lower()
|
|
602
|
+
if not needle:
|
|
603
|
+
return {"ok": True, "query": query, "field": field, "topic": topic,
|
|
604
|
+
"matches": [], "count": 0, "error": "search 需要 query 或 topic 之一", "profile_path": path}
|
|
605
|
+
fields = [f for f in SEARCH_FIELDS if field in ("all", f)]
|
|
606
|
+
for n, e in learned.items():
|
|
607
|
+
hit_field = None
|
|
608
|
+
for f in fields:
|
|
609
|
+
if f == "name":
|
|
610
|
+
haystack = [n]
|
|
611
|
+
else:
|
|
612
|
+
haystack = e.get(f, []) if isinstance(e.get(f), list) else [e.get(f, "")]
|
|
613
|
+
if any(needle in str(h).lower() for h in haystack):
|
|
614
|
+
hit_field = f
|
|
615
|
+
break
|
|
616
|
+
if hit_field:
|
|
617
|
+
matches.append({"name": n, "role": e.get("role", ""), "description": e.get("description", ""),
|
|
618
|
+
"pub": e.get("pub", []), "sub": e.get("sub", []), "srv": e.get("srv", []),
|
|
619
|
+
"act": e.get("act", []), "learned_at": e.get("learned_at", ""),
|
|
620
|
+
"matched": f"字段 {hit_field} 命中 '{query}'"})
|
|
621
|
+
return {"ok": True, "query": query, "field": field, "topic": topic,
|
|
622
|
+
"matches": matches, "count": len(matches), "profile_path": path}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def register(name: str, urdf: str, srdf: str, description: str) -> dict:
|
|
626
|
+
profile_dir = os.path.dirname(os.path.join(DEFAULT_DIR, name + ".yaml"))
|
|
627
|
+
os.makedirs(profile_dir, exist_ok=True)
|
|
628
|
+
|
|
629
|
+
urdf_xml = ""
|
|
630
|
+
if urdf:
|
|
631
|
+
try:
|
|
632
|
+
with open(urdf) as f:
|
|
633
|
+
urdf_xml = f.read()
|
|
634
|
+
except Exception as e: # noqa: BLE001
|
|
635
|
+
return {"ok": False, "error": f"cannot read URDF {urdf}: {e}"}
|
|
636
|
+
else:
|
|
637
|
+
urdf_xml = get_live_urdf() or ""
|
|
638
|
+
if not urdf_xml:
|
|
639
|
+
return {"ok": False, "error": "no URDF: pass --urdf or have a live /robot_description"}
|
|
640
|
+
|
|
641
|
+
body = parse_urdf(urdf_xml)
|
|
642
|
+
limits = parse_urdf_limits(urdf_xml)
|
|
643
|
+
srdf_resolved = resolve_srdf(srdf)
|
|
644
|
+
groups = parse_srdf_groups(srdf_resolved) if srdf_resolved else {}
|
|
645
|
+
zero = read_zero_pose()
|
|
646
|
+
|
|
647
|
+
profile = {
|
|
648
|
+
"robot": {
|
|
649
|
+
"name": name,
|
|
650
|
+
"description": description,
|
|
651
|
+
"registered_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
652
|
+
"urdf": urdf or "/robot_description (live)",
|
|
653
|
+
"urdf_links": body["links"],
|
|
654
|
+
"joints": body["joints"],
|
|
655
|
+
"tf_root": find_tf_root(),
|
|
656
|
+
"cameras": list_image_topics(),
|
|
657
|
+
"moveit": {
|
|
658
|
+
"srdf": srdf_resolved,
|
|
659
|
+
"groups": groups,
|
|
660
|
+
},
|
|
661
|
+
"zero_pose": zero or {"note": "未校准;可用 ros2_zero_pose_semantics 校准"},
|
|
662
|
+
"safety": default_safety(limits),
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
path = os.path.join(DEFAULT_DIR, f"{name}.yaml")
|
|
666
|
+
with open(path, "w") as f:
|
|
667
|
+
import yaml as pyyaml
|
|
668
|
+
f.write(f"# robot body profile (written by dsh-ros2 robot_profile)\n")
|
|
669
|
+
pyyaml.safe_dump(profile, f, allow_unicode=True, sort_keys=False)
|
|
670
|
+
return {"ok": True, "written": path, "robot": profile["robot"]}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def load(name: str):
|
|
674
|
+
path = os.path.join(DEFAULT_DIR, f"{name}.yaml")
|
|
675
|
+
if not os.path.exists(path):
|
|
676
|
+
return {"ok": False, "error": f"未找到机器人档案 {name}(可用 robot_profile.py list 查看,或先 register)"}
|
|
677
|
+
try:
|
|
678
|
+
import yaml as pyyaml
|
|
679
|
+
with open(path) as f:
|
|
680
|
+
data = pyyaml.safe_load(f) or {}
|
|
681
|
+
robot = data.get("robot", {})
|
|
682
|
+
# normalise counts for convenience
|
|
683
|
+
if "urdf_links" in robot:
|
|
684
|
+
robot["link_count"] = len(robot["urdf_links"])
|
|
685
|
+
if "joints" in robot:
|
|
686
|
+
robot["joint_count"] = len(robot["joints"])
|
|
687
|
+
return {"ok": True, "robot": robot, "profile_path": path}
|
|
688
|
+
except Exception as e: # noqa: BLE001
|
|
689
|
+
return {"ok": False, "error": f"解析档案失败: {e}"}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def list_profiles():
|
|
693
|
+
files = sorted(glob.glob(os.path.join(DEFAULT_DIR, "*.yaml")))
|
|
694
|
+
names = [os.path.basename(f)[: -len(".yaml")] for f in files]
|
|
695
|
+
return {"ok": True, "robots": names, "dir": DEFAULT_DIR}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main():
|
|
699
|
+
global DEFAULT_DIR
|
|
700
|
+
ap = argparse.ArgumentParser()
|
|
701
|
+
ap.add_argument("action", choices=["register", "load", "list", "topology", "safety"])
|
|
702
|
+
ap.add_argument("--name", default="")
|
|
703
|
+
ap.add_argument("--urdf", default="")
|
|
704
|
+
ap.add_argument("--srdf", default="")
|
|
705
|
+
ap.add_argument("--topology-action", default="show", choices=["snapshot", "learn", "show", "diagnose", "search"])
|
|
706
|
+
ap.add_argument("--safety-action", default="show", choices=["show", "set"])
|
|
707
|
+
ap.add_argument("--key", default="")
|
|
708
|
+
ap.add_argument("--query", default="")
|
|
709
|
+
ap.add_argument("--field", default="all")
|
|
710
|
+
ap.add_argument("--topic", default="")
|
|
711
|
+
ap.add_argument("--value", default="")
|
|
712
|
+
ap.add_argument("--node", default="")
|
|
713
|
+
ap.add_argument("--role", default="")
|
|
714
|
+
ap.add_argument("--pub", default="")
|
|
715
|
+
ap.add_argument("--sub", default="")
|
|
716
|
+
ap.add_argument("--srv", default="")
|
|
717
|
+
ap.add_argument("--act", default="")
|
|
718
|
+
ap.add_argument("--description", default="")
|
|
719
|
+
ap.add_argument("--dir", default=DEFAULT_DIR)
|
|
720
|
+
args = ap.parse_args()
|
|
721
|
+
DEFAULT_DIR = args.dir
|
|
722
|
+
|
|
723
|
+
if args.action == "safety":
|
|
724
|
+
if not args.name:
|
|
725
|
+
print(json.dumps({"ok": False, "error": "safety 需要 --name"}))
|
|
726
|
+
return 1
|
|
727
|
+
if args.safety_action == "set":
|
|
728
|
+
if not args.key or not args.value:
|
|
729
|
+
print(json.dumps({"ok": False, "error": "safety set 需要 --key 与 --value(value 为 JSON)"}))
|
|
730
|
+
return 1
|
|
731
|
+
out = safety_set(args.name, args.key, args.value)
|
|
732
|
+
else:
|
|
733
|
+
out = safety_show(args.name)
|
|
734
|
+
elif args.action == "topology":
|
|
735
|
+
topo_action = args.topology_action
|
|
736
|
+
if topo_action == "snapshot":
|
|
737
|
+
out = topo_snapshot(args.name)
|
|
738
|
+
elif topo_action == "learn":
|
|
739
|
+
if not (args.name and args.node):
|
|
740
|
+
print(json.dumps({"ok": False, "error": "topology learn 需要 --name 与 --node"}))
|
|
741
|
+
return 1
|
|
742
|
+
out = topo_learn(args.name, args.node, args.role, args.description,
|
|
743
|
+
args.pub, args.sub, args.srv, args.act)
|
|
744
|
+
elif topo_action == "diagnose":
|
|
745
|
+
if not args.name:
|
|
746
|
+
print(json.dumps({"ok": False, "error": "topology diagnose 需要 --name"}))
|
|
747
|
+
return 1
|
|
748
|
+
out = topo_diagnose(args.name)
|
|
749
|
+
elif topo_action == "search":
|
|
750
|
+
if not args.name:
|
|
751
|
+
print(json.dumps({"ok": False, "error": "topology search 需要 --name"}))
|
|
752
|
+
return 1
|
|
753
|
+
out = topo_search(args.name, args.query, args.field, args.topic)
|
|
754
|
+
else: # show
|
|
755
|
+
out = topo_show(args.name)
|
|
756
|
+
elif args.action == "register":
|
|
757
|
+
if not args.name:
|
|
758
|
+
print(json.dumps({"ok": False, "error": "register 需要 --name"}))
|
|
759
|
+
return 1
|
|
760
|
+
out = register(args.name, args.urdf, args.srdf, args.description)
|
|
761
|
+
elif args.action == "load":
|
|
762
|
+
if not args.name:
|
|
763
|
+
print(json.dumps({"ok": False, "error": "load 需要 --name"}))
|
|
764
|
+
return 1
|
|
765
|
+
out = load(args.name)
|
|
766
|
+
else:
|
|
767
|
+
out = list_profiles()
|
|
768
|
+
print(json.dumps(out, ensure_ascii=False, default=str))
|
|
769
|
+
return 0 if out.get("ok") else 1
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
if __name__ == "__main__":
|
|
773
|
+
sys.exit(main())
|