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