From 250d82a25e5d753678b36d5da5ae894ed1b905c5 Mon Sep 17 00:00:00 2001 From: PlaiPin Date: Fri, 12 Jun 2026 11:07:36 +0800 Subject: [PATCH] docs: add learn/ tutorials and code walkthroughs Co-Authored-By: Claude Opus 4.8 --- ...46\344\271\240\346\225\231\347\250\213.md" | 1618 +++ ...43\347\240\201\345\257\274\350\257\273.md" | 9786 +++++++++++++++++ learn/.build/img/00-1.png | Bin 0 -> 163349 bytes learn/.build/img/00-2.png | Bin 0 -> 76222 bytes learn/.build/img/01-1.png | Bin 0 -> 44839 bytes learn/.build/img/01-2.png | Bin 0 -> 37562 bytes learn/.build/img/01-3.png | Bin 0 -> 45163 bytes learn/.build/img/03-1.png | Bin 0 -> 79096 bytes learn/.build/img/03-2.png | Bin 0 -> 89804 bytes learn/.build/img/03-3.png | Bin 0 -> 138838 bytes learn/.build/process.py | 67 + ...71\347\233\256\346\246\202\350\247\210.md" | 244 + ...15\347\275\256\347\237\245\350\257\206.md" | 218 + ...61\345\272\246\350\247\243\346\236\220.md" | 329 + ...43\347\240\201\345\257\274\350\257\273.md" | 216 + ...36\346\210\230\346\225\231\347\250\213.md" | 613 ++ learn/README.md | 79 + ...44\347\232\204\346\227\205\347\250\213.md" | 485 + learn/code/01-plugin-api.ts.md | 496 + learn/code/02-config.ts.md | 320 + learn/code/03-transport-types.ts.md | 365 + learn/code/04-transport.ts.md | 262 + learn/code/05-rosbridge-types.ts.md | 247 + learn/code/06-rosbridge-client.ts.md | 855 ++ learn/code/07-rosbridge-topics.ts.md | 256 + learn/code/08-rosbridge-services.ts.md | 207 + learn/code/09-rosbridge-actions.ts.md | 293 + learn/code/10-rosbridge-adapter.ts.md | 395 + learn/code/11-transport-factory.ts.md | 182 + learn/code/12-service.ts.md | 317 + learn/code/13-index.ts.md | 192 + learn/code/14-tools-index.ts.md | 168 + learn/code/15-ros2-publish.ts.md | 191 + learn/code/16-ros2-subscribe.ts.md | 226 + learn/code/17-ros2-service.ts.md | 113 + learn/code/18-ros2-action.ts.md | 123 + learn/code/19-ros2-param.ts.md | 210 + learn/code/20-ros2-introspect.ts.md | 83 + learn/code/21-ros2-camera.ts.md | 173 + learn/code/22-safety-validator.ts.md | 190 + learn/code/23-robot-context.ts.md | 390 + learn/code/24-commands-estop.ts.md | 164 + learn/code/25-commands-transport.ts.md | 383 + learn/code/26-local-transport.ts.md | 534 + learn/code/27-local-conversion.ts.md | 267 + learn/code/28-local-entities.ts.md | 264 + learn/code/29-webrtc-signaling-types.ts.md | 178 + learn/code/30-webrtc-transport.ts.md | 408 + learn/code/31-webrtc-signaling-client.ts.md | 290 + learn/code/32-openclaw-canvas.md | 140 + learn/code/README.md | 270 + 51 files changed, 22807 insertions(+) create mode 100644 "learn/.build/RosClaw-\345\255\246\344\271\240\346\225\231\347\250\213.md" create mode 100644 "learn/.build/RosClaw-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" create mode 100644 learn/.build/img/00-1.png create mode 100644 learn/.build/img/00-2.png create mode 100644 learn/.build/img/01-1.png create mode 100644 learn/.build/img/01-2.png create mode 100644 learn/.build/img/01-3.png create mode 100644 learn/.build/img/03-1.png create mode 100644 learn/.build/img/03-2.png create mode 100644 learn/.build/img/03-3.png create mode 100644 learn/.build/process.py create mode 100644 "learn/01-\351\241\271\347\233\256\346\246\202\350\247\210.md" create mode 100644 "learn/02-\346\212\200\346\234\257\345\211\215\347\275\256\347\237\245\350\257\206.md" create mode 100644 "learn/03-\346\236\266\346\236\204\346\267\261\345\272\246\350\247\243\346\236\220.md" create mode 100644 "learn/04-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" create mode 100644 "learn/05-\345\256\214\346\225\264\345\256\236\346\210\230\346\225\231\347\250\213.md" create mode 100644 learn/README.md create mode 100644 "learn/code/00-\344\270\200\346\235\241\345\221\275\344\273\244\347\232\204\346\227\205\347\250\213.md" create mode 100644 learn/code/01-plugin-api.ts.md create mode 100644 learn/code/02-config.ts.md create mode 100644 learn/code/03-transport-types.ts.md create mode 100644 learn/code/04-transport.ts.md create mode 100644 learn/code/05-rosbridge-types.ts.md create mode 100644 learn/code/06-rosbridge-client.ts.md create mode 100644 learn/code/07-rosbridge-topics.ts.md create mode 100644 learn/code/08-rosbridge-services.ts.md create mode 100644 learn/code/09-rosbridge-actions.ts.md create mode 100644 learn/code/10-rosbridge-adapter.ts.md create mode 100644 learn/code/11-transport-factory.ts.md create mode 100644 learn/code/12-service.ts.md create mode 100644 learn/code/13-index.ts.md create mode 100644 learn/code/14-tools-index.ts.md create mode 100644 learn/code/15-ros2-publish.ts.md create mode 100644 learn/code/16-ros2-subscribe.ts.md create mode 100644 learn/code/17-ros2-service.ts.md create mode 100644 learn/code/18-ros2-action.ts.md create mode 100644 learn/code/19-ros2-param.ts.md create mode 100644 learn/code/20-ros2-introspect.ts.md create mode 100644 learn/code/21-ros2-camera.ts.md create mode 100644 learn/code/22-safety-validator.ts.md create mode 100644 learn/code/23-robot-context.ts.md create mode 100644 learn/code/24-commands-estop.ts.md create mode 100644 learn/code/25-commands-transport.ts.md create mode 100644 learn/code/26-local-transport.ts.md create mode 100644 learn/code/27-local-conversion.ts.md create mode 100644 learn/code/28-local-entities.ts.md create mode 100644 learn/code/29-webrtc-signaling-types.ts.md create mode 100644 learn/code/30-webrtc-transport.ts.md create mode 100644 learn/code/31-webrtc-signaling-client.ts.md create mode 100644 learn/code/32-openclaw-canvas.md create mode 100644 learn/code/README.md diff --git "a/learn/.build/RosClaw-\345\255\246\344\271\240\346\225\231\347\250\213.md" "b/learn/.build/RosClaw-\345\255\246\344\271\240\346\225\231\347\250\213.md" new file mode 100644 index 0000000..3973554 --- /dev/null +++ "b/learn/.build/RosClaw-\345\255\246\344\271\240\346\225\231\347\250\213.md" @@ -0,0 +1,1618 @@ +# RosClaw 学习教程 + +# RosClaw 新手学习指南 + +欢迎来到 RosClaw 学习中心!本指南带你从零开始理解和使用这个项目——**面向"新手中的新手"**,不要求你懂 ROS2 或 TypeScript。 + +--- + +## 学习路径 + +按顺序读这五章,循序渐进: + +| 序号 | 文件 | 内容 | 需要动手吗 | +|---|---|---|---| +| 1 | [项目概览](01-项目概览.md) | 是什么、解决什么痛点、三大概念、整体架构、术语表 | 否 | +| 2 | [技术前置知识](02-技术前置知识.md) | 会碰到哪些技术,每项最少要懂多少 | 否 | +| 3 | [架构深度解析](03-架构深度解析.md) | 三层架构、六模块、四种对接 AI 的方式、传输层设计 | 否 | +| 4 | [核心代码导读](04-核心代码导读.md) | 源码鸟瞰索引:哪个文件干什么、按什么顺序读 | 否 | +| 5 | [完整实战教程](05-完整实战教程.md) | 从零跑通:裸协议 → AI → 进阶练习 | **是** | + +读完这五章,再进 **[code/ 逐行详解系列](code/README.md)**(33 篇)逐行精读源码。 + +--- + +## 两条特别推荐的主线 + +无论你想"读懂"还是"跑通",这两篇是各自的最佳起点: + +- 🧭 **想读懂代码怎么运转** → [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md) + 跟着「向前走 1 米」这一条命令,看它如何穿过 8 个文件到达机器人——**精确到哪个文件、哪个函数、第几行**,并链到每篇逐行详解。这是整个 code/ 系列的"龙头"。 + +- 🚀 **想亲手跑起来** → [第五章:完整实战教程](05-完整实战教程.md) + 手把手:先用裸 JSON 让仿真机器人动(不用 AI),再叠加 OpenClaw + AI,最后做进阶练习。 + +--- + +## 按目标快速定位 + +- **我只想最快看到机器人动** → [第五章](05-完整实战教程.md) 的"第 0 + 第 1 部分"(不用 AI)。 +- **我想理解它怎么工作** → [第一章](01-项目概览.md) → [第三章](03-架构深度解析.md)。 +- **我想读源码** → [第四章](04-核心代码导读.md) 鸟瞰,再进 [code/ 系列](code/README.md);先读 [code/00](code/00-一条命令的旅程.md) 主线不迷路。 +- **我想自己动手改** → [第五章 · 第 5 部分进阶练习](05-完整实战教程.md#第-5-部分进阶练习)。 + +--- + +## 仓库地图 + +``` +rosclaw/ +├── extensions/openclaw-plugin/ ← 核心插件(TypeScript) +│ └── src/ +│ ├── config.ts ← 配置定义 +│ ├── index.ts ← 插件入口 +│ ├── tools/ ← AI 可调用的 8 个 ROS2 工具 +│ ├── transport/ ← 三种传输层适配器(A/B/C 模式) +│ ├── safety/ ← 安全校验钩子 +│ ├── context/ ← 机器人能力注入钩子 +│ └── commands/ ← 直接命令(如 /estop 急停) +├── extensions/openclaw-canvas/ ← 第二个扩展:实时仪表盘(占位中) +├── ros2_ws/src/ ← ROS2 Python 包 +│ ├── rosclaw_discovery/ ← 能力自动发现节点 +│ ├── rosclaw_msgs/ ← 自定义消息/服务类型 +│ └── rosclaw_agent/ ← WebRTC 模式的机器人侧节点 +├── docker/ ← 一键启动仿真环境 +├── examples/ ← 三个场景示例 +├── docs/ ← 架构文档 +└── learn/ ← 【你在这里】新手学习资料 + ├── 01~05-*.md ← 五章学习路径 + └── code/ ← 逐行详解系列(00 主线 + 01~32 各文件) +``` + +--- + +## 这套资料的两个层次 + +| 层次 | 在哪 | 干什么 | 比喻 | +|---|---|---|---| +| **入门五章** | `learn/01~05` | 概念、架构、鸟瞰、实战 | 城市地图 | +| **逐行详解** | `learn/code/` | 每一行、每个语法都拆开讲 | 逐街逐户导览 | + +先看地图(五章)建立全局观,再逐户细看(code/)才扎实。code/ 系列专为"新手中的新手"设计,每个新出现的 TypeScript 语法都有「语法小课堂」,并配有可反查的"语法学习地图"。 + + + +--- + + +# 第一章:项目概览 + +> 本章目标:在不碰任何代码的前提下,让你彻底搞懂 **RosClaw 是什么、解决什么问题、整体怎么运作**。读完你会对整个项目有一张"心理地图",后面每一章都是往这张地图上填细节。 +> +> 面向**完全的新手**——不要求你懂 ROS2、不要求你会 TypeScript。遇到术语都会用大白话和类比解释。 + +--- + +## 一、一句话:RosClaw 是什么? + +**RosClaw 是一座"桥"——它让你能用聊天软件里的大白话,去指挥一个机器人。** + +![](img/01-1.png) + +不用学机器人编程、不用记复杂命令,**像聊天一样指挥机器人**——这就是 RosClaw 想做的事。 + +### 它到底解决了什么痛点? + +传统上要控制一个 ROS2 机器人,你得: + +- 在和机器人**同一个网络/同一台电脑**上; +- 安装一整套 ROS2 开发环境; +- 用命令行敲 `ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}"` 这种又长又容易写错的指令; +- 自己换算"向前 1 米"等于"速度 0.5 持续 2 秒"。 + +RosClaw 把这些**全部交给 AI 和插件代劳**。你只要会发微信消息,就能控制机器人——哪怕机器人在地球另一端。 + +--- + +## 二、三个必须先搞懂的概念 + +整个项目反复出现三个名词。先用大白话讲清楚,后面就不会懵。 + +### 概念 1:OpenClaw —— "AI 版的客服总机" + +**OpenClaw 是一个 AI 网关平台**。你可以把它想成一个**智能总机**: + +- 一头连着各种聊天软件(WhatsApp、Telegram、Discord、Slack,还有网页聊天框); +- 中间坐着一个 **AI 代理**(大脑是 Claude 这样的大模型),负责"听懂人话、决定该干什么"; +- 另一头连着各种**插件**,每个插件提供一组"它会干的活儿"(叫做"工具")。 + +> 类比:OpenClaw 像一家公司的**前台 + 调度中心**。顾客(你)打电话进来说需求,前台(AI)听懂后,派给对应的部门(插件)去办。 + +**RosClaw 就是 OpenClaw 的一个插件**——专门负责"和机器人打交道"这摊活儿。OpenClaw 本身不在这个仓库里,它是运行 RosClaw 的"宿主平台"。 + +### 概念 2:ROS2 —— "机器人世界的操作系统" + +**ROS2(Robot Operating System 2)是机器人软件的通用框架**。几乎所有研究和工业机器人都用它。你不需要会写 ROS2 程序,但要听懂下面 4 个词,因为 RosClaw 干的事全是围绕它们: + +| 词 | 大白话 | 例子 | +|---|---|---| +| **节点(Node)** | 一个独立运行的小程序 | "摄像头节点"、"导航节点"、"电机节点" | +| **话题(Topic)** | 一条持续广播的"频道",发布者往里发、订阅者从里收 | `/cmd_vel`(速度指令频道)、`/odom`(位置反馈频道) | +| **服务(Service)** | 一问一答的调用,发一个请求等一个回复 | "告诉我当前电量是多少?" | +| **动作(Action)** | 一个耗时任务,过程中能持续汇报进度 | "导航到门口"(要走一会儿,途中不断报"还剩 3 米") | + +再加一个底层名词: + +- **DDS**:ROS2 内部传递消息用的"快递系统"(一种高效的二进制通信协议)。机器人内部节点之间靠它互相发消息。 + +> 类比:把机器人想成一家工厂。**节点**是各个车间;**话题**是厂区广播("所有人注意,开始干活");**服务**是打电话问某车间"现在产量多少";**动作**是派一个长期任务并要求定时汇报;**DDS** 是厂内的内部邮路。 + +### 概念 3:rosbridge —— "给机器人装的一扇翻译窗口" + +这里有个现实难题:ROS2 内部用 **DDS**(二进制)通信,而我们的插件是用 **TypeScript/Node.js** 写的,**两者语言不通**——Node.js 没法直接说 DDS。 + +**rosbridge** 就是解决这个的官方组件。它给 ROS2 开了一扇"翻译窗口":把 ROS2 的 DDS 通信**封装成 WebSocket + JSON**——而 JSON 和 WebSocket 是任何编程语言(包括 Node.js)都会的"普通话"。 + +![](img/01-2.png) + +> 类比:你(只会中文)要和一位只会法语的工程师沟通,中间请了个**翻译**(rosbridge)。你说中文,翻译转成法语;对方回法语,翻译转回中文。rosbridge 就是 ROS2 和外部世界之间的这位翻译。 + +一条典型的"rosbridge 普通话"长这样(让机器人前进): + +```json +{ + "op": "publish", + "topic": "/cmd_vel", + "msg": { "linear": { "x": 0.5, "y": 0, "z": 0 }, "angular": { "x": 0, "y": 0, "z": 0 } } +} +``` + +`op` 字段表明"这是一个发布操作",后面是发到哪个话题、发什么内容。记住这个 `op` 格式,它会贯穿整个项目。 + +--- + +## 三、整体架构:一句话 + 一张图 + +把上面三个概念串起来,就是 RosClaw 的全貌: + +![](img/01-3.png) + +**每一个箭头,都是一次"翻译"**(信息换了一种形态,但意思不变): + +| # | 这一步 | 把"什么"翻译成"什么" | 谁干的 | +|---|---|---|---| +| 1 | 消息应用 → OpenClaw | 微信/Telegram 协议 → OpenClaw 内部消息 | OpenClaw | +| 2 | OpenClaw → 插件 | 自然语言「向前走」→ 一次"工具调用" | AI 代理 | +| 3 | 插件 → rosbridge | 工具调用 → rosbridge 的 JSON | RosClaw 插件 | +| 4 | rosbridge → ROS2 | JSON → DDS 二进制消息 | rosbridge_server | + +> 整个项目的"主角"是第 3 步——**RosClaw 插件**。它是这个仓库里你将要读的几乎所有代码。第 1、4 步是别人(OpenClaw、rosbridge)干的,第 2 步是 AI 干的。 + +如果你想**立刻看到这条链路在代码里逐行是怎么走的**(精确到哪个文件、哪个函数、第几行),有一篇专门的"流程主线"文档:[code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)。建议读完本系列前几章后去看,它会把这张抽象的图变成真实的代码地图。 + +--- + +## 四、三种部署模式:机器人离你多远? + +RosClaw 支持三种连接机器人的方式,对应"机器人离你有多远"的三种情况: + +| 模式 | 场景 | 怎么连 | 什么时候用 | +|---|---|---|---| +| **模式 A:同机** | OpenClaw 和机器人在**同一台电脑** | 直接走 ROS2 DDS,不经过网络 | 延迟最低;机器人自带的板载电脑 | +| **模式 B:局域网** | OpenClaw 在你笔记本,机器人在**同一个 WiFi** | 通过 rosbridge 的 WebSocket | **新手首选**,开发调试最方便 | +| **模式 C:云端** | OpenClaw 在云服务器,机器人在**远端**(家里/野外) | 通过 WebRTC 穿透网络 | 远程遥控,跨地域 | + +> 💡 **新手建议先用模式 B**:在自己电脑上用 Docker 跑一个 ROS2 仿真机器人,再让插件通过 rosbridge 连上它。不需要真机器人,零成本就能体验完整流程。具体步骤见 [第五章:完整实战教程](05-完整实战教程.md)。 + +这三种模式在代码里对应三套"传输层"实现,但它们**对上层提供完全一样的接口**——这是 RosClaw 一个很漂亮的设计,后面 [第三章](03-架构深度解析.md) 会细讲。 + +> 📌 **关于实现状态(诚实说明)**:项目文档曾把模式 A(local)和模式 C(webrtc)标为"存根/未实现"。但实际逐行读代码会发现,**模式 C 其实是相当完整的实现**(真的用了 WebRTC 库)。这种"文档和代码不一致"的情况,我们在逐行详解里会如实指出(见 [code/30](code/30-webrtc-transport.ts.md) 的勘误)。**读代码时以代码为准**——这本身就是一项重要的读码功夫。目前可以确定:**模式 B(rosbridge)是默认且最完整的**,新手就用它。 + +--- + +## 五、仓库长什么样?(目录巡览) + +这是一个 **monorepo**(多个相关项目放在同一个仓库里)。主要目录: + +``` +rosclaw/ +├── extensions/ ← 【主角】OpenClaw 插件(TypeScript) +│ ├── openclaw-plugin/ ← 核心插件:控制机器人 +│ │ ├── src/ ← 你要读的源代码几乎都在这 +│ │ │ ├── index.ts ← 插件入口 +│ │ │ ├── config.ts ← 配置定义 +│ │ │ ├── tools/ ← AI 能调用的 8 个工具 +│ │ │ ├── transport/ ← 三种传输层(A/B/C 模式) +│ │ │ ├── safety/ ← 安全校验 +│ │ │ ├── context/ ← 把机器人能力告诉 AI +│ │ │ └── commands/ ← 直接命令(如 /estop 急停) +│ │ └── skills/ ← 多步"技能"(导航、拍照……) +│ └── openclaw-canvas/ ← 第二个插件:实时仪表盘(占位中) +├── ros2_ws/src/ ← ROS2 这一侧的 Python 包 +│ ├── rosclaw_discovery/ ← "能力自动发现"节点 +│ ├── rosclaw_msgs/ ← 自定义消息/服务类型 +│ └── rosclaw_agent/ ← 模式 C 机器人侧节点 +├── docker/ ← 一键启动仿真环境 +├── examples/ ← 三个场景示例 +├── docs/ ← 架构文档 +└── learn/ ← 【你在这里】新手学习资料 +``` + +**90% 的代码阅读都集中在 `extensions/openclaw-plugin/src/`**。其余目录(ROS2 包、Docker、示例)是配套。 + +--- + +## 六、三个现成示例 + +仓库自带三个示例场景,展示 RosClaw 能干什么: + +| 示例 | 路径 | 说明 | +|---|---|---| +| 小车聊天控制 | `examples/turtlebot-chat/` | 用聊天消息控制 Gazebo 仿真里的 TurtleBot3 小车 | +| 机械臂控制 | `examples/arm-control/` | 用自然语言让机械臂做动作 | +| 多机巡逻 | `examples/fleet-patrol/` | 指挥多个机器人执行巡逻任务 | + +--- + +## 七、新手术语小词典 + +读后面章节前,把这张表存着,遇到生词回来查: + +| 术语 | 一句话解释 | +|---|---| +| OpenClaw | AI 网关平台,RosClaw 运行在它上面(宿主) | +| 插件(plugin) | 给 OpenClaw 增加能力的扩展,RosClaw 就是一个插件 | +| 工具(tool) | 插件提供给 AI 调用的一个具体动作(如"发布话题") | +| ROS2 | 机器人软件框架 | +| 话题 / 服务 / 动作 | ROS2 的三种通信方式:广播频道 / 一问一答 / 耗时任务带进度 | +| DDS | ROS2 内部的二进制通信协议 | +| rosbridge | 把 ROS2 的 DDS 封装成 WebSocket+JSON 的翻译组件 | +| WebSocket | 一种保持长连接、可双向实时收发的网络协议 | +| 传输层(transport) | 插件里负责"实际和机器人通信"的那一层,有 A/B/C 三种实现 | +| 仿真(simulation) | 用软件(Gazebo)模拟一个机器人,不用真硬件 | + +--- + +## 八、接下来读什么 + +你已经有了全局地图。根据你的目的选择下一步: + +- **想继续打基础**(推荐按顺序)→ [第二章:技术前置知识](02-技术前置知识.md):学这个项目会碰到哪些技术、每项最少要懂多少。 +- **想理解它内部怎么搭起来的** → [第三章:架构深度解析](03-架构深度解析.md)。 +- **想立刻动手跑起来** → [第五章:完整实战教程](05-完整实战教程.md):手把手让仿真机器人动起来。 +- **想钻进源码** → [第四章:核心代码导读](04-核心代码导读.md),及配套的 [code/ 逐行详解系列](code/README.md)。 + +**下一步** → [第二章:技术前置知识](02-技术前置知识.md) + + + +--- + + +# 第二章:技术前置知识 + +> 本章把"读懂 RosClaw 会用到的技术"逐项过一遍。每一项都按同一个套路讲:**是什么(类比)→ 项目为什么用它 → 在项目哪个位置 → 你最少要懂多少 → 想深入看哪篇**。 +> +> **重要心态**:你**不需要精通**这些技术。本章的目的是让你"认得出、不害怕",而不是"全学会"。真正想钻语法细节时,[code/ 逐行详解系列](code/README.md) 会用「语法小课堂」一点点补课。 + +--- + +## 怎么用这一章 + +下面把技术分成两档: + +- **必须有个印象**(TypeScript、ROS2 概念、rosbridge、Docker)——不懂会卡。 +- **知道有这回事就行**(Zod、TypeBox、pnpm、WebSocket、WebRTC)——用到时回来查。 + +每项末尾的"深入看哪篇"指向 code/ 系列,等你读源码时按图索骥即可。 + +--- + +## 第一档:必须有个印象 + +### 1. TypeScript / Node.js / ESM + +**是什么**: + +- **Node.js** 是"让 JavaScript 能在电脑上(而非只在浏览器里)运行"的运行环境。 +- **TypeScript(简称 TS)** 是给 JavaScript "加了类型标注"的升级版。所谓"类型",就是给每个数据贴个标签说明"它长什么样"(是文字 `string`、数字 `number`,还是某种对象)。加了类型,**很多错误在写代码时就能被发现**,而不是等运行才崩。 +- **ESM**(ES Modules)是 JS 现代的"模块化"方式——用 `import`/`export` 在文件之间互相借用代码。 + +> 类比:JavaScript 像随手写的便条,写错了没人管;TypeScript 像填表格,每个格子规定了"只能填数字"或"只能填日期",填错当场提示。 + +**项目为什么用**:整个插件层要求类型安全、现代化,所以全用 TS + ESM(`"type": "module"`,严格模式)。 + +**在项目哪里**:`extensions/` 下所有 `.ts` 文件。 + +**最少要懂**:认识三样就够起步—— +- `interface` / `type`:定义"一个对象长什么样"。 +- 泛型 ``:给类型留个"待填的空"(如"一个装 T 的数组")。 +- `import` / `export`:文件间借用代码。 + +> 💡 一个会让新手困惑的小细节:项目里 `import` 别的文件时写的是 `.js` 后缀(如 `from "./config.js"`),但文件明明是 `.ts`。这是 ESM 的规矩,[code/01](code/01-plugin-api.ts.md) 会解释。 + +**深入看哪篇**:[code/01 · plugin-api.ts](code/01-plugin-api.ts.md) 从最基础的"什么是类型/接口"讲起,是整个语法学习的起点。 + +### 2. ROS2 的四个核心概念 + +**是什么**:机器人软件框架。详见 [第一章](01-项目概览.md) 的讲解,这里只复述要记的四个词 + 一个底层词: + +| 概念 | 大白话 | 典型例子 | +|---|---|---| +| 节点 Node | 一个独立运行的小程序 | 摄像头节点、导航节点 | +| 话题 Topic | 持续广播的频道(发布/订阅) | `/cmd_vel` 速度、`/odom` 里程计 | +| 服务 Service | 一问一答 | "当前电量?" | +| 动作 Action | 耗时任务 + 进度反馈 | "导航到目标点" | +| DDS | ROS2 内部的二进制通信协议 | (底层,看不见) | + +**项目为什么用**:RosClaw 做的所有事,本质都是"对这四种东西做操作"——发布话题、订阅话题、调服务、发动作。 + +**在项目哪里**:`src/tools/` 下的 8 个工具,正好一一对应这些操作(发布、订阅、调服务、发动作、读写参数、列话题、拍照)。 + +**最少要懂**:能区分"话题 vs 服务 vs 动作"——这决定了用哪个工具。一句话记忆: +- 持续广播的数据(速度、传感器)→ **话题**; +- 一次性问答(查个值、设个参数)→ **服务**; +- 要等一会儿、还想看进度(导航、抓取)→ **动作**。 + +还要知道**消息类型**:每个话题的数据都有固定格式,如 `geometry_msgs/msg/Twist`(速度)含 `linear.x`(前进)和 `angular.z`(转向)字段。 + +**深入看哪篇**:[code/03 · transport-types.ts](code/03-transport-types.ts.md)(这些概念在代码里如何用类型表达)。 + +### 3. rosbridge 协议(WebSocket + JSON) + +**是什么**:把 ROS2 的 DDS 通信"翻译"成 WebSocket + JSON 的官方组件(详见 [第一章](01-项目概览.md) 的"翻译窗口"类比)。 + +**项目为什么用**:因为 Node.js 不会说 DDS,但人人都会 JSON。rosbridge 让我们的 TS 插件能通过发 JSON 文本来操作 ROS2。这是**模式 B(默认模式)**的基础。 + +**在项目哪里**:`src/transport/rosbridge/` 整个目录。 + +**最少要懂**:rosbridge 消息就是带 `op` 字段的 JSON。认识这几个 `op`: + +```json +// 发布(让机器人动) +{"op": "publish", "topic": "/cmd_vel", "msg": { ... }} + +// 订阅(持续接收某话题) +{"op": "subscribe", "topic": "/odom"} + +// 调用服务(一问一答) +{"op": "call_service", "service": "/rosapi/topics"} +``` + +了解它对**调试**很有用——出问题时你能看懂插件到底发了什么。[第五章](05-完整实战教程.md) 还会教你亲手发这些 JSON。 + +**深入看哪篇**:[code/05 · rosbridge-types.ts](code/05-rosbridge-types.ts.md)(协议全部消息的类型定义)+ [code/06 · client.ts](code/06-rosbridge-client.ts.md)(怎么收发,最核心)。 + +### 4. Docker / Docker Compose + +**是什么**: + +- **Docker** 把一个软件 + 它需要的所有环境**打包成一个"集装箱"(容器)**,在任何电脑上都能一致地跑起来,不用自己装一堆依赖。 +- **Docker Compose** 用一个 `.yml` 文件**一次性启动多个容器**并把它们连起来。 + +> 类比:Docker 像"即食餐盒"——菜、米、调料都配好封装,加热即食,不用自己买菜。Docker Compose 则是"一桌套餐",一道命令上齐好几个餐盒。 + +**项目为什么用**:让你**不装 ROS2** 就能跑起一个完整的仿真机器人环境——这是新手能零成本体验的关键。 + +**在项目哪里**:`docker/` 目录。注意有好几个 compose 文件,对应不同部署模式: + +``` +docker/ +├── docker-compose.yml # 默认(模式 B):ROS2 仿真 + rosbridge +├── docker-compose.dev.yml # 开发模式(挂载源码) +├── docker-compose.local.yml # 模式 A:同机 +├── docker-compose.robot.yml # 模式 C:机器人侧 +├── docker-compose.cloud.yml # 模式 C:云侧 +└── docker-compose.sim.yml # 纯仿真 +``` + +**最少要懂**:会用两条命令——`docker compose up`(启动)和 `docker compose down`(停止)。注意是 `docker compose`(带空格,新语法),不是老的 `docker-compose`。 + +**深入看哪篇**:[第五章:完整实战教程](05-完整实战教程.md) 会手把手带你用 Docker 跑起仿真。 + +--- + +## 第二档:知道有这回事就行 + +### 5. pnpm 与 Monorepo(工作空间) + +**是什么**:`pnpm` 是个更快、更省磁盘的包管理器(类似 `npm`)。**workspace(工作空间)**让一个仓库里管多个包。 + +**项目为什么用**:仓库里有两个包(`openclaw-plugin` 和 `openclaw-canvas`),用 pnpm workspace 统一管理。 + +**最少要懂**两条命令: + +```bash +pnpm install # 一次性装好所有包的依赖 +pnpm typecheck # 对所有包做类型检查(验证代码类型没写错) +``` + +### 6. Zod(配置校验) + +**是什么**:一个"运行时检查数据形状对不对"的库。你定义一个 schema(数据该长什么样),它就能在程序运行时验证真实数据、填默认值。 + +**项目为什么用**:用户写的插件配置(连哪个机器人、速度上限多少)可能写错或缺项。Zod 在插件启动时**校验一次**,把缺的填上默认值,把错的报出来。 + +**在项目哪里**:`src/config.ts`(开头就是 `import { z } from "zod"`)。 + +**深入看哪篇**:[code/02 · config.ts](code/02-config.ts.md)。 + +### 7. TypeBox(工具参数定义)—— 注意别和 Zod 搞混 + +**是什么**:另一个定义"数据形状"的库,但用途不同——它生成的是 **AI 能读懂的参数说明(JSON Schema)**。 + +**项目为什么用 + 和 Zod 的区别**(新手很容易混淆,记住这条): + +| | Zod | TypeBox | +|---|---|---| +| 用来定义 | **插件配置**的形状 | **工具参数**的形状 | +| 给谁看 | 给程序校验用户配置 | 给 **AI** 看"这个工具要传什么参数" | +| 在哪 | `src/config.ts` | `src/tools/*.ts` 每个工具里 | + +**深入看哪篇**:[code/14 · 工具通用结构](code/14-tools-index.ts.md)、[code/15 · ros2-publish.ts](code/15-ros2-publish.ts.md)。 + +### 8. WebSocket + +**是什么**:一种网络协议,特点是**建立一次连接后,双方可以持续、双向、实时地互发消息**(不像普通网页那样"问一次答一次就断")。 + +**项目为什么用**:rosbridge 用 WebSocket 通信。机器人的传感器数据需要持续推送,正适合 WebSocket 这种长连接。 + +**在项目哪里**:`src/transport/rosbridge/client.ts` 用 `ws` 库连 WebSocket。 + +**深入看哪篇**:[code/06 · client.ts](code/06-rosbridge-client.ts.md)。 + +### 9. WebRTC 与 DDS(仅模式 A / C,可跳过) + +**是什么**: + +- **WebRTC**:浏览器/设备之间**点对点直连**传输数据的技术,能穿透防火墙/NAT。模式 C 用它来连远端机器人。 +- **直连 DDS**:模式 A 在同一台机器上时,跳过 rosbridge,直接说 ROS2 的"母语"DDS(通过 `rclnodejs` 库)。 + +**项目为什么用**:覆盖"机器人在远端"(C)和"在同机"(A)两种场景。 + +**在项目哪里**:`src/transport/webrtc/` 和 `src/transport/local/`。 + +> 新手可以**完全跳过**这两块,先掌握模式 B。等熟练了再回来。 + +**深入看哪篇**:[code/26 · local-transport.ts](code/26-local-transport.ts.md)(模式 A)、[code/29–31](code/29-webrtc-signaling-types.ts.md)(模式 C)。 + +--- + +## 技术 → 深入篇目 速查表 + +| 技术 | 在项目哪里 | 深入看哪篇 code/ | +|---|---|---| +| TypeScript 基础 | 所有 `.ts` | [01](code/01-plugin-api.ts.md) | +| Zod 配置校验 | `config.ts` | [02](code/02-config.ts.md) | +| ROS2 概念建模 | `transport/types.ts` | [03](code/03-transport-types.ts.md) | +| 传输层抽象接口 | `transport/transport.ts` | [04](code/04-transport.ts.md) | +| rosbridge 协议 | `transport/rosbridge/types.ts` | [05](code/05-rosbridge-types.ts.md) | +| WebSocket 收发 | `transport/rosbridge/client.ts` | [06](code/06-rosbridge-client.ts.md) 🔥 | +| TypeBox 工具参数 | `tools/*.ts` | [14](code/14-tools-index.ts.md)、[15](code/15-ros2-publish.ts.md) | +| 模式 A(DDS 直连) | `transport/local/` | [26–28](code/26-local-transport.ts.md) | +| 模式 C(WebRTC) | `transport/webrtc/` | [29–31](code/29-webrtc-signaling-types.ts.md) | + +--- + +## 快速自测 + +读完本章,能用大白话回答这几个问题就可以继续了(答不上就回看对应小节): + +1. ROS2 的"话题"和"服务"有什么区别?什么时候用哪个? +2. 为什么需要 rosbridge?它解决了 Node.js 的什么困难? +3. Zod 和 TypeBox 都在定义"数据形状",它们的用途有什么不同? +4. `docker compose up` 是用来干什么的? +5. 三种部署模式里,新手该先用哪个?为什么? + +--- + +**下一步** → [第三章:架构深度解析](03-架构深度解析.md):把这些技术拼起来,看 RosClaw 内部是怎么搭的。 + + + +--- + + +# 第三章:架构深度解析 + +> 本章目标:把 RosClaw 插件**内部是怎么搭起来的**讲透——分几层、每层干什么、谁先谁后、为什么这么设计。 +> +> [第一章](01-项目概览.md) 给了全局地图,本章把镜头推近到"插件内部"。读完你会理解四种核心机制(工具/钩子/命令/传输层),这是看懂源码的骨架。 +> +> 想看一条命令在代码里**逐行、逐函数**怎么流动?那是配套的 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)——本章讲"结构",那篇讲"流动",互为表里。 + +--- + +## 一、三层架构:全局视角 + +整个系统从上到下分三层,职责清晰: + +
+ +
RosClaw 三层架构
+
+
+
① 消息层(用户接口)
+
WhatsApp
Telegram
Discord
网页聊天
+
+
▼   自然语言消息
+
+
② AI 网关层(OpenClaw) ← RosClaw 插件挂在这里
+
AI 代理
理解意图
RosClaw 插件
工具 / 钩子 / 命令
决定调用哪个工具
+
+
▼   rosbridge JSON / DDS / WebRTC
+
+
③ ROS2 层(机器人)
+
/cmd_vel
速度
/odom
里程计
/camera
图像
+
+
电机
摄像头
传感器
+
+
+
+ +- **消息层**:用户从哪进来。RosClaw 不管这层(OpenClaw 管)。 +- **AI 网关层**:OpenClaw + 大模型。**RosClaw 插件就挂在这一层**,给 AI 提供"和机器人打交道的能力"。 +- **ROS2 层**:真正的机器人(或仿真)。 + +**RosClaw 插件是连接 AI 层和 ROS2 层的桥。** 本章剩下的内容都在拆解这座桥。 + +--- + +## 二、插件内部:六个模块 + +RosClaw 插件的全部代码在 `extensions/openclaw-plugin/src/`,分成六块: + +``` +src/ +├── index.ts ← 入口:把下面所有模块"注册"起来 +├── config.ts ← 配置定义(Zod 校验) +├── plugin-api.ts ← OpenClaw 平台的 API 类型声明("合同") +├── service.ts ← 传输层连接的生命周期管理(单例) +├── tools/ ← AI 工具(8 个) +├── transport/ ← 传输层(3 种模式) +├── safety/ ← 安全校验钩子 +├── context/ ← 机器人能力注入钩子 +└── commands/ ← 直接命令(/estop、/transport) +``` + +把它们的关系画出来: + +![](img/03-1.png) + +- `config.ts`、`plugin-api.ts` 是**地基**(配置和类型契约)。 +- `service.ts` + `transport/` 是**连接管道**(怎么和机器人通信)。 +- `tools/`、`safety/`、`context/`、`commands/` 是**四种对接 AI 的方式**(下一节细讲)。 + +--- + +## 三、真实启动顺序:`register()` 做了什么 + +插件被 OpenClaw 加载时,会调用入口 `index.ts` 里的 `register()` 函数。这是**真实代码**(不是伪代码),按顺序做 7 件事: + +```typescript +register(api) { + api.logger.info("RosClaw plugin loading..."); + const config = parseConfig(api.pluginConfig ?? {}); // 0. 解析+校验配置 + + registerService(api, config); // 1. 建立 ROS2 连接(传输层) + registerTools(api); // 2. 注册 8 个 AI 工具 + registerSafetyHook(api, config); // 3. 注册安全校验钩子 + registerRobotContext(api, config); // 4. 注册机器人能力注入钩子 + registerEstopCommand(api, config); // 5. 注册 /estop 命令 + registerTransportCommand(api, config); // 6. 注册 /transport 命令 + + api.logger.info("RosClaw plugin loaded successfully"); +} +``` + +**这 7 步就是插件的完整生命周期起点。** 注意两个要点: + +1. **顺序有讲究**:`registerService` 必须第一个——先把"和机器人的连接"建好,后面的工具、钩子、命令才有连接可用。 +2. **"注册" ≠ "执行"**:`register` 阶段只是**登记**("这里有个工具叫 X""门口安排个安全员"),并不真的去控制机器人。真正干活是等用户消息进来、相应的零件被触发时。 + +> 🔍 逐行详解:[code/13 · index.ts](code/13-index.ts.md)。 + +--- + +## 四、四种对接 AI 的方式(核心机制) + +这是理解 RosClaw 的**最关键一节**。插件和 OpenClaw/AI 打交道,一共四种方式,时机和用途各不相同: + +| 方式 | 谁触发 | 何时 | 返回什么 | 例子 | +|---|---|---|---|---| +| **工具 Tool** | **AI** 主动调用 | AI 决定要做某动作时 | `content` + `details` | `ros2_publish` 发速度 | +| **钩子 Hook(工具前)** | 宿主自动 | 每次工具执行**前** | `{block, blockReason}` 或放行 | 安全校验拦超速 | +| **钩子 Hook(会话前)** | 宿主自动 | 每次 AI 会话**开始前** | `{prependContext}` | 注入机器人能力清单 | +| **命令 Command** | **用户**直接打 | 用户输入 `/xxx` 时 | `{text}` | `/estop` 急停 | + +逐一说明: + +### 4.1 工具(tools/)—— AI 的"动作按钮" + +AI 不能直接碰机器人,它只能**调用插件注册的工具**。每个工具是一个"AI 能按的按钮",带: +- 名字(`ros2_publish`); +- 给 AI 看的说明(`description`,相当于提示词,决定 AI 何时用它); +- 参数定义(TypeBox schema,告诉 AI 要传什么); +- 执行逻辑(`execute`,真正干活)。 + +RosClaw 注册了 **8 个工具**: + +| 文件 | 工具名 | 功能 | 对应 ROS2 操作 | +|---|---|---|---| +| `ros2-publish.ts` | `ros2_publish` | 向话题发布消息 | 话题(发布) | +| `ros2-subscribe.ts` | `ros2_subscribe_once` | 读取话题最新一条消息 | 话题(订阅) | +| `ros2-service.ts` | `ros2_service_call` | 调用服务 | 服务 | +| `ros2-action.ts` | `ros2_action_goal` | 发送动作目标 | 动作 | +| `ros2-param.ts` | `ros2_param_get` / `ros2_param_set` | 读/写节点参数 | 服务(参数) | +| `ros2-introspect.ts` | `ros2_list_topics` | 列出可用话题 | 服务(自省) | +| `ros2-camera.ts` | `ros2_camera_snapshot` | 截取摄像头图像 | 话题(订阅图像) | + +> 🔍 逐行详解:[code/14 · 工具通用结构](code/14-tools-index.ts.md),及 [code/15–21](code/15-ros2-publish.ts.md) 各工具。 + +### 4.2 钩子(safety/ 和 context/)—— 自动触发的"拦截 / 注入" + +钩子是**宿主在特定时机自动触发**的函数(不是 AI 或用户主动调)。RosClaw 用了两个: + +- **`before_tool_call`(工具执行前)—— 安全校验** `safety/validator.ts` + 每次工具即将执行前触发。检查 `ros2_publish` 的速度是否超限,超了就返回 `{ block: true, blockReason }` **拦下**,否则放行。这保证了"AI 再怎么乱来也突破不了物理安全上限"。 + +- **`before_agent_start`(AI 会话开始前)—— 能力注入** `context/robot-context.ts` + 每次对话开始前触发。它去查机器人当前有哪些话题/服务/动作,拼成一段文字,通过 `{ prependContext }` **塞进 AI 的系统提示**——这样 AI 才知道这台机器人"能干什么、该往哪个话题发"。还带 60 秒缓存和失败降级。 + +> 🔍 逐行详解:[code/22 · validator.ts](code/22-safety-validator.ts.md)、[code/23 · robot-context.ts](code/23-robot-context.ts.md)。 + +### 4.3 命令(commands/)—— 用户直接打的 `/xxx` + +命令**绕过 AI**,由用户直接输入触发,返回的 `{ text }` 直接显示给用户。RosClaw 有两个: + +- **`/estop`(紧急停止)** `commands/estop.ts` + 直接向 `/cmd_vel` 发零速度,**完全绕过 AI 推理**——紧急情况下不能等 AI 慢慢想,必须即时响应。 +- **`/transport`(运行时切换传输模式)** `commands/transport.ts` + 在 rosbridge/local/webrtc 之间热切换,不重启插件。 + +> 🔍 逐行详解:[code/24 · estop.ts](code/24-commands-estop.ts.md)、[code/25 · transport.ts](code/25-commands-transport.ts.md)。 + +--- + +## 五、传输层:RosClaw 最漂亮的设计 + +工具调 `transport.publish(...)` 时,**根本不知道底层用的是 WebSocket 还是 WebRTC**——因为传输层用了**适配器模式**把三种通信方式的差异藏了起来。 + +![](img/03-2.png) + +**关键设计点**: + +1. **统一接口 `RosTransport`**(`transport/transport.ts`):定义了 13 个方法(publish/subscribe/callService/sendActionGoal/listTopics……)。这是一份"承诺清单"。 +2. **三种实现都履行这份承诺**:每种模式写一个类去 `implements RosTransport`。上层只认接口、不认具体类。 +3. **工厂按配置选实现**(`transport/factory.ts`): + +```typescript +// 伪代码示意(真实现是动态 import 懒加载) +async function createTransport(config) { + switch (config.mode) { + case "rosbridge": return new RosbridgeTransport(config.rosbridge); + case "local": return new LocalTransport(config.local); + case "webrtc": return new WebRTCTransport(config.webrtc); + } +} +``` + +**这么设计的好处**:想换通信方式,只改工厂里 `new` 哪个类,**上层工具一行都不用动**。这就是"面向接口编程、实现可替换"。 + +> 📌 **实现状态(诚实说明)**:默认的 `rosbridge`(模式 B)是最完整的实现。`local`(A)和 `webrtc`(C)在项目文档里曾被标为"存根",但逐行读代码发现 **模式 C 其实是完整实现**(真用了 WebRTC 库)。详见 [code/30 的勘误](code/30-webrtc-transport.ts.md)。**以代码为准。** + +> 🔍 逐行详解:接口 [code/04](code/04-transport.ts.md)、工厂 [code/11](code/11-transport-factory.ts.md)、单例管理 [code/12](code/12-service.ts.md)、rosbridge 实现 [code/05–10](code/05-rosbridge-types.ts.md)。 + +--- + +## 六、能力自动发现:让 AI "认识"这台机器人 + +AI 怎么知道某台机器人有哪些话题可用?靠 **`rosclaw_discovery`** 这个 ROS2 节点(在 `ros2_ws/src/rosclaw_discovery/`,Python 写的): + +1. 它扫描当前 ROS2 环境里所有活跃的话题和服务; +2. 通过一个 `GetCapabilities` 服务接口把结果暴露出来; +3. 插件的 `context/robot-context.ts` 在每次 AI 会话开始前调用它(或调用标准的 `/rosapi/topics`),把能力清单**注入到 AI 的系统提示**。 + +这样同一个插件接到不同机器人时,AI 都能自动"认识"对方的能力,而不用为每台机器人改代码。 + +--- + +## 七、一次完整交互的数据流(高层版) + +把前面所有机制串起来,一条命令的高层流动是: + +![](img/03-3.png) + +> 这张图是"高层"的。**想看精确到"哪个文件、哪个函数、第几行"的版本**,请读配套的 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)——它把上面每个箭头都落实到了真实代码位置。 + +--- + +## 八、用到的设计模式(点名) + +读源码时你会反复遇到这几个经典设计模式,先认个脸: + +| 模式 | 在哪体现 | 解决什么 | +|---|---|---| +| **适配器(Adapter)** | 传输层三种实现同一个 `RosTransport` 接口 | 隐藏底层差异,可替换 | +| **单例(Singleton)** | `service.ts` 全程序共享一个 `transport` | 所有工具共用同一条连接 | +| **工厂(Factory)** | `factory.ts` 按配置造对应传输层 | 把"造哪种对象"的判断集中一处 | +| **观察者 / 回调** | `onConnection`、话题订阅回调 | 状态变化时通知关心的人 | + +这些模式不是 RosClaw 独创,而是软件工程的通用套路。认识它们后,你会发现"原来很多项目都长这样"。 + +--- + +## 九、本章小结 + +- 系统三层:消息层 / AI 网关层(插件挂这)/ ROS2 层。 +- 插件六模块:地基(config、plugin-api)+ 连接(service、transport)+ 四种对接方式(tools、safety、context、commands)。 +- **四种对接 AI 的方式**:工具(AI 调)、钩子-工具前(安全拦截)、钩子-会话前(能力注入)、命令(用户打)——这是全章最该记住的。 +- **传输层适配器模式**:统一接口 + 三种可替换实现 + 工厂选择,是项目最漂亮的设计。 + +--- + +**下一步** → [第四章:核心代码导读](04-核心代码导读.md):从架构走进源码,看每个文件的概貌;想逐行精读就去 [code/ 系列](code/README.md)。 + + + +--- + + +# 第四章:核心代码导读(鸟瞰版) + +> 本章是源码的**鸟瞰索引**:用一张全局文件结构图 + 每个文件的一句话概述,帮你建立"哪个文件干什么、该按什么顺序读"的全貌。 +> +> **本章只做鸟瞰,不逐行。** 真正的"显微镜"——每一行、每一个符号都拆开讲、第一次出现的语法都补课——在配套的 [code/ 逐行详解系列](code/README.md)(共 33 篇)。本章的每个文件都会链到它对应的逐行详解篇。 +> +> 这样分工是为了**各取所需**:想先看大图,读本章;想吃透某个文件,点进 code/ 对应篇。 + +--- + +## 一、两种读法,先选一个 + +读源码有两条路,按你的风格选: + +| | 路线 A:先看主线 | 路线 B:按依赖层层读 | +|---|---|---| +| 适合 | 想先搞懂"一条命令怎么流过整个项目" | 想从地基开始、一块块吃透 | +| 怎么走 | 先读 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md),跟着一条命令跳过 8 个文件,对全局有感觉后再钻细节 | 按本章下面的"推荐阅读顺序",从 `plugin-api.ts` 一路读到 canvas | +| 优点 | 不迷路,始终知道"我在整体的哪一站" | 系统、扎实,后一篇都建立在前一篇之上 | + +> 💡 **强烈建议先花 10 分钟读 [code/00](code/00-一条命令的旅程.md)**。它是整个 code/ 系列的"龙头"——把一条命令从聊天框到机器人电机的全过程,精确标注到"哪个文件、哪个函数、第几行",并链到每一篇逐行详解。有了这条主线打底,无论你之后怎么读都不会迷路。 + +--- + +## 二、全局文件结构 + +``` +extensions/ +├── openclaw-plugin/ ← 核心插件(控制机器人) +│ ├── openclaw.plugin.json ← 插件元数据与配置 Schema +│ ├── src/ +│ │ ├── index.ts ← 插件入口,统一注册所有模块 +│ │ ├── plugin-api.ts ← OpenClaw API 的 TypeScript 类型声明("合同") +│ │ ├── config.ts ← 配置 Schema(Zod),所有配置项和默认值 +│ │ ├── service.ts ← transport 单例,连接生命周期管理 +│ │ │ +│ │ ├── transport/ ← 传输层抽象(三种模式) +│ │ │ ├── transport.ts ← RosTransport 统一接口 +│ │ │ ├── types.ts ← 所有 Option/Result/Config 类型 +│ │ │ ├── factory.ts ← 按模式动态加载适配器 +│ │ │ ├── rosbridge/ ← 模式 B:WebSocket(默认,最完整) +│ │ │ │ ├── client.ts ← WebSocket 连接、重连、消息路由 🔥 +│ │ │ │ ├── adapter.ts ← 把 RosTransport 接口适配到 client +│ │ │ │ ├── topics.ts ← 话题发布/订阅 +│ │ │ │ ├── services.ts ← 服务调用 +│ │ │ │ ├── actions.ts ← 动作目标发送/取消 +│ │ │ │ └── types.ts ← rosbridge 协议消息结构 +│ │ │ ├── local/ ← 模式 A:直连 DDS(需 rclnodejs) +│ │ │ │ ├── transport.ts ← rclnodejs 包装实现 +│ │ │ │ ├── conversion.ts ← JS 对象 ↔ ROS2 消息类型转换 +│ │ │ │ └── entities.ts ← 节点/发布者/订阅者缓存 +│ │ │ └── webrtc/ ← 模式 C:WebRTC P2P(实为完整实现) +│ │ │ ├── transport.ts ← WebRTC 传输主体(全项目最长) +│ │ │ ├── signaling-client.ts ← 信令客户端 +│ │ │ └── signaling-types.ts ← 信令协议类型 +│ │ │ +│ │ ├── tools/ ← 注册给 AI 的 8 个工具 +│ │ │ ├── index.ts ← 汇总注册入口 +│ │ │ ├── ros2-publish.ts ← ros2_publish +│ │ │ ├── ros2-subscribe.ts ← ros2_subscribe_once +│ │ │ ├── ros2-service.ts ← ros2_service_call +│ │ │ ├── ros2-action.ts ← ros2_action_goal +│ │ │ ├── ros2-param.ts ← ros2_param_get + ros2_param_set +│ │ │ ├── ros2-introspect.ts← ros2_list_topics +│ │ │ └── ros2-camera.ts ← ros2_camera_snapshot +│ │ │ +│ │ ├── safety/validator.ts ← before_tool_call 安全拦截钩子 +│ │ ├── context/robot-context.ts ← before_agent_start 能力注入钩子 +│ │ └── commands/ +│ │ ├── estop.ts ← /estop 紧急停止 +│ │ └── transport.ts ← /transport 运行时切换传输模式 +│ └── skills/ ← 技能描述文档(纯文本,供 AI 读取) +│ +└── openclaw-canvas/ ← 实时仪表盘扩展(Phase 3,占位) + ├── index.ts ← 仅打印一行 log + └── README.md ← 详细设计文档 +``` + +--- + +## 三、按模块速览(每个文件一句话 + 逐行详解链接) + +### 地基:契约与配置 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `plugin-api.ts` | OpenClaw 与插件之间的"合同"——类型声明,无逻辑 | [code/01](code/01-plugin-api.ts.md) | +| `config.ts` | 用 Zod 定义所有配置项、默认值、校验 | [code/02](code/02-config.ts.md) | + +简化示意(`index.ts` 入口): + +```typescript +export default { + id: "rosclaw", + name: "RosClaw", + register(api) { + const config = parseConfig(api.pluginConfig ?? {}); + registerService(api, config); // 先建连接 + registerTools(api); // 再摆工具 + registerSafetyHook(api, config); + registerRobotContext(api, config); + registerEstopCommand(api, config); + registerTransportCommand(api, config); + }, +}; +``` + +### 连接:传输层抽象 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `transport/transport.ts` | `RosTransport` 统一接口(13 个方法的承诺清单) | [code/04](code/04-transport.ts.md) | +| `transport/types.ts` | 各种 Option/Result/Config 数据结构 | [code/03](code/03-transport-types.ts.md) | +| `transport/factory.ts` | 按 `mode` 动态加载对应适配器 | [code/11](code/11-transport-factory.ts.md) | +| `service.ts` | 管理唯一的 transport 单例 + 连接生命周期 | [code/12](code/12-service.ts.md) | + +接口长这样(简化): + +```typescript +export interface RosTransport { + connect(): Promise; + disconnect(): Promise; + publish(options: PublishOptions): void; + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription; + callService(options: ServiceCallOptions): Promise; + // ……共 13 个方法,三种模式都实现它…… +} +``` + +### 模式 B:rosbridge(默认,最完整) + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `rosbridge/types.ts` | rosbridge 协议消息的类型 | [code/05](code/05-rosbridge-types.ts.md) | +| `rosbridge/client.ts` | WebSocket 连接、重连、消息路由(**最核心最难**) | [code/06](code/06-rosbridge-client.ts.md) 🔥 | +| `rosbridge/topics.ts` | 话题发布/订阅 | [code/07](code/07-rosbridge-topics.ts.md) | +| `rosbridge/services.ts` | 服务调用(按 id 配对响应) | [code/08](code/08-rosbridge-services.ts.md) | +| `rosbridge/actions.ts` | 动作目标发送/取消/进度 | [code/09](code/09-rosbridge-actions.ts.md) | +| `rosbridge/adapter.ts` | 把以上组装成 `RosTransport` 实例 | [code/10](code/10-rosbridge-adapter.ts.md) | + +### 工具层:AI 的 8 个动作按钮 + +| 文件 | 工具 | 逐行详解 | +|---|---|---| +| `tools/index.ts` | 汇总注册 + 工具通用结构 | [code/14](code/14-tools-index.ts.md) | +| `ros2-publish.ts` | `ros2_publish` | [code/15](code/15-ros2-publish.ts.md) | +| `ros2-subscribe.ts` | `ros2_subscribe_once` | [code/16](code/16-ros2-subscribe.ts.md) | +| `ros2-service.ts` | `ros2_service_call` | [code/17](code/17-ros2-service.ts.md) | +| `ros2-action.ts` | `ros2_action_goal` | [code/18](code/18-ros2-action.ts.md) | +| `ros2-param.ts` | `ros2_param_get/set` | [code/19](code/19-ros2-param.ts.md) | +| `ros2-introspect.ts` | `ros2_list_topics` | [code/20](code/20-ros2-introspect.ts.md) | +| `ros2-camera.ts` | `ros2_camera_snapshot` | [code/21](code/21-ros2-camera.ts.md) | + +### 钩子与命令 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `safety/validator.ts` | `before_tool_call` 钩子,速度超限则拦截 | [code/22](code/22-safety-validator.ts.md) | +| `context/robot-context.ts` | `before_agent_start` 钩子,注入机器人能力 | [code/23](code/23-robot-context.ts.md) | +| `commands/estop.ts` | `/estop` 急停(绕过 AI 发零速) | [code/24](code/24-commands-estop.ts.md) | +| `commands/transport.ts` | `/transport` 运行时切换传输模式 | [code/25](code/25-commands-transport.ts.md) | + +### 模式 A / C 与第二扩展 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `transport/local/*` | 模式 A:rclnodejs 直连 DDS(转换/缓存) | [code/26–28](code/26-local-transport.ts.md) | +| `transport/webrtc/*` | 模式 C:WebRTC P2P(信令/握手,实为完整实现) | [code/29–31](code/29-webrtc-signaling-types.ts.md) | +| `openclaw-canvas/` | 第二个扩展:实时仪表盘(占位中) | [code/32](code/32-openclaw-canvas.md) | + +--- + +## 四、推荐阅读顺序 + +code/ 系列**按依赖关系**编排(先读被依赖的底层,再读上层),与下面顺序一致: + +``` +0. code/00 一条命令的旅程 ← 先读这条主线,建立全局观 +1. plugin-api.ts 理解 OpenClaw 契约(AgentTool、钩子) +2. config.ts 所有配置项和默认值 +3. transport/types.ts 传输层数据结构 +4. transport/transport.ts RosTransport 统一接口 +5. rosbridge/types.ts rosbridge 协议消息格式 +6. rosbridge/client.ts WebSocket 连接/重连/路由(最难)🔥 +7. rosbridge/topics.ts 话题发布/订阅 +8. rosbridge/services.ts 服务调用的 pending 模式 +9. rosbridge/actions.ts 动作目标与 feedback +10. rosbridge/adapter.ts 组装成 RosTransport +11. transport/factory.ts 工厂 +12. service.ts 单例与生命周期 +13. index.ts 启动流程总览 +14-21. tools/ 8 个工具(先 publish 学通用结构) +22. safety/validator.ts 安全拦截钩子 +23. context/robot-context.ts 能力注入、并行、缓存、降级 +24-25. commands/ /estop、/transport +26-28. transport/local/ 模式 A +29-31. transport/webrtc/ 模式 C +32. openclaw-canvas/ 第二扩展 +``` + +> 完整的进度表、语法学习地图(上百个 TypeScript 语法点 → 在哪篇首次讲)在 [code/ 系列总览](code/README.md)。 + +--- + +## 五、为什么把"逐行"单独放到 code/? + +你可能注意到本章很"薄"——这是**有意的**。本章和 code/ 系列分工明确: + +- **本章(鸟瞰)**:回答"有哪些文件、各干什么、怎么连、按什么顺序读"。看完心里有张地图。 +- **code/ 系列(显微镜)**:回答"这一行到底什么意思、这个符号是什么语法"。是给"新手中的新手"逐行精读用的,每个新语法都有「语法小课堂」。 + +如果把代码比作一座城市:本章是**地图**,code/ 是**逐街逐户的导览**。先看地图不迷路,再逐户细看才扎实。 + +--- + +**下一步** → [第五章:完整实战教程](05-完整实战教程.md):亲手把仿真机器人跑起来、用一句话让它移动。或直接进 [code/ 逐行详解系列](code/README.md)。 + + + +--- + + +# 第五章:完整实战教程——从零到用网页端控制机器人 + +> 本章是**唯一的动手实战章**,手把手带你走完整条链路,并附进阶练习。它合并、去重了原先三篇相互重叠的教程,按"由浅入深"重排成一条主线: +> +> **先用裸协议(无 AI)跑通机器人 → 再叠加 OpenClaw + AI → 最后做进阶练习。** +> +> 这样安排是有教学用意的:**先理解底层协议、亲手发 JSON 让机器人动**,你对"AI 背后到底发生了什么"会有切身体感;之后再加上 AI,就不会觉得它是黑魔法。 + +--- + +## 本章地图 + +| 部分 | 你会做什么 | 需要 AI 吗 | +|---|---|---| +| **第 0 部分** | 检查系统环境、准备账号 | — | +| **第 1 部分** | 用 Docker 跑起 ROS2 仿真,**亲手发裸 JSON** 让机器人移动 | ❌ 不需要 | +| **第 2 部分** | 安装并初始化 OpenClaw,打开网页聊天 | — | +| **第 3 部分** | 把 RosClaw 插件装进 OpenClaw 并配置 | — | +| **第 4 部分** | 连上机器人,**用自然语言**指挥它 | ✅ | +| **第 5 部分** | 进阶练习(改安全限制、加工具、对比模式……) | 部分 | + +> **只想最快看到效果?** 做完 **第 0 + 第 1 部分** 就能让仿真机器人动起来(不用 AI、不用 OpenClaw)。想体验"发一句话控制机器人"的完整魔法,继续第 2–4 部分。 + +--- + +# 第 0 部分:前置与系统自检 + +## 0.1 系统要求 + +| 项目 | 最低要求 | +|---|---| +| 操作系统 | Ubuntu 20.04+ / macOS 13+ / Windows 11(WSL2)| +| 内存 | 8 GB RAM | +| 磁盘 | 10 GB 可用空间 | +| 网络 | 能访问 GitHub、Docker Hub | +| 浏览器 | Chrome / Edge / Firefox 等现代浏览器 | + +## 0.2 检查工具链 + +打开终端,逐条运行,确认每条都有合理输出: + +```bash +node -v # 需要 v20 以上,如 v22.3.0 +pnpm -v # 需要 9 以上,如 9.15.4 +docker --version # 如 Docker version 24.x.x +docker compose version # 注意是 docker compose(带空格),如 v2.x.x +git --version # 如 git version 2.x.x +``` + +### 如果某项没装 + +- **Node.js 20+**:去 [nodejs.org](https://nodejs.org) 下 LTS,或用 nvm: + ```bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + # 重启终端后 + nvm install 20 && nvm use 20 + ``` +- **pnpm**:`npm install -g pnpm` +- **Docker Desktop**:去 [docker.com](https://www.docker.com/products/docker-desktop) 下载安装,装完启动 Docker Desktop 应用。 + +## 0.3 准备账号(第 4 部分才需要) + +- **Anthropic API Key**(OpenClaw 的 AI 功能需要):在 https://console.anthropic.com 注册并生成。 + > 也可用 OpenAI Key,但本教程以 Anthropic(Claude)为例。 + +> 第 1 部分(裸协议)**不需要任何账号或 Key**,可以先跳过 0.3。 + +--- + +# 第 1 部分:ROS2 仿真 + 裸协议验证(不需要 AI) + +> **本部分的成功标准**:在终端发一条 JSON 消息,仿真里的机器人向前移动。 +> +> 这一步**完全不涉及 AI、不涉及 OpenClaw 插件**——只有"你 → rosbridge → 机器人"。目的是让你亲眼看到 rosbridge 协议是怎么驱动机器人的。理解了这层,第 4 部分的 AI 不过是"自动帮你发这些 JSON"而已。 + +## 1.1 克隆仓库 + +```bash +git clone https://github.com/PlaiPin/rosclaw.git +cd rosclaw +``` + +验证:`ls` 应能看到 `docker`、`extensions`、`learn`、`ros2_ws`、`package.json` 等。 + +## 1.2 安装 Node 依赖并类型检查 + +```bash +pnpm install +``` + +这会装好 `extensions/` 下两个包的全部依赖。 + +**✅ 检查点:类型检查** + +```bash +pnpm typecheck +``` + +期望:没有任何错误、安静返回。看到两个包(openclaw-plugin、openclaw-canvas)都执行了 `tsc --noEmit` 且无报错即通过。 + +> 若报错,最常见是 Node 版本不够:`node -v` 必须 ≥ v20。 + +## 1.3 构建 ROS2 Docker 镜像 + +```bash +cd docker +docker compose build ros2 +``` + +第一次构建较慢(5–15 分钟,要拉 ROS2 基础镜像)。看到最后出现: + +``` +=> naming to docker.io/rosclaw/ros2:latest +``` + +即构建成功。 + +> **只构建 ros2 服务**:`docker-compose.yml` 里还有个 `rosclaw` 服务,它依赖尚未合并到仓库的 `packages/` 目录,会报错。本部分只需要 `ros2`。 + +**构建失败排查**: +- 网络超时(国内拉镜像慢):重跑 `docker compose build ros2`,Docker 会断点续传;或配置镜像加速(见文末排错指南)。 +- 磁盘不足:`df -h /`(需 ≥ 5GB),`docker system prune` 清缓存。 + +## 1.4 启动仿真 + +```bash +docker compose up ros2 +``` + +终端持续输出日志,**等出现这一行**(约 30–60 秒): + +``` +ros2-ros2-1 | [INFO] [rosbridge_websocket_launch]: Rosbridge WebSocket server started on port 9090 +``` + +看到 `port 9090` 就绪。**保持这个终端开着**,另开一个新终端做后续步骤。 + +## 1.5 验证 rosbridge 端口 + +新终端里: + +```bash +curl -s --max-time 3 http://localhost:9090 +``` + +期望报这类错(都说明端口通了——rosbridge 是 WebSocket,不接受普通 HTTP): + +``` +curl: (56) Recv failure: Connection reset by peer +# 或 +curl: (1) Received HTTP/0.9 when not allowed +``` + +> 若是 `curl: (7) Failed to connect`,说明容器还没起好,等几秒再试。 + +## 1.6 用裸 JSON 控制机器人(本部分高潮) + +安装 `websocat`(命令行 WebSocket 客户端): + +```bash +# macOS +brew install websocat +# Linux +curl -L https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl -o /usr/local/bin/websocat && chmod +x /usr/local/bin/websocat +# Windows(scoop) +scoop install websocat +``` + +> **不想装 websocat?** 用浏览器替代:F12 打开开发者工具,在 Console 粘贴: +> ```javascript +> const ws = new WebSocket('ws://localhost:9090'); +> ws.onopen = () => ws.send(JSON.stringify({op:'call_service', service:'/rosapi/topics'})); +> ws.onmessage = (e) => console.log(JSON.parse(e.data)); +> ``` +> 看到话题列表打印即成功。 + +### 第 ① 步:先列出话题(最简单的连通测试) + +```bash +websocat ws://localhost:9090 +``` + +连上后光标停在空行,粘贴并回车: + +```json +{"op":"call_service","service":"/rosapi/topics"} +``` + +期望几秒内返回包含 `/cmd_vel`、`/odom` 等的话题列表。**看到这个就说明 rosbridge ↔ ROS2 通了。** + +### 第 ② 步:订阅里程计(用来确认机器人真的在动) + +保持上面的连接,粘贴: + +```json +{"op":"subscribe","topic":"/odom","type":"nav_msgs/msg/Odometry","queue_length":1} +``` + +### 第 ③ 步:发布速度指令(**新开一个终端**再连一次) + +```bash +websocat ws://localhost:9090 +``` + +发送(线速度 0.3 m/s 向前): + +```json +{"op":"publish","topic":"/cmd_vel","type":"geometry_msgs/msg/Twist","msg":{"linear":{"x":0.3,"y":0.0,"z":0.0},"angular":{"x":0.0,"y":0.0,"z":0.0}}} +``` + +### 第 ④ 步:观察 + +切回**订阅 `/odom` 的那个终端**,你应看到持续涌入的里程计数据,`position.x` 在不断增大——**机器人正在向前移动!** + +### 第 ⑤ 步:停下机器人 + +在发指令的终端发零速: + +```json +{"op":"publish","topic":"/cmd_vel","type":"geometry_msgs/msg/Twist","msg":{"linear":{"x":0.0,"y":0.0,"z":0.0},"angular":{"x":0.0,"y":0.0,"z":0.0}}} +``` + +> 🎯 **你刚刚亲手做了 RosClaw 在底层做的事**:拼一条 rosbridge JSON、发给 rosbridge、机器人响应。第 4 部分的 AI,本质就是自动帮你生成并发送这些 JSON。对照 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md) 看代码里是怎么走的,会非常有感觉。 + +### ✅ 第 1 部分成功标准 + +| 检查点 | 命令 | 成功标志 | +|---|---|---| +| 依赖安装 | `pnpm install` | 无报错 | +| 类型检查 | `pnpm typecheck` | 无错误 | +| 镜像构建 | `docker compose build ros2` | 出现 `naming to rosclaw/ros2:latest` | +| 仿真启动 | `docker compose up ros2` | 日志出现 `port 9090` | +| 端口可达 | `curl localhost:9090` | 报 WebSocket 相关错误 | +| ROS2 响应 | 发 `/rosapi/topics` | 返回含 `/cmd_vel` 的列表 | +| **机器人移动** | 发布 `/cmd_vel` | `/odom` 的 `position.x` 持续增大 | + +完成第 1 部分后,如果只想验证底层就到此为止;想体验完整 AI 链路,继续往下。 + +--- + +# 第 2 部分:安装并初始化 OpenClaw + +> 从这里开始进入"AI 链路"。我们让浏览器网页聊天框 → 本机 OpenClaw 网关 → AI 这条线先通起来。**网页聊天内置在网关里,不需要注册任何消息平台账号或 Bot。** + +## 2.1 安装 OpenClaw + +```bash +curl -fsSL https://openclaw.ai/install.sh | bash +``` + +它会检测系统、安装/更新 Node(需 22.19+)、装好 `openclaw` CLI。装完**重开终端**刷新 PATH,验证: + +```bash +openclaw --version # 期望输出版本号 +``` + +> **Windows**:PowerShell(管理员)跑 `iwr -useb https://openclaw.ai/install.ps1 | iex`,之后在 WSL2 里操作。 + +## 2.2 初始化 + +```bash +openclaw onboard --install-daemon +``` + +`--install-daemon` 把 OpenClaw 注册为开机自启的系统服务。向导按如下回答: + +``` +? Choose your AI provider → Anthropic (Claude) +? Anthropic API Key → 粘贴你的 sk-ant-... Key +? Choose a model → claude-sonnet-4-6(或最新 Sonnet) +? Gateway port → 回车(默认 3000) +? Enable web dashboard → Yes ← 关键!网页聊天就靠它 +``` + +> **务必把 `Enable web dashboard` 选 `Yes`**——网页聊天界面内置在 Dashboard 里,是本教程和机器人对话的入口。 + +初始化完跑一下体检: + +```bash +openclaw doctor # 期望所有项都是绿色 ✓ +``` + +## 2.3 启动网关并验证 + +```bash +openclaw gateway start +# 或(装了 daemon 时) +openclaw gateway status # 期望 Gateway is running (PID: xxxxx) +``` + +浏览器打开 `http://localhost:3000`,应看到 OpenClaw Dashboard。 + +**✅ 阶段验证**:浏览器能打开 `http://localhost:3000` 并显示 Dashboard。 + +## 2.4 打开网页聊天,验证 AI + +进入 Dashboard 后,在左侧导航找到 **Chat** 入口点进去(首次可能提示创建会话,按默认建一个)。在聊天框输入: + +``` +hello +``` + +几秒内 AI 应回复你——说明"网页 → 网关 → AI"这条线通了。 + +> 没回复?看日志排查:`openclaw logs --tail 50`。常见原因:API Key 填错/额度用尽。 + +**✅ 阶段验证**:网页聊天框发 `hello`,几秒内收到 AI 回复。 + +--- + +# 第 3 部分:安装并配置 RosClaw 插件 + +## 3.1 确认仓库已克隆 + 装依赖 + +你在第 1 部分已经 `git clone` 和 `pnpm install` 过了。确认一下: + +```bash +ls /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin/ +# 应看到:openclaw.plugin.json package.json src/ skills/ ... +``` + +(路径换成你的实际仓库位置。) + +## 3.2 把插件装进 OpenClaw + +```bash +openclaw skills install /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin +openclaw skills list # 期望看到 rosclaw 出现在列表 +``` + +> 若命令不支持本地路径,手动复制: +> ```bash +> cp -r /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin ~/.openclaw/skills/rosclaw +> ``` + +## 3.3 配置插件 + +打开 `~/.openclaw/config.json5`,加上 `plugins` 部分(`channels.web` 是第 2 部分留下的,保持): + +```json5 +{ + channels: { + web: { enabled: true } + }, + + plugins: { + rosclaw: { + enabled: true, + config: { + transport: { mode: "rosbridge" }, + rosbridge: { + url: "ws://localhost:9090", // Docker 暴露的 rosbridge 地址 + reconnect: true + }, + robot: { name: "TurtleBot3", namespace: "" }, + safety: { + maxLinearVelocity: 0.5, // 仿真用保守速度 + maxAngularVelocity: 1.0 + } + } + } + } +} +``` + +保存后重启网关并看日志: + +```bash +openclaw gateway restart +openclaw logs --follow +``` + +应看到类似 `RosClaw plugin loaded successfully`。按 `Ctrl+C` 退出日志跟踪。 + +**✅ 阶段验证**:`openclaw logs` 出现 `RosClaw plugin loaded successfully`。 + +--- + +# 第 4 部分:连接并下达自然语言指令 + +## 4.1 确保仿真在运行 + +如果第 1 部分的仿真还开着就行;否则重新启动(这次用后台模式 `-d`): + +```bash +cd /home/agiuser/data/ROSCLAW/docker +docker compose up -d ros2 +docker compose logs ros2 | grep "port 9090" # 确认就绪 +``` + +## 4.2 确认插件连上了机器人 + +```bash +openclaw logs | grep -i "transport" +# 期望看到:ROS2 transport status: connected +``` + +**✅ 阶段验证**:`openclaw logs` 出现 `ROS2 transport status: connected`。 + +## 4.3 发出第一条自然语言指令(完整链路的高潮) + +回到浏览器网页聊天(`http://localhost:3000` 的 Chat),发送: + +``` +向前走 1 米 +``` + +**预期过程**(约 5–10 秒): +1. AI 回复:"好的,我来控制机器人向前移动 1 米。" +2. AI 调用 `ros2_publish` 工具,向 `/cmd_vel` 发速度指令; +3. AI 确认:"已完成。" + +**后台验证**(另开终端): + +```bash +docker exec -it docker-ros2-1 bash -c "source /opt/ros/jazzy/setup.bash && ros2 topic echo /odom --once" +# 应看到 pose.pose.position.x 有非零值 +``` + +> 🎉 **见证一下**:第 1 部分你**亲手发 JSON** 让机器人动;这次你只说了句中文,AI **自动**帮你完成了同样的事。这就是 RosClaw 的价值。 + +## 4.4 试试更多指令 + +| 你发的消息 | AI 调用的工具 | 效果 | +|---|---|---| +| `向前走 1 米` | `ros2_publish` → `/cmd_vel` | 前进 | +| `左转 90 度` | `ros2_publish` → `/cmd_vel` | 左转 | +| `电池还有多少` | `ros2_subscribe_once` → `/battery_state` | 返回电量 | +| `现在在哪里` | `ros2_subscribe_once` → `/odom` | 返回坐标 | +| `有哪些可以控制的话题` | `ros2_list_topics` | 返回话题列表 | +| `/estop` | 直接发零速(绕过 AI) | 立即停止 | + +## 4.5 查看 AI 的工具调用过程(可选) + +网页 Dashboard 里,你能在对话旁看到每条消息的完整推理与工具调用记录:AI 选了哪个工具、传了什么参数、工具返回了什么。**对照 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md),你能把界面上看到的每一步对上代码里的函数。** + +**✅ 阶段验证(核心)**:网页发「向前走 1 米」→ AI 回复 + `/odom` 数值变化。 + +--- + +# 第 5 部分:进阶练习 + +> 跑通链路后,下面的练习帮你从"会用"走向"会改"。每个练习都链到相关的逐行详解篇,便于深入。 + +## 练习一:理解并修改安全限制 + +**目标**:理解安全校验逻辑,并改速度限制。 + +1. 打开 [extensions/openclaw-plugin/src/config.ts](../extensions/openclaw-plugin/src/config.ts),找到 `safety` 的默认值 `maxLinearVelocity`。 +2. 打开 [extensions/openclaw-plugin/src/safety/validator.ts](../extensions/openclaw-plugin/src/safety/validator.ts),看校验逻辑(合速度 `Math.sqrt(x²+y²+z²)` 与上限比较)。 +3. **尝试**:把配置里的 `maxLinearVelocity` 改成 `0.1`,重启网关,再让机器人"向前走"(AI 会发约 0.5 的速度),观察日志是否出现拦截:`Blocked: linear velocity ... exceeds limit ...`。 + +**思考题**:安全校验目前只查 `/cmd_vel` 速度。若要给导航目标点也加边界检查,该改哪个文件的哪部分? + +> 🔍 深入:[code/22 · validator.ts](code/22-safety-validator.ts.md) + +## 练习二:添加一个新的 ROS2 工具 + +**目标**:给 AI 加一个 `ros2_list_services` 工具(列出可用服务)。 + +1. 参考 [tools/ros2-introspect.ts](../extensions/openclaw-plugin/src/tools/ros2-introspect.ts)(现有的 `ros2_list_topics`),看工具的结构:名字、参数 schema、`execute`。 +2. 在 `RosTransport` 接口([transport/types.ts](../extensions/openclaw-plugin/src/transport/types.ts) / [transport.ts](../extensions/openclaw-plugin/src/transport/transport.ts))参考 `listTopics()` 加一个 `listServices()`。 +3. 在 rosbridge 适配器里实现它——rosbridge 列服务用 `{"op":"call_service","service":"/rosapi/services"}`(对照列话题用的 `/rosapi/topics`)。 +4. 在 `tools/ros2-introspect.ts` 注册新工具,并确保 `tools/index.ts` 注册了它。 +5. `pnpm typecheck` 验证。 + +> 🔍 深入:[code/14 工具结构](code/14-tools-index.ts.md)、[code/20 introspect](code/20-ros2-introspect.ts.md)、[code/10 适配器](code/10-rosbridge-adapter.ts.md) + +## 练习三:理解三种部署模式的差异 + +**目标**:通过对比 Docker Compose 文件理解三种模式的实际部署。 + +1. 对比阅读: + - [docker/docker-compose.local.yml](../docker/docker-compose.local.yml)(模式 A) + - [docker/docker-compose.yml](../docker/docker-compose.yml)(模式 B) + - [docker/docker-compose.cloud.yml](../docker/docker-compose.cloud.yml) + [docker/docker-compose.robot.yml](../docker/docker-compose.robot.yml)(模式 C) +2. 找出每种模式里 `ROSCLAW_TRANSPORT_MODE` 的值,以及服务之间如何通信(同容器?不同容器?不同网络?)。 +3. 对照 [docs/architecture.zh.md](../docs/architecture.zh.md) 验证理解。 + +**思考题**:模式 C 为什么需要两个独立的 compose 文件(cloud + robot)? + +> 🔍 深入:模式 A [code/26–28](code/26-local-transport.ts.md)、模式 C [code/29–31](code/29-webrtc-signaling-types.ts.md) + +## 练习四:理解"技能(Skill)"与工具的区别 + +**目标**:搞懂"技能"概念。 + +1. 阅读 `extensions/openclaw-plugin/skills/` 下四个技能:`navigate-to`、`take-photo`、`check-status`、`pick-object` 的 `SKILL.md`。 +2. 理解区别: + - **工具**:单步操作(发一个话题、调一个服务)。 + - **技能**:多步流程(如"导航到目标点" = 发 Nav2 目标 + 等完成 + 订阅里程计确认)。 +3. **尝试**:参考现有技能,为"巡逻"场景草拟一个 `patrol/SKILL.md`,描述 AI 应如何依次执行多个导航目标。 + +## 练习五:探索 `rosclaw_discovery` 能力发现节点 + +**目标**:理解能力自动发现机制。 + +仿真运行时,进入 ROS2 容器: + +```bash +docker exec -it docker-ros2-1 bash +source /opt/ros/jazzy/setup.bash +ros2 topic list # 当前话题 +ros2 service list | grep rosclaw # 发现服务是否就绪 +ros2 service call /rosclaw/get_capabilities rosclaw_msgs/srv/GetCapabilities # 手动调用 +``` + +对比输出,理解插件如何把这些信息注入给 AI(对应 [code/23 robot-context](code/23-robot-context.ts.md))。 + +--- + +# 排错指南(合并去重) + +### 网页端打不开 / 聊天框不显示 + +```bash +openclaw gateway status # 1. 网关在运行? +openclaw doctor # 2. web dashboard 已启用? +nc -zv localhost 3000 # 3. 端口没被占用? +``` +常见原因:onboard 时 `Enable web dashboard` 选了 No;3000 端口被占。 + +### 网页发消息后 AI 不回复 + +```bash +openclaw gateway status +openclaw logs --tail 50 +openclaw doctor # 确认 AI Key 正确 +``` +常见原因:Anthropic Key 填错/额度用尽;config.json5 语法错误(缺逗号、括号不匹配)导致网关没正常启动。 + +### 插件加载报错 + +```bash +openclaw logs | grep -i "error\|rosclaw" +``` + +| 报错 | 原因 | 解决 | +|---|---|---| +| `Cannot find module` | 插件依赖没装 | `cd 仓库 && pnpm install` | +| `Transport not initialized` | rosbridge 还没连上 | 确认 Docker 容器已启动 | +| `Connection to ws://localhost:9090 timed out` | rosbridge 没起来/端口不对 | 查 `docker compose logs ros2` | + +### AI 调用工具后说"失败"——可能被安全校验拦了 + +```bash +openclaw logs | grep -i "blocked\|safety\|velocity" +``` +看到 `Blocked: linear velocity X exceeds limit Y`,说明速度超过 `maxLinearVelocity`。临时解决:把它调大(如 1.0)并重启网关。 + +### `/estop` 发出后机器人没停 + +`/estop` 要求 transport 已连接: +```bash +openclaw logs | grep "transport status" +``` +若显示 `disconnected`,重启网关等重连。 + +### Docker 镜像构建卡在下载 + +ROS2 基础镜像约 1GB,国内慢。配置镜像加速:Docker Desktop → Settings → Docker Engine 添加: +```json +{ "registry-mirrors": ["https://mirror.ccs.tencentyun.com"] } +``` + +### `docker compose up ros2` 看不到 `port 9090` + +等待不够(ROS2 启动慢)。超过 3 分钟仍无,查错: +```bash +docker compose logs ros2 | grep -i error +``` + +### 容器起不来 + +```bash +docker compose logs ros2 +docker compose down +docker compose build ros2 --no-cache +docker compose up -d ros2 +``` + +--- + +# 完整成功标准 + +全部通过即为完整链路打通: + +| # | 验证项 | 命令/操作 | 成功标志 | +|---|---|---|---| +| 1 | 类型检查 | `pnpm typecheck` | 无错误 | +| 2 | 仿真起来 | `docker compose logs ros2 \| grep 9090` | 出现 `port 9090` | +| 3 | **裸协议控制** | websocat 发 `/cmd_vel` | `/odom` 的 `position.x` 增大 | +| 4 | OpenClaw 安装 | `openclaw --version` | 输出版本号 | +| 5 | Dashboard 可访问 | 浏览器开 `localhost:3000` | 正常显示 | +| 6 | 网页对话通 | 网页发 `hello` | 收到 AI 回复 | +| 7 | 插件加载 | `openclaw logs \| grep RosClaw` | `loaded successfully` | +| 8 | transport 连接 | `openclaw logs \| grep transport` | `status: connected` | +| **9** | **自然语言控制** | 网页发「向前走 1 米」 | **AI 回复 + odom 变化** | + +第 3 条是"裸协议里程碑",第 9 条是"完整链路里程碑"——两个都达到,你就走通了从底层协议到 AI 的全程。 + +--- + +## 下一步探索 + +- **接入消息平台**:在网页之外,再配 Telegram/WhatsApp/Discord 通道,就能用手机远程控制(把 `channels.web` 换成对应平台,配置方式类似)。 +- **尝试导航**:发「导航到坐标 2, 2」,测试 `ros2_action_goal` 工具(动作)。 +- **接真实机器人**:把 `rosbridge.url` 改成真机 IP,其余代码完全不变——这正是传输层抽象的好处。 +- **读源码**:对照 [第四章:核心代码导读](04-核心代码导读.md) 和 [code/ 逐行详解系列](code/README.md) 理解每一步背后的实现,尤其推荐先读 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)。 + +--- + +← 返回 [README 学习总览](README.md) | ← 上一章 [第四章:核心代码导读](04-核心代码导读.md) + + + +--- + diff --git "a/learn/.build/RosClaw-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" "b/learn/.build/RosClaw-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" new file mode 100644 index 0000000..b8fa97f --- /dev/null +++ "b/learn/.build/RosClaw-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" @@ -0,0 +1,9786 @@ +# RosClaw 核心代码导读 + +# 逐行详解系列 · 总览 + +> 这是为**新手中的新手**准备的"逐行 + 逐语法"代码详解系列,是对 [`learn/04-核心代码导读.md`](../04-核心代码导读.md) 的深度展开。 +> +> - 顶层 `04` 章是"鸟瞰"——讲设计意图、用简化代码。 +> - 本系列 `code/` 是"显微镜"——读**真实源码**,**每一行、每一个符号**都拆开讲,第一次出现的 TypeScript 语法都用「语法小课堂」单独补课。 +> +> **从哪开始**:🧭 **强烈建议先读 [00 · 一条命令的旅程](00-一条命令的旅程.md)**——它是全系列的"龙头",跟着一条命令(「向前走 1 米」)走过 8 个文件,精确到"哪个文件、哪个函数、第几行",并链到下面每一篇。有了这条主线打底,再按下表顺序逐篇读就不会迷路。 +> +> **怎么用**:按下表顺序一篇篇读。每篇结尾有「语法点回顾清单」,可当字典反查。讲解顺序遵循**依赖关系**(先读被依赖的底层,再读依赖它的上层),而不是文件名顺序。 + +--- + +## 流程主线(先读我) + +| # | 文档 | 内容 | 状态 | +|---|---|---|---| +| 00 | [一条命令的旅程](00-一条命令的旅程.md) | 跟踪一条命令端到端穿过 8 个文件,每跳标注 `文件:函数:行号` 并链入对应逐行详解——**全系列的导航主线** | ✅ | + +--- + +## 进度一览 + +图例:✅ 已完成 · ⬜ 待完成 · "行数"=源码行数(难度参考) + +### 第一部分:契约与配置(基础,无执行逻辑) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 01 | [plugin-api.ts](01-plugin-api.ts.md) | `src/plugin-api.ts` | ✅ | 138 | +| 02 | [config.ts](02-config.ts.md) | `src/config.ts` | ✅ | 73 | + +### 第二部分:传输层抽象(接口与数据结构) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 03 | [transport-types.ts](03-transport-types.ts.md) | `src/transport/types.ts` | ✅ | 119 | +| 04 | [transport.ts](04-transport.ts.md) | `src/transport/transport.ts` | ✅ | 70 | + +### 第三部分:rosbridge 实现(模式 B,唯一完整实现) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 05 | [rosbridge-types.ts](05-rosbridge-types.ts.md) | `src/transport/rosbridge/types.ts` | ✅ | 119 | +| 06 | [rosbridge-client.ts](06-rosbridge-client.ts.md) 🔥最难 | `src/transport/rosbridge/client.ts` | ✅ | 317 | +| 07 | [rosbridge-topics.ts](07-rosbridge-topics.ts.md) | `src/transport/rosbridge/topics.ts` | ✅ | 60 | +| 08 | [rosbridge-services.ts](08-rosbridge-services.ts.md) | `src/transport/rosbridge/services.ts` | ✅ | 41 | +| 09 | [rosbridge-actions.ts](09-rosbridge-actions.ts.md) | `src/transport/rosbridge/actions.ts` | ✅ | 81 | +| 10 | [rosbridge-adapter.ts](10-rosbridge-adapter.ts.md) | `src/transport/rosbridge/adapter.ts` | ✅ | 148 | + +### 第四部分:装配与启动 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 11 | [transport-factory.ts](11-transport-factory.ts.md) | `src/transport/factory.ts` | ✅ | 42 | +| 12 | [service.ts](12-service.ts.md) | `src/service.ts` | ✅ | 114 | +| 13 | [index.ts](13-index.ts.md) | `src/index.ts` | ✅ | 40 | + +### 第五部分:工具层(注册给 AI 的 8 个工具) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 14 | [tools-index.ts(含通用结构)](14-tools-index.ts.md) | `src/tools/index.ts` | ✅ | 21 | +| 15 | [ros2-publish.ts](15-ros2-publish.ts.md) | `src/tools/ros2-publish.ts` | ✅ | 39 | +| 16 | [ros2-subscribe.ts](16-ros2-subscribe.ts.md) | `src/tools/ros2-subscribe.ts` | ✅ | 50 | +| 17 | [ros2-service.ts](17-ros2-service.ts.md) | `src/tools/ros2-service.ts` | ✅ | 43 | +| 18 | [ros2-action.ts](18-ros2-action.ts.md) | `src/tools/ros2-action.ts` | ✅ | 47 | +| 19 | [ros2-param.ts](19-ros2-param.ts.md) | `src/tools/ros2-param.ts` | ✅ | 83 | +| 20 | [ros2-introspect.ts](20-ros2-introspect.ts.md) | `src/tools/ros2-introspect.ts` | ✅ | 29 | +| 21 | [ros2-camera.ts](21-ros2-camera.ts.md) | `src/tools/ros2-camera.ts` | ✅ | 53 | + +### 第六部分:钩子与命令 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 22 | [safety-validator.ts](22-safety-validator.ts.md) | `src/safety/validator.ts` | ✅ | 43 | +| 23 | [robot-context.ts](23-robot-context.ts.md) | `src/context/robot-context.ts` | ✅ | 192 | +| 24 | [commands-estop.ts](24-commands-estop.ts.md) | `src/commands/estop.ts` | ✅ | 40 | +| 25 | [commands-transport.ts](25-commands-transport.ts.md) | `src/commands/transport.ts` | ✅ | 171 | + +### 第七部分:另外两种传输模式(模式 A / C) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 26 | [local-transport.ts](26-local-transport.ts.md) | `src/transport/local/transport.ts` | ✅ | 375 | +| 27 | [local-conversion.ts](27-local-conversion.ts.md) | `src/transport/local/conversion.ts` | ✅ | 153 | +| 28 | [local-entities.ts](28-local-entities.ts.md) | `src/transport/local/entities.ts` | ✅ | 129 | +| 29 | [webrtc-signaling-types.ts](29-webrtc-signaling-types.ts.md) | `src/transport/webrtc/signaling-types.ts` | ✅ | 130 | +| 30 | [webrtc-transport.ts](30-webrtc-transport.ts.md) | `src/transport/webrtc/transport.ts` | ✅ | 516 | +| 31 | [webrtc-signaling-client.ts](31-webrtc-signaling-client.ts.md) | `src/transport/webrtc/signaling-client.ts` | ✅ | 196 | + +### 第八部分:第二个扩展 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 32 | [openclaw-canvas](32-openclaw-canvas.md) | `extensions/openclaw-canvas/` | ✅ | 14 | + +--- + +## 当前进度 + +- **全部完成:32 / 32 篇 🎉**(整个 RosClaw 项目——主插件 31 个源文件 + 第二个扩展 canvas——已逐行读完)。 +- 收官篇 [32 · openclaw-canvas](32-openclaw-canvas.md) 末尾有全系列总回顾与下一步建议。 +- 想反查某个语法点,用下面的「语法学习地图」。 + +### 交付批次记录 + +| 批次 | 包含文档 | 状态 | +|---|---|---| +| 第一批 | 01, 02 | ✅ | +| 第二批(合并入第一批前的确认) | — | — | +| 第三批 | 03, 04 | ✅ | +| 第四批 | 05, 06 | ✅ | +| 第五批 | 07, 08, 09 | ✅ | +| 第六批 | 10, 11 | ✅ | +| 第七批 | 12, 13 | ✅ | +| 第八批 | 14, 15, 16 | ✅ | +| 第九批 | 17, 18, 19 | ✅ | +| 第十批 | 20, 21 | ✅ | +| 第十一批 | 22, 23 | ✅ | +| 第十二批 | 24, 25 | ✅ | +| 第十三批 | 26, 27, 28 | ✅ | +| 第十四批 | 29, 30, 31 | ✅ | +| 第十五批 | 32 | ✅ 收官 🎉 | + +> 批次划分是计划,可能随内容长短微调;以上表"进度一览"的 ✅/⬜ 为准。 + +--- + +## 语法学习地图(已讲过的 TypeScript 语法 → 在哪篇首次出现) + +读到后面遇到忘了的语法,回这里查它在哪篇讲过。 + +### 注释与模块 +- 三种注释 `// /* */ /** */`、JSDoc、`@see` —— 01、05 +- `import { X }`(具名)/ `import X`(默认)/ `import type` —— 01、02、06 +- `import`(值,要 new/调用)vs `import type`(仅类型,运行时抹除)的判断标准 —— 10 +- 一条 import 混用值与类型:`import { 值, type 类型 } from …` —— 30 +- 动态 `import("...")`:执行到才加载、返回 Promise、按需加载 —— 11 +- `createRequire(import.meta.url)`:在 ESM 里借 CJS 的 `require` 加载老库(rclnodejs);`import.meta.url` 当前文件地址 —— 26 +- `export default`(默认导出,一文件至多一个、导入不带花括号)vs 具名 `export` —— 13 +- `export` 导出、`./` 当前目录、`../` 上级、内部导入写 `.js` 后缀 —— 01、04 + +### 类型系统基础 +- 基础类型 `string`/`number`/`boolean`/`void`/`unknown`/`null` —— 01、06 +- `interface`(对象形状)vs `type`(更万能) —— 01 +- 可选 `?`、数组 `[]`、`Record`、`Promise` —— 01 +- 联合 `|`、字面量类型、判别联合、类型收窄 —— 01、03 +- 交叉类型 `&` —— 06 +- 函数类型 `(参数) => 返回`、回调、函数重载 —— 01、03 +- 剩余参数 `function f(a, ...args: unknown[])`(收集任意多个尾部参数) —— 32 +- 极简局部接口:只声明真正用到的成员(vs 完整 `OpenClawPluginApi`) —— 32 +- `interface extends`(继承)、子接口收紧字段 —— 05 +- `class implements 接口`:承诺实现契约、TS 逐方法强制检查 —— 10 +- `never` 类型 + 穷举检查(`const _e: never = 判别变量`,漏分支编译期报错) —— 11 +- `any`(放弃类型检查的逃生类型,catch 里常见) —— 11 +- `type X = any` 给 any 起有意义的别名(自我文档化) —— 28 +- 工具类型 `Required`、`ReturnType` —— 06 +- `as`(类型断言)、`!`(非空断言)、`typeof 值`(值→类型) —— 06、02 +- 双重断言 `as unknown as X`(源/目标差太远时的强转) —— 09 +- `satisfies 类型`:验证对象符合某类型但保留精确类型(比 `as` 安全——核对而非强断言) —— 31 +- 索引签名 `[key: string]: unknown`(除已知字段外允许任意字符串键) —— 29 +- `string | null`(值可为 null)vs `?`(键可省略)的区别 —— 29 +- 索引访问类型 `TransportConfig["mode"]`(从对象类型里取某字段的类型,跟着源头自动变) —— 12 +- `as const`(锁成只读字面量类型)+ 从数组派生类型 `(typeof 数组)[number]` —— 25 +- 类型守卫 `function f(x): x is T`(运行时检查 + 让 TS 收窄类型) —— 25 +- 真值判断 `if (someValue)`(判空简写)、可空函数字段 `(() => void) | null` —— 07 + +### 值与变量 +- `const`/`let`/`var`、对象简写 `{a,b}`、数组解构 `[x,y]` —— 02、06 +- 模块级变量(写在文件最外层、全文件共享、程序运行期长存)——单例的朴素实现 —— 12 +- 先声明后赋值 `let x: T;` 再在各分支赋值(穷尽分支时可省 `default`) —— 12 +- 参数名加 `_` 前缀(`_ctx`)表示"故意不用此参数" —— 12 +- 对象解构 `const { X } = 模块/对象`(拆出某项) —— 11 +- 模板字符串 `` `${x}` ``、数字分隔符 `10_000` —— 06 +- `??`(空值合并)、`?.`(可选链)、默认参数 —— 06 + +### 运算与控制流 +- `===`/`!==`、`!`/`&&`/`||`、三元 `?:` —— 06 +- `if`、`switch`/`case`/`break`、`for...of`、`continue`、`return` —— 02、06 +- `++x`/`x++`、`Math.min`/`Math.pow`、`字符串.startsWith` —— 06 +- `**`(乘方)、`Math.sqrt`(平方根)、`Math.abs`(绝对值)、`数字.toFixed(2)`(保留小数) —— 22 +- 数组 `.map((元素,下标)=>…)`、`.push()`;箭头函数返回对象套圆括号 `({...})` —— 10 +- 数组 `.filter(判断函数)`(筛选)、`.length`(长度) —— 23 +- 数组 `.some(判断)`(有无任一满足)、`.find(判断)`(找第一个)、链式 `.filter().map()` —— 26 +- `Object.entries`(键值对数组)/`Object.keys`(键名数组)/`Map.values`(值),配 `for...of` 遍历 —— 26 +- 数组 `.includes`/`.join`/`.slice(1)`、字符串 `.includes`/`.split`、正则 `/\s+/`(按空白分割) —— 25 +- 解构剩余元素 `const [key, ...rest] = …`(处理"值里也含分隔符") —— 25 +- `{ ...对象 }` 展开浅拷贝(改副本不污染原对象)、`键 in 对象`(判断有无该键) —— 25 +- `Number(value)` 转数字、`Number.isNaN` 校验、`value === "true"` 转布尔、按 `typeof` 分支转换 —— 25 +- 字符串 `.endsWith` / `.slice(起,止)` / 负数下标"从末尾数";`对象["键名"]` 取属性 —— 10 +- 字符串拼装:`s += x`(追加)、`\n` 换行、多行模板字符串、`\\\`` 转义反引号、`.trim()` —— 23 +- `字符串.replace(/\/+$/, "")`:正则去掉结尾斜杠(URL 规整) —— 31 + +### 类与异步 +- `class`/`new`/实例/`this`/`constructor`/`private` —— 06 +- `static` 静态成员(类共享一份)+ 静态门闩(全进程只初始化一次) —— 26 +- 递归:函数调用自己处理"同类的子结构"(嵌套消息层层穿透) —— 27 +- 构造函数参数属性简写(参数前加 `private`/`public` 自动建字段) —— 07 +- `get 属性()`:取值器(getter),用时不加括号、像读属性但实时计算 —— 31 +- `Map`/`Set` 容器及其方法 —— 06 +- `Promise` 构造 `new Promise((resolve,reject)=>…)`、`async`/`await` —— 06 +- `async` 函数直接 `return` 一个 Promise(不在体内 await)、事件→Promise 桥接 —— 08 +- `setTimeout`/`clearTimeout`、`try/catch`、`throw`/`new Error` —— 02、06 +- `setInterval`/`clearInterval`:反复定时触发(vs setTimeout 一次);心跳保活 —— 31 +- `fetch(url, {method,headers,body})` 发 HTTP 请求;`res.ok`/`res.status`/`res.json()`/`res.text()` —— 31 +- `catch {}`(不接错误对象,出错也无所谓)vs `catch (err)`(要用错误) —— 23 +- `try/finally`(无论成败必执行的善后) —— 09 +- 并发门闩:布尔标志 + `try { 上闩… } finally { 抬闩 }` 防止异步操作重入 —— 12 +- **`Promise.all([...])`:多个异步操作并行、一起等,结果按序解构 `const [a,b,c]=…`** —— 23 +- 带 TTL 的缓存:`Date.now()` 时间戳 + `现在-timestamp`、返回 `content`(文本) + `details`(对象) —— 14、15 +- `params["键"] as 类型`:从 `Record` 取参数并断言(有 schema 校验兜底) —— 15 +- 竞速 Promise:一条路 `resolve`、另一条 `setTimeout`→`reject`,谁先到谁结束 + 双向清理 —— 16 +- 一个注册函数里多次 `api.registerTool`(一文件注册多个工具) —— 19 +- "某能力 = 调某约定服务"模式:模板字符串拼服务名 + 迁就服务形状(值包成数组、嵌套结构) —— 19(呼应 10 的 `/rosapi/topics`) +- 纯转发的值不必 `as` 断言(要用具体能力才断言) —— 19 +- 底层能力(如动作进度回调)在工具层可选择不用——务实取舍 —— 18 +- `Type.Object({})`:声明"无参数"工具(仍必须写 `parameters`) —— 20 +- base64:把二进制(图像)编码成纯文本以便走 JSON 协议;`ToolResult.content` 支持 `text`/`image` 两型 —— 21 + +### 钩子(OpenClaw hooks,按时机自动触发) +- `api.on(事件名, async (event, ctx) => {...})`:登记钩子,宿主到点自动触发(vs 工具被动调用) —— 22 +- `before_tool_call`:工具调用前拦截,返回 `{block,blockReason}` 拦下 / `void` 放行 —— 22 +- `before_agent_start`:对话开始前注入,返回 `{prependContext: 文本}` 塞进系统提示 —— 23 +- 钩子用返回值影响宿主行为(拦截 / 注入),是统一的机制 —— 22、23 + +### 命令(OpenClaw commands,用户直接打 `/xxx`) +- `api.registerCommand({ name, description, handler })`:登记命令,绕过 AI;`description` 写给人 —— 24 +- 命令 `handler(ctx)` 返回 `{ text }`:作为回复直接显示给用户 —— 24 +- 三种交互对照:工具(AI 调,返 content/details) / 钩子(宿主触发,返 block/prependContext) / 命令(用户打,返 text) —— 24 +- `ctx.args` 解析参数:`?? ""` + `.trim()` + `.split(/\s+/)` + 类型守卫校验 —— 25 +- 安全操作的 try/catch:成功确认、失败响亮告警、绝不静默(`/estop`) —— 24 + +> 早先预告的大语法(`Promise.all` 并行、`createRequire`/CommonJS 互操作)均已在第 23、26 篇讲完。至此本系列承诺补的语法点已全部覆盖。 + +### 读码方法论(比语法更值钱的几样) +- 看懂"分层":接口在中间、实现可替换(三种传输模式同实现一个 `RosTransport`) —— 04 + 10/26/30 +- 识别"模式":缓存(查→建→存)、Promise 包装回调、按 id 配对、引用计数清理 —— 反复出现 +- 对照"现状 vs 文档":发现并诚实指出代码与文档/注释的不一致(以代码为准) —— 21、30、31 +- 存根的正当价值:正确类型签名 + 诚实标注未实现(vs 假装实现) —— 32 + +--- + +## 阅读建议 + +1. **严格按顺序**:后一篇默认你已掌握前面讲过的语法,讲过的不再重复。 +2. **对照真实源码**:每篇开头有源文件链接,建议左边开源码、右边开详解对照看。 +3. **卡住就回查**:忘了某语法,用上面「语法学习地图」定位到首讲那篇。 +4. **第 06 篇(client.ts)是分水岭**:它最难、语法最密。吃透它,后面都是轻松的。建议反复读 `connect` 和 `handleMessage` 两个方法。 +5. **读完动手改一行**:挑个最感兴趣的方法,自己改一行、跑一下看会怎样——"读"之后"动手"才能把知识焊牢。 + +--- + +## 🎉 全系列已完成(32 / 32) + +从 01 `plugin-api.ts` 到 32 `openclaw-canvas`,整个 RosClaw 已逐行读完。收官总回顾见 [第 32 篇结尾](32-openclaw-canvas.md)。一路累计的上百个 TypeScript 语法点都收录在上面的「语法学习地图」里,可随时反查——它们是读**任何** TS 项目的通用功底。 + +← 返回 [核心代码导读(鸟瞰版)](../04-核心代码导读.md) + + + +--- + + +# 逐行详解 ⓪:一条命令的旅程(全系列主线 · 先读我) + +> **这是整个逐行详解系列的"龙头"文档,建议第一个读。** +> +> 前面的 01–32 篇是"显微镜"——每篇把一个源文件**逐行**拆开。但新手最容易迷路的不是某一行看不懂,而是**"这些文件之间到底怎么串起来的?我发一句话,代码里到底依次发生了什么?"** +> +> 本篇就回答这个问题:我们跟踪**一条命令**——用户在聊天框打「向前走 1 米」——看它如何穿过整个 RosClaw,最终让机器人动起来。**每一跳都精确标注"哪个文件、哪个函数、第几行、信息变成了什么形状",并链接到对应的逐行详解篇。** +> +> 读完它,你就有了一张"全局地图"。之后再按 01→32 钻进每个文件时,你随时知道"我现在在地图的哪个位置"。 + +--- + +## 怎么用这篇文档 + +- **第一次读**:从头顺读,不必点开每个链接,先在脑子里建立"一条命令流过 8 个站点"的整体印象。 +- **读 01–32 时**:每当你读某一篇有点"只见树木"的感觉,回到本篇,找到那个文件在主线中的位置,就知道它"上游是谁、下游是谁"。 +- **查地图**:文末有一张"全站点速查表",可随时反查。 + +> 本篇出现的所有行号都对照当前源码核实过。若你本地代码版本不同,行号可能有小幅偏移,但函数名和顺序不变。 + +--- + +## 先看全景:一条命令要过几道关 + +想象你在微信里对机器人说「**向前走 1 米**」。这句中文,要变成机器人电机的转动,中间隔着**两个世界**: + +- **"人话"的世界**:自然语言、聊天消息。 +- **"机器"的世界**:ROS2 的话题、二进制 DDS 消息、电机指令。 + +RosClaw 就是把前者翻译成后者的**翻译流水线**。这条流水线大致是: + +![](img/00-1.png) + +**注意信息的"形态"在一路上不断变化**(这是本篇最想让你记住的一点): + +| 阶段 | 信息长什么样 | +|---|---| +| 你发的 | 一句中文字符串 `"向前走 1 米"` | +| AI 产出的 | 一次"工具调用":工具名 + 参数对象 | +| 工具内部 | 一个 JavaScript 对象 `{ topic, type, msg }` | +| 传输层发出的 | 一条 rosbridge **JSON 文本** `{"op":"publish",...}` | +| WebSocket 上 | 一个网络数据帧(就是上面那段 JSON 的字节) | +| rosbridge 之后 | ROS2 的 **DDS 二进制消息** | + +**翻译,就是不断改变信息的形态、同时保持含义不变。** 下面逐站点细看。 + +--- + +## 站点 0(启动时就绪):插件装配 —— `index.ts` + +在你发任何消息**之前**,OpenClaw 启动时会加载 RosClaw 插件,调用它的 `register()` 函数。这一步把后面要用到的所有"零件"装好。 + +📍 **`src/index.ts` · `register()` · 第 17–39 行** + +```typescript +register(api: OpenClawPluginApi): void { + api.logger.info("RosClaw plugin loading..."); + const config = parseConfig(api.pluginConfig ?? {}); // 解析配置 + + registerService(api, config); // ① 建立 ROS2 连接(传输层) + registerTools(api); // ② 注册 8 个 AI 工具 + registerSafetyHook(api, config); // ③ 注册安全钩子 + registerRobotContext(api, config); // ④ 注册"能力注入"钩子 + registerEstopCommand(api, config); // ⑤ 注册 /estop 命令 + registerTransportCommand(api, config); // ⑥ 注册 /transport 命令 +} +``` + +- **打个比方**:`register` 像开店前的"备货 + 摆货架"。它本身不卖东西,只是把"连接"建好、把"工具"摆上货架、把"安检员"安排到门口。等顾客(你的消息)上门,这些零件才各司其职。 +- 这 6 个 `registerXxx` 就是插件的全部家当,**后面每一个站点都是其中之一被触发**。 +- 注意一个**关键顺序**:`registerService` 排第一——必须先把"和机器人的连接"建好,后面的工具才有"管子"可用。 + +> 🔍 逐行详解:[第 13 篇 · index.ts](13-index.ts.md)(插件入口、`export default`、"只注册不执行"的节奏) + +--- + +## 站点 1(会话开始前):告诉 AI "这台机器人能干什么" —— `robot-context.ts` + +你刚打开对话、还没说话时,就有一个钩子悄悄运行了:它**查出机器人当前有哪些话题/服务/动作,塞进 AI 的"脑子"里**。否则 AI 根本不知道该往哪个话题发消息。 + +📍 **`src/context/robot-context.ts` · `registerRobotContext()` · 第 26–49 行** + +```typescript +api.on("before_agent_start", async (_event, _ctx) => { + const capabilities = await discoverCapabilities(api, robotNamespace); // 查能力 + const context = buildRobotContext(robotName, robotNamespace, capabilities); // 拼成文本 + return { prependContext: context }; // 塞进 AI 系统提示 +}); +``` + +- **`before_agent_start`** 是个"钩子"——OpenClaw 在"AI 开始思考之前"自动触发它。返回 `{ prependContext: 文本 }`,这段文本就会被**插到 AI 的系统提示最前面**。 +- 那段文本长这样(大意):`这台机器人叫 TurtleBot3,可用话题有 /cmd_vel、/odom、/battery_state……`。AI 读了它,才知道"向前走"应该往 `/cmd_vel` 发。 + +**那"能力"是怎么查出来的?** 看 `discoverCapabilities`: + +📍 **`src/context/robot-context.ts` · `discoverCapabilities()` · 第 55–98 行** + +```typescript +const [topics, services, actions] = await Promise.all([ + transport.listTopics(), // 并行发起三个查询 + transport.listServices(), + transport.listActions(), +]); +``` + +- **`Promise.all`** 让三个查询**并行**跑、一起等结果(比一个个等快)。 +- 这三个 `transport.listXxx()` 最终会**调用一个 ROS2 服务**去问机器人。以 `listTopics` 为例,它落到适配器里: + +📍 **`src/transport/rosbridge/adapter.ts` · `listTopics()` · 第 102–112 行** + +```typescript +const response = await callService(this.client, "/rosapi/topics", {}, "rosapi/srv/Topics"); +``` + +- 看到了吗——**"查有哪些话题"本身也是一次"调用服务"**(服务名 `/rosapi/topics`)。这就和站点 6 之后的请求-响应走同一套机制了(后面会讲)。 +- 还有个贴心设计:结果会**缓存 60 秒**(第 60 行 `Date.now() - cache.timestamp < CACHE_TTL_MS`),避免每次对话都重新查一遍;查询失败也不会崩,而是**降级**成一份硬编码的默认能力(第 89–97 行)。 + +> 🔍 逐行详解:[第 23 篇 · robot-context.ts](23-robot-context.ts.md)(`before_agent_start`、`Promise.all`、TTL 缓存、降级兜底) + +--- + +## 站点 2(AI 的工作):把"人话"变成"工具调用" —— OpenClaw 侧 + +这一步**不在本仓库**,是 OpenClaw 平台 + 大模型(Claude)干的,但它是整条链的转折点,必须理解: + +- AI 收到你的「向前走 1 米」+ 站点 1 注入的能力清单,**推理**出:"这要往 `/cmd_vel` 发一个 Twist 速度消息。" +- 于是 AI 产出一次**工具调用(tool call)**: + - 工具名:`ros2_publish` + - 参数:`{ topic: "/cmd_vel", type: "geometry_msgs/msg/Twist", message: { linear: { x: 0.5, ... }, angular: {...} } }` +- **AI 怎么知道有 `ros2_publish` 这个工具、该传什么参数?** 靠的是工具注册时写的 `description`(提示词)和 `parameters`(参数 schema)——这正是站点 0 里 `registerTools` 摆上货架时声明的。 + +> 📌 **这里是"人话世界"和"机器世界"的分界线**:往上是自然语言 + AI 推理,往下全是确定的代码逻辑。RosClaw 插件的工作从下一站正式开始。 +> +> 🔍 想理解"工具"这种交互形式:[第 14 篇 · 工具通用结构](14-tools-index.ts.md);想看整体架构:[顶层 · 03 架构深度解析](../03-架构深度解析.md) + +--- + +## 站点 3(工具执行前):安全员拦一道 —— `validator.ts` + +AI 说要发速度消息,但**先别急**——在工具真正执行前,安全钩子会拦下来检查"这个速度会不会太快出危险"。 + +📍 **`src/safety/validator.ts` · `registerSafetyHook()` 内的 `before_tool_call` · 第 11–42 行** + +```typescript +api.on("before_tool_call", async (event, _ctx) => { + if (event.toolName === "ros2_publish") { + const msg = event.params["message"] as Record | undefined; + if (msg) { + const linear = msg["linear"] as Record | undefined; + if (linear) { + const speed = Math.sqrt( // 第 20 行:算合速度 + (linear["x"] ?? 0) ** 2 + (linear["y"] ?? 0) ** 2 + (linear["z"] ?? 0) ** 2, + ); + if (speed > safety.maxLinearVelocity) { + return { block: true, blockReason: `...exceeds safety limit...` }; // 第 27 行:拦下 + } + } + // ……角速度同理(第 31–37 行)…… + } + } +}); +``` + +- **`before_tool_call`** 也是钩子,但时机不同:**每次工具即将执行前**触发。 +- 它把参数里的 `message.linear` 取出来,用 `Math.sqrt(x² + y² + z²)` 算出**合速度**(第 20 行),和配置的上限 `maxLinearVelocity` 比。 +- **两种结局**: + - 超限 → `return { block: true, blockReason: "..." }`(第 27 行)→ **工具被拦截、不执行**,AI 收到拦截原因。 + - 没超 → 函数自然走完、`return` 一个 `undefined` → **放行**,进入下一站。 +- **打个比方**:安全员站在工具门口,速度合规就放你进去,超速就把你拦下并说明理由。这一道关保证"AI 再怎么发疯,也发不出超过物理上限的危险速度"。 + +> 🔍 逐行详解:[第 22 篇 · validator.ts](22-safety-validator.ts.md)(钩子机制、`Math.sqrt`/`**`、默认放行/危险拦截) + +--- + +## 站点 4:工具执行 —— `ros2-publish.ts` + +安全员放行后,`ros2_publish` 工具的 `execute` 真正运行。它的工作出奇地简单——**取参数、拿传输层、发出去**。 + +📍 **`src/tools/ros2-publish.ts` · `execute()` · 第 24–37 行** + +```typescript +async execute(_toolCallId, params) { + const topic = params["topic"] as string; // 第 25 行:取出三个参数 + const type = params["type"] as string; + const message = params["message"] as Record; + + const transport = getTransport(); // 第 29 行:拿到传输层单例 + transport.publish({ topic, type, msg: message }); // 第 30 行:发布! + + const result = { success: true, topic, type }; + return { content: [{ type: "text", text: JSON.stringify(result) }], details: result }; +} +``` + +- 第 25–27 行:从 `params` 里取出 AI 传来的 `topic`、`type`、`message`。 +- 第 29 行:`getTransport()` 拿到"和机器人的连接"(下一站讲它从哪来)。 +- 第 30 行:`transport.publish({...})`——**真正的发布动作**。注意工具把参数 `message` 改名成 `msg` 装进对象(迁就传输层接口的字段名)。 +- **工具层只是"薄薄一层"**:它不关心底层用 WebSocket 还是 WebRTC,只管"把请求交给 transport"。这正是分层设计的好处。 + +> 🔍 逐行详解:[第 15 篇 · ros2-publish.ts](15-ros2-publish.ts.md)(TypeBox 参数、`execute` 套路、`ToolResult` 的 content/details) + +--- + +## 站点 5:取出"传输层"单例 —— `service.ts` + +第 29 行那个 `getTransport()` 是从哪儿拿到连接的?答案在 `service.ts`——它管着一个**全程序共享的唯一连接对象(单例)**。 + +📍 **`src/service.ts` · `getTransport()` · 第 18–23 行** + +```typescript +let transport: RosTransport | null = null; // 第 9 行:模块级单例变量 + +export function getTransport(): RosTransport { + if (!transport) { + throw new Error("Transport not initialized. Is the service running?"); + } + return transport; // 返回那个唯一的连接 +} +``` + +- `transport` 是个**模块级变量**(写在文件最外层,全文件共享),保存"唯一的那条连接"。 +- **这条连接是什么时候建的?** 回到站点 0:`registerService` 在插件启动时,于 `start()` 里建好并连上: + +📍 **`src/service.ts` · `registerService().start()` · 第 94–101 行** + +```typescript +transport = await createTransport(transportCfg); // 第 94 行:按配置造一个传输层 +transport.onConnection((status) => { ... }); +await transport.connect(); // 第 100 行:连上机器人 +currentMode = mode; +``` + +- `createTransport` 是个**工厂**:看配置里 `mode` 是 `rosbridge`/`local`/`webrtc`,就造对应的那一种。默认 `rosbridge`,所以这里造的是 `RosbridgeTransport`。 +- **打个比方**:`service.ts` 像大楼的"总闸"——开店时合闸(`connect`),所有工具共用这一路电(同一个 `transport`),关店时拉闸(`disconnect`)。 +- 还有个 `switchTransport`(第 35 行)支持运行时换传输模式,用了"并发门闩"防止换到一半被打断——那是 `/transport` 命令用的,本主线用不到。 + +> 🔍 逐行详解:[第 12 篇 · service.ts](12-service.ts.md)(模块级单例、并发门闩、生命周期);工厂见 [第 11 篇](11-transport-factory.ts.md) + +--- + +## 站点 6:适配器 —— `adapter.ts` + +`transport.publish(...)` 调到的是 `RosbridgeTransport`(默认模式)的 `publish` 方法。它做的事也很薄——**转交给一个专门发布话题的小助手**。 + +📍 **`src/transport/rosbridge/adapter.ts` · `publish()` · 第 55–58 行** + +```typescript +publish(options: PublishOptions): void { + const publisher = new TopicPublisher(this.client, options.topic, options.type); + publisher.publish(options.msg); +} +``` + +- `RosbridgeTransport` 实现了一个统一接口 `RosTransport`(13 个方法:publish/subscribe/callService……)。**三种传输模式都实现同一个接口**,所以上层(工具)调 `transport.publish` 时根本不用管底下是哪种模式。 +- 这里它把活儿交给 `TopicPublisher`(话题发布小助手),自己只负责"组装零件、转交"。 +- **打个比方**:适配器像前台。你说"我要发布到 /cmd_vel",前台不亲自跑腿,而是叫来"话题发布专员"(`TopicPublisher`)去办。 + +> 🔍 逐行详解:[第 10 篇 · adapter.ts](10-rosbridge-adapter.ts.md)(`implements` 接口、13 个方法、把活儿分派给各助手);统一接口本身见 [第 04 篇](04-transport.ts.md) + +--- + +## 站点 7:拼成 rosbridge 协议 JSON —— `topics.ts` + +`TopicPublisher.publish` 终于把请求变成了**rosbridge 协议规定的那条 JSON**。 + +📍 **`src/transport/rosbridge/topics.ts` · `TopicPublisher.publish()` · 第 15–22 行** + +```typescript +publish(msg: Record): void { + this.client.send({ + op: "publish", // ← rosbridge 协议:op 字段表明"这是发布操作" + topic: this.topic, // /cmd_vel + type: this.type, // geometry_msgs/msg/Twist + msg, // { linear: {...}, angular: {...} } + }); +} +``` + +- **这就是"信息形态"的关键一变**:原本是 JavaScript 对象,现在被拼成了 **rosbridge 协议**约定的形状——一个带 `op` 字段的对象。`op: "publish"` 告诉 rosbridge_server "我要往话题发消息"。 +- rosbridge 协议就是一套"用带 `op` 字段的 JSON 表达 ROS2 操作"的约定:`publish`(发布)、`subscribe`(订阅)、`call_service`(调服务)等。 +- 拼好后交给 `this.client.send(...)`——下一站真正发到网络上。 + +> 🔍 逐行详解:[第 07 篇 · topics.ts](07-rosbridge-topics.ts.md);rosbridge 协议全貌见 [第 05 篇](05-rosbridge-types.ts.md) + +--- + +## 站点 8:通过 WebSocket 发出去 —— `client.ts` + +`client.send(...)` 是整条出站链的**最后一站**(插件这一侧)。它把那个 JSON 对象变成文本,写进 WebSocket。 + +📍 **`src/transport/rosbridge/client.ts` · `send()` · 第 138–144 行** + +```typescript +send(message: RosbridgeMessage & Record): void { + if (!this.ws || this.status !== "connected") { + throw new Error("Not connected to rosbridge server"); + } + this.ws.send(JSON.stringify(message)); // 第 143 行:对象 → JSON 文本 → 发出 +} +``` + +- 第 143 行 `JSON.stringify(message)`——**把对象序列化成 JSON 字符串**,再 `this.ws.send(...)` 写进 WebSocket 连接。 +- 至此,那段文本 `{"op":"publish","topic":"/cmd_vel",...}` 以网络数据帧的形式,飞向 Docker 容器里的 **rosbridge_server**。 +- `RosbridgeClient` 是最底层的"网络工":管 WebSocket 的连接、断线重连、收发。**它是全系列最难、最核心的一篇**——因为"收消息"那一半(下面返回路径要用)也在这里。 + +> 🔍 逐行详解:[第 06 篇 · client.ts](06-rosbridge-client.ts.md) 🔥(全系列最难:connect、send、handleMessage) + +--- + +## 站点 9(插件之外):rosbridge → DDS → 机器人 + +JSON 飞出插件后: + +1. **rosbridge_server**(跑在 Docker 容器里的 ROS2 官方组件)收到这条 JSON,**把它翻译成 ROS2 的 DDS 二进制消息**,发布到 `/cmd_vel` 话题。 +2. ROS2 机器人(仿真里的 TurtleBot3)的电机控制节点**订阅**了 `/cmd_vel`,收到速度消息,驱动电机。 +3. 🚗 **机器人向前动了。** + +这一段不在本仓库(是 ROS2 生态的标准组件),但你要知道**信息在这里完成了最后一次形态转换**:rosbridge JSON → DDS 二进制。 + +> 想理解 rosbridge 是什么、为什么需要它:[顶层 · 01 项目概览](../01-项目概览.md) 和 [顶层 · 02 技术前置知识](../02-技术前置知识.md)。 + +--- + +## 返回路径:读类命令怎么把数据拿回来? + +上面的「向前走」是**单向**的——发出去就完事(发布不需要回复)。但如果你问「**电池还有多少?**」,AI 会调 `ros2_subscribe_once` 工具,这就需要**等机器人回一条数据**。返回路径用了一个很妙的"竞速"技巧。 + +📍 **`src/tools/ros2-subscribe.ts` · `execute()` · 第 22–42 行** + +```typescript +const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: msgType }, + (msg) => { // 收到消息时: + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); // ← 兑现:把数据交出去 + }, + ); + const timer = setTimeout(() => { // 同时起一个倒计时: + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); // ← 超时:报错 + }, timeout); +}); +``` + +- **这是一场"竞速"**:两条路谁先到算谁—— + - **路 A**:机器人在 `timeout`(默认 5 秒)内回了消息 → 回调触发 → `resolve` 兑现,拿到数据。 + - **路 B**:超时了还没消息 → `setTimeout` 触发 → `reject` 报错。 + - 无论哪条先到,都会 `unsubscribe()` 退订并清掉对方(清 timer / 退订)——干净收尾。 + +**那机器人回的消息,是怎么走到这个回调里的?** 这就要回到站点 8 的 `client.ts`,但这次是**收消息**的那一半: + +📍 **`src/transport/rosbridge/client.ts` · `handleMessage()` · 第 216–273 行** + +```typescript +private handleMessage(data: string): void { + const msg = JSON.parse(data); // 收到的 JSON 文本 → 对象 + const op = msg.op as string | undefined; + switch (op) { // 按 op 分派 + case "publish": { // 订阅的话题来消息了 + const handlers = this.messageHandlers.get(msg.topic); + if (handlers) for (const h of handlers) h(msg.msg); // → 触发上面那个回调 + break; + } + case "service_response": // 服务调用的回复 + case "action_result": { + this.resolvePending(msg.id, msg); // 第 189 行:按 id 找到等待者并兑现 + break; + } + case "action_feedback": { ... } // 动作进度 + } +} +``` + +- 收到的每条消息都先 `JSON.parse` 变回对象,再看 `op` 字段**分派**: + - `op: "publish"`(订阅的话题有新消息)→ 找到登记在 `messageHandlers` 里的回调,挨个触发——**这就接回了上面 subscribe 的那个 `(msg) => { ... resolve(...) }`**。 + - `op: "service_response"` / `"action_result"` → 按消息里的 `id` 调 `resolvePending`(第 189 行),找到当初那个"在等这个 id 回复"的 Promise 并兑现。 +- **"按 id 配对"是核心套路**:发请求时记下 `id` 和一个"等待者",回复带着同样的 `id` 回来,就能精确找到该唤醒谁。站点 1 查能力用的 `callService` 也是靠这套机制拿到话题列表的。 + +把 `handleMessage` 这个"总机"的分派逻辑画出来: + +![](img/00-2.png) + +> 🔍 逐行详解:[第 16 篇 · ros2-subscribe.ts](16-ros2-subscribe.ts.md)(竞速 Promise);收发核心 [第 06 篇 · client.ts](06-rosbridge-client.ts.md)(`handleMessage` 的 `switch(op)`、`registerPending`/`resolvePending`) + +--- + +## 全站点速查表(随时回查) + +| 站点 | 文件 | 函数 | 行号 | 一句话 | 逐行详解 | +|---|---|---|---|---|---| +| 0 启动装配 | `index.ts` | `register()` | 17–39 | 注册 6 个零件 | [13](13-index.ts.md) | +| 1 能力注入 | `context/robot-context.ts` | `registerRobotContext` / `discoverCapabilities` | 26–49 / 55–98 | 把机器人能力塞进 AI 提示 | [23](23-robot-context.ts.md) | +| └ 发现走服务 | `transport/rosbridge/adapter.ts` | `listTopics()` | 102–112 | 调 `/rosapi/topics` 服务 | [10](10-rosbridge-adapter.ts.md) | +| 2 AI 选工具 | (OpenClaw 侧,非本仓库) | — | — | 人话 → 工具调用 | [14](14-tools-index.ts.md) | +| 3 安全拦截 | `safety/validator.ts` | `before_tool_call` 钩子 | 11–42 | 超速则 `block` | [22](22-safety-validator.ts.md) | +| 4 工具执行 | `tools/ros2-publish.ts` | `execute()` | 24–37 | 取参 → `transport.publish` | [15](15-ros2-publish.ts.md) | +| 5 取单例 | `service.ts` | `getTransport()` | 18–23 | 返回唯一连接(建于 94–101) | [12](12-service.ts.md) | +| 6 适配器 | `transport/rosbridge/adapter.ts` | `publish()` | 55–58 | 转交 `TopicPublisher` | [10](10-rosbridge-adapter.ts.md) | +| 7 拼协议 | `transport/rosbridge/topics.ts` | `TopicPublisher.publish()` | 15–22 | 组 `op:"publish"` JSON | [07](07-rosbridge-topics.ts.md) | +| 8 发送 | `transport/rosbridge/client.ts` | `send()` | 138–144 | `ws.send(JSON.stringify)` | [06](06-rosbridge-client.ts.md) | +| 9 机器人侧 | rosbridge_server → DDS | — | — | JSON → DDS → `/cmd_vel` | [01](../01-项目概览.md) | +| 返回 收消息 | `transport/rosbridge/client.ts` | `handleMessage()` | 216–273 | 按 `op`/`id` 分派回调 | [06](06-rosbridge-client.ts.md) | +| 返回 读工具 | `tools/ros2-subscribe.ts` | `execute()` | 22–42 | 竞速 Promise 等一条消息 | [16](16-ros2-subscribe.ts.md) | + +--- + +## 三个最该记住的"全局道理" + +读完这条主线,比记住具体行号更重要的是这三点——它们在 01–32 里会反复出现: + +1. **分层 + 同一接口**:工具不知道底层用 WebSocket 还是 WebRTC,因为三种传输模式都实现同一个 `RosTransport` 接口(站点 4→6)。换模式只改工厂里 `new` 哪个类,上层一行都不用动。 +2. **信息逐层改形、含义不变**:中文 → 工具调用 → JS 对象 → rosbridge JSON → WebSocket 帧 → DDS(看开头那张形态表)。每一层只负责"翻译成下一层听得懂的话"。 +3. **请求-响应靠 id 配对、订阅靠话题回调**:出站是一条直线;入站则是 `handleMessage` 这个"总机"按 `op`/`id` 把消息接回正确的等待者(返回路径)。 + +--- + +## 接下来怎么读 + +- **想从最基础的"合同"开始**:→ [第 01 篇 · plugin-api.ts](01-plugin-api.ts.md),然后按 01→32 顺序读,本系列每篇都假设你已掌握前面讲过的语法。 +- **想先看大图、再钻代码**:→ 顶层 [03 架构深度解析](../03-架构深度解析.md) 和 [04 核心代码导读](../04-核心代码导读.md)。 +- **想亲手跑起来、发一句话让机器人动**:→ 顶层 [05 完整实战教程](../05-完整实战教程.md)。 + +**这条主线会一直在这里。** 任何时候你在某一篇里"看不到全局"了,就回到本篇的速查表,找到那个文件的站点编号,你就知道它的上下游是谁。 + +下一份:[第 01 篇 · plugin-api.ts 逐行详解 →](01-plugin-api.ts.md)(一切的起点:OpenClaw 和插件之间的"合同") + +← 返回 [逐行详解系列 · 总览](README.md) + + + +--- + + +# 逐行详解 ①:`plugin-api.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/plugin-api.ts](../../extensions/openclaw-plugin/src/plugin-api.ts) +> +> 这是推荐阅读顺序的第 1 个文件。它**不是程序逻辑,而是一份"合同"**——用 TypeScript 的类型系统,把 OpenClaw 这个平台和我们插件之间的约定写下来。读懂它,你就知道"OpenClaw 会给我们什么、我们要还给它什么"。 + +--- + +## 阅读本章前,先建立 3 个最基础的概念 + +这 3 个概念后面会反复出现,先用大白话讲清楚。 + +### 概念 1:什么是"类型"? + +在 TypeScript(简称 TS)里,每个数据都有"类型",就是"这个数据长什么样"的标签。 + +- `string` —— 字符串,也就是文字,比如 `"你好"`、`"/cmd_vel"`。 +- `number` —— 数字,比如 `3000`、`1.5`。 +- `boolean` —— 布尔值,只有两个取值:`true`(真)和 `false`(假)。 +- `void` —— "什么都不返回"。一个函数如果做完事不给你任何结果,它的返回类型就是 `void`。 + +### 概念 2:什么是 `interface`(接口)? + +`interface` 是 TS 的关键字,意思是"定义一种对象的形状"。它**只描述长什么样,不写具体怎么做**。 + +打个比方:`interface` 就像招聘启事——"我们要招一个人,他必须会做 A、会做 B"。它不关心具体是谁来做,只规定"必须具备哪些能力/字段"。 + +```typescript +interface 人 { + 名字: string; // 必须有一个叫"名字"的字段,且是文字 + 年龄: number; // 必须有一个叫"年龄"的字段,且是数字 +} +``` + +任何对象只要同时有 `名字`(文字)和 `年龄`(数字),就算"符合这个接口"。 + +### 概念 3:什么是"声明文件 / 类型声明"? + +这个文件开头的注释说得很清楚(第 1-7 行): + +```typescript +/** + * Type declarations matching the real OpenClaw plugin SDK. + * + * Only the subset used by the rosclaw plugin is declared here. + * These types mirror openclaw/plugin-sdk so that the plugin compiles + * without importing the SDK at build time (it is provided at runtime). + */ +``` + +**语法小课堂:`/** ... */` 是什么?** +以 `/*` 开头、`*/` 结尾的是"块注释",中间的内容计算机**完全忽略**,纯粹写给人看。开头是 `/**`(两个星号)的叫 **JSDoc 注释**,是一种约定俗成的、写给函数/类型的"文档说明"。 + +这段注释翻译过来是: +> 这些是与"真正的 OpenClaw 插件 SDK"相匹配的类型声明。这里只声明了 rosclaw 插件用到的那一小部分。这些类型是 `openclaw/plugin-sdk` 的"镜像",这样插件在**编译时**不需要真的去导入 SDK 就能通过类型检查(真正的实现是在**运行时**由 OpenClaw 提供的)。 + +用大白话说:**OpenClaw 平台运行的时候,会塞给我们一个真实的工具箱(`api` 对象)。但我们写代码时它还不在手边,所以我们先按它的样子画一张"图纸"(类型声明),照着图纸写代码就不会出错。** 这个文件就是那张图纸。 + +--- + +## 第 9 行:导入一个外部类型 + +```typescript +import type { TSchema } from "@sinclair/typebox"; +``` + +逐部分拆解: + +- `import` —— 关键字,"从别的文件/库里拿东西过来用"。 +- `type` —— 紧跟在 `import` 后面,强调"我只拿它的**类型**,不拿它的实际代码"。这是个优化:编译成最终运行的 JavaScript 时,这一行会被完全删掉(因为类型只在开发期检查用,运行时不需要)。 +- `{ TSchema }` —— 一对花括号里写"我要拿的东西的名字"。这里要拿的是 `TSchema`。 +- `from "@sinclair/typebox"` —— "从哪里拿"。`@sinclair/typebox` 是一个第三方库的名字(叫 TypeBox),用来描述"数据的结构和规则"。 +- 行末的 `;` —— 分号,表示"这条语句到此结束"。TS 里分号大多数时候可加可不加,本项目风格是加。 + +**`TSchema` 是什么?** 它代表"一份用 TypeBox 写出来的数据结构定义"。后面工具的 `parameters`(参数定义)字段就是这个类型——AI 调用工具时要填哪些参数、每个参数什么类型,都用它来描述。现在记住"它是参数结构的类型"即可。 + +--- + +## 第 13-17 行:日志记录器 `PluginLogger` + +```typescript +export interface PluginLogger { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} +``` + +**语法小课堂:`export` 是什么?** +`export` 意思是"导出"——把这个东西公开出去,让别的文件可以 `import` 它。没有 `export` 的东西只能在本文件内部用。这里 `export interface PluginLogger` 表示"我定义了一个叫 `PluginLogger` 的接口,并且允许别的文件使用它"。 + +逐行看接口内部: + +- `info(msg: string): void;` —— 规定:必须有一个叫 `info` 的**方法(函数)**。 + - `info` 是方法名。 + - 括号 `(msg: string)` 表示它接收一个**参数**,参数名叫 `msg`,类型是 `string`(文字)。`参数名: 类型` 是 TS 写参数的固定格式。 + - `: void` 在括号后面,表示这个方法的**返回类型是 `void`**——调用它,它只是默默打印日志,不会返回任何值给你。 +- `warn(msg: string): void;` —— 同理,一个叫 `warn`(警告)的方法。 +- `error(msg: string): void;` —— 一个叫 `error`(错误)的方法。 + +**整体含义**:OpenClaw 会给我们一个"日志对象",上面挂着三个方法。我们想打印普通信息就调 `api.logger.info("...")`,想打印警告就 `api.logger.warn("...")`,想打印错误就 `api.logger.error("...")`。这三档对应日志的严重程度。 + +> 第 11 行的 `// --- Logger ---` 是**单行注释**:以 `//` 开头,从这里到行尾都是给人看的说明,计算机忽略。这里只是个分隔小标题。 + +--- + +## 第 21-31 行:AI 工具的形状 `AgentTool` + +这是整个插件最核心的类型之一——它定义了"一个能被 AI 调用的工具长什么样"。 + +```typescript +export interface AgentTool { + name: string; + label: string; + description: string; + parameters: TSchema; + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +} +``` + +逐字段拆解: + +- `name: string;` —— 工具的**唯一 ID**,是文字。AI 在内部就是用这个名字来"点名调用"工具,比如 `"ros2_publish"`。 +- `label: string;` —— 工具的**显示名**,给人在界面上看的,比如 `"ROS2 Publish"`。 +- `description: string;` —— 工具的**说明书**。这一段文字会交给 AI 阅读,AI 靠它判断"什么时候该用这个工具、每个参数该怎么填"。**这段写得越精确,AI 调用得越准**,所以它极其重要。 +- `parameters: TSchema;` —— 工具**参数的结构定义**(就是上面导入的 `TSchema` 类型)。它规定了 AI 调用时要提供哪些参数、各是什么类型。 +- `execute(...)` —— 工具的**执行函数**,也就是"真正干活的代码"。当 AI 决定调用这个工具时,OpenClaw 就会调用这个 `execute`。 + +重点看 `execute` 的签名(也就是它的输入和输出): + +```typescript + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +``` + +它接收三个参数: + +1. `toolCallId: string` —— 本次调用的唯一编号(文字),用于追踪是哪一次调用。 +2. `params: Record` —— AI 实际填进来的参数。 + - **语法小课堂:`Record` 是什么?** + `Record` 是 TS 内置的一个"泛型类型",写成 `Record<键的类型, 值的类型>`,表示"一个对象,它的键是某种类型、值是另一种类型"。 + - `string` 是键的类型——意思是对象的字段名都是文字。 + - `unknown` 是值的类型——意思是"值是什么类型我现在不知道"。`unknown` 是 TS 里最"谨慎"的类型,表示"未知",用它之前你必须先检查/断言它到底是什么,不能直接拿来乱用。 + - 合起来 `Record` 就是"一个键是文字、值类型未知的普通对象",等价于 `{ [字段名: string]: unknown }`。这正好适合描述"AI 传进来的、我们还没核实过的一包参数"。 + - **什么是"泛型"?** 就是尖括号 `<...>` 里那部分,相当于给类型传"参数"。`Record` 本身是个模板,你用 `` 把模板里的空填上,得到一个具体类型。后面会反复见到尖括号,见到就想"这是在给类型传参"。 +3. `signal?: AbortSignal` —— 一个"中止信号",用来在任务执行到一半时取消它。 + - **语法小课堂:参数名后面的 `?` 是什么?** + `?` 表示"这个参数**可选**"——调用时可以传,也可以不传。没有 `?` 的参数是必填的。所以 `signal?` 意思是"中止信号你爱给不给"。 + - `AbortSignal` 是浏览器/Node.js 内置的一个类型,专门用于"取消异步操作",现在不深究。 + +返回类型是 `Promise`: + +- **语法小课堂:`Promise<...>` 是什么?这是理解本项目的关键。** + 很多操作不是一瞬间完成的,比如"通过网络问机器人要数据",需要等一会儿。TS/JS 用 `Promise`(承诺)来表示"一个将来才会有结果的值"。 + - 你可以把 `Promise` 想成一张"取餐号":你点了餐(发起了操作),拿到一张号(`Promise`),暂时没饭吃,但承诺将来饭好了凭号取。 + - `Promise` 的尖括号里写的是"将来兑现时给你的东西的类型"。所以这表示"将来会给你一个 `ToolResult`(工具执行结果)"。 + - 后面你会看到 `async` 和 `await` 两个关键字,它们就是专门用来优雅地"等 Promise 兑现"的,到时再细讲。 + +**整体含义**:一个工具 = 一个名字 + 一个标签 + 一段给 AI 看的说明 + 一份参数结构 + 一个"将来会返回结果"的执行函数。 + +--- + +## 第 33-36 行:工具的返回结果 `ToolResult` + +```typescript +export interface ToolResult { + content: ToolContent[]; + details?: unknown; +} +``` + +- `content: ToolContent[];` —— 给 AI 看的内容。 + - **语法小课堂:类型后面的 `[]` 是什么?** + `[]` 表示"数组",也就是"一串同类型的东西排成队"。`ToolContent[]` 就是"一个由若干 `ToolContent` 组成的数组/列表"。例如 `[内容1, 内容2]`。 + - 为什么是数组?因为一次返回可能包含多条内容,比如"一段文字 + 一张图片"(后面摄像头工具就是这样)。 +- `details?: unknown;` —— 可选的(注意那个 `?`)附加数据。类型是 `unknown`(未知结构)。这部分**给界面展示用,AI 不读**。 + +--- + +## 第 38-40 行:内容可以是文字或图片 `ToolContent` + +```typescript +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; +``` + +**语法小课堂:`type` 和 `interface` 有什么区别?** +`type` 也是用来定义类型的关键字。粗略说,`interface` 偏向描述"对象的形状",而 `type` 更万能,还能表达"这个 **或** 那个"这种组合。这里用 `type` 正是因为要表达"或"。 + +**语法小课堂:`|` 是"联合类型"(union),表示"或者"。** +开头那个 `=` 后面换行写的 `| ... | ...`,每个 `|` 引出一个可能的选项。整体读作:"`ToolContent` 是下面两种之一": + +- `{ type: "text"; text: string }` —— 第一种:一个对象,有 `type` 字段且值**正好是文字 `"text"`**,外加一个 `text` 字段放具体文字。 + - **语法小课堂:`type: "text"` 这里 `"text"` 当类型用是什么意思?** + 通常类型是 `string`(任意文字),但这里直接写了具体的 `"text"`,叫"字面量类型"——意思是"这个字段的值必须**恰好等于** `"text"` 这个词,别的文字都不行"。它起到"标签"的作用,让程序能一眼区分这是哪一种内容。 +- `{ type: "image"; data: string; mimeType: string }` —— 第二种:`type` 必须是 `"image"`,再加 `data`(图片数据,通常是 base64 编码的文字)和 `mimeType`(媒体类型,比如 `"image/jpeg"`)。 + +**整体含义**:工具返回的每条内容,要么是"文字",要么是"图片",通过 `type` 字段区分。这种"用一个固定标签字段区分多种形状"的写法叫**判别联合(discriminated union)**,后面还会再见到。 + +--- + +## 第 44-54 行:服务相关类型 + +```typescript +export interface ServiceContext { + config: Record; + stateDir: string; + logger: PluginLogger; +} + +export interface PluginService { + id: string; + start(ctx: ServiceContext): Promise; + stop?(ctx: ServiceContext): Promise; +} +``` + +先看 `ServiceContext`(服务上下文)——OpenClaw 启动一个服务时,会把一些"环境信息"打包给我们: + +- `config: Record;` —— 用户填的配置(未知结构的对象)。 +- `stateDir: string;` —— 一个目录路径(文字),服务可以把要持久保存的状态写在这里。 +- `logger: PluginLogger;` —— 又见到上面定义的日志器类型,方便服务打日志。 + +再看 `PluginService`(插件服务)——所谓"服务",是一段有**生命周期**的后台逻辑(比如"维持和机器人的连接"),它需要"启动"和"停止"两个动作: + +- `id: string;` —— 服务的唯一标识。 +- `start(ctx: ServiceContext): Promise;` —— 启动方法。接收一个上下文 `ctx`,返回 `Promise`(异步执行,做完不返回具体值)。 +- `stop?(ctx: ServiceContext): Promise;` —— 停止方法。注意方法名后面的 `?`:**整个 `stop` 方法是可选的**——有的服务不需要清理,就可以不提供 `stop`。 + +> 小结一下 `?` 出现在不同位置的含义: +> - `参数名?` → 这个**参数**可选(可不传)。 +> - `字段名?` 或 `方法名?` → 这个**字段/方法**可选(可不写)。 +> 本质都是"可有可无"。 + +--- + +## 第 58-80 行:命令相关类型 + +OpenClaw 里"命令"是用户直接敲 `/xxx` 触发的(比如 `/estop`),**不经过 AI**。这几段定义命令的相关类型。 + +```typescript +export interface CommandContext { + senderId?: string; + channel: string; + channelId?: string; + isAuthorizedSender: boolean; + args?: string; + commandBody: string; + config: Record; + from?: string; + to?: string; + accountId?: string; + messageThreadId?: number; +} +``` + +`CommandContext` 是"命令被触发时的现场信息",挑几个关键字段看(带 `?` 的都是可选): + +- `senderId?: string;` —— 谁发的(用户 ID),可选。 +- `channel: string;` —— 来自哪个渠道(如 `"telegram"`),必填。 +- `isAuthorizedSender: boolean;` —— 这个发送者是否**已授权**(`true`/`false`)。这对安全很重要——比如紧急停止命令,可能只允许授权用户用。 +- `args?: string;` —— 命令后面跟的参数文字。比如用户输入 `/transport rosbridge ws://x:9090`,`args` 就是 `"rosbridge ws://x:9090"`。可选(有的命令没参数)。 +- `commandBody: string;` —— 命令的完整原文。 +- `config: Record;` —— 配置对象。 +- 其余 `from / to / accountId / messageThreadId` 是不同消息平台的细节,可选,现在略过。 +- 注意 `messageThreadId?: number;` 这个是**数字**类型,其它大多是文字。 + +```typescript +export interface PluginCommand { + name: string; + description: string; + handler(ctx: CommandContext): Promise | CommandResult; +} +``` + +`PluginCommand`(一个命令的定义): + +- `name: string;` —— 命令名,比如 `"estop"`(用户敲 `/estop`)。 +- `description: string;` —— 命令说明(给人看)。 +- `handler(ctx: CommandContext): Promise | CommandResult;` —— 处理函数,命令被触发时执行。 + - 它接收上面的 `ctx`(现场信息)。 + - 返回类型是 `Promise | CommandResult`——又见到联合 `|`。意思是"返回值**要么**是一个 `Promise`(异步给结果),**要么**直接是一个 `CommandResult`(同步立刻给结果)"。这样写很贴心:你的命令逻辑如果是异步的就返回 Promise,是同步的就直接返回,两种都允许。 + +```typescript +export interface CommandResult { + text: string; +} +``` + +`CommandResult`(命令的执行结果)非常简单:就一个 `text` 字段,是要回给用户的文字。 + +--- + +## 第 84-103 行:钩子之一 —— "会话开始前" `before_agent_start` + +**先理解什么是"钩子(hook)"**:钩子是"在某个时机自动被调用的函数"。你把自己的函数"挂"在某个事件上,事件一发生,平台就替你调用它。`before_agent_start` 这个钩子,会在"AI 会话即将开始"这个时机触发——我们正好趁这时把机器人能力信息塞给 AI。 + +```typescript +export interface BeforeAgentStartEvent { + prompt: string; +} +``` + +`BeforeAgentStartEvent`——事件触发时带来的数据,这里就是 `prompt`(用户即将发给 AI 的提示文字)。 + +```typescript +export interface BeforeAgentStartResult { + prependContext?: string; +} +``` + +`BeforeAgentStartResult`——我们的钩子函数可以**返回**的东西: + +- `prependContext?: string;` —— 可选的一段文字。如果我们返回了它,OpenClaw 会把这段文字"**前置(prepend)**"插入到 AI 的系统提示最前面。我们正是用它来注入"机器人有哪些话题/服务可用"。 + +```typescript +export interface BeforeAgentStartContext { + agentId?: string; + sessionKey?: string; + sessionId?: string; + workspaceDir?: string; + messageProvider?: string; +} +``` + +`BeforeAgentStartContext`——额外的现场信息(会话 ID、工作目录等),全是可选,现在略过。 + +```typescript +export type BeforeAgentStartHandler = ( + event: BeforeAgentStartEvent, + ctx: BeforeAgentStartContext, +) => Promise | BeforeAgentStartResult | void; +``` + +这是**最绕的一行**,但拆开就不难。它用 `type` 定义了"这个钩子的处理函数长什么样"。 + +**语法小课堂:`(参数) => 返回类型` 是"函数类型"的写法。** +箭头 `=>` 把"输入"和"输出"分开:左边括号里是参数,右边是返回类型。注意这里是**描述一个函数的类型**,不是真的函数体。 + +- 参数部分: + - `event: BeforeAgentStartEvent` —— 第一个参数,是上面的事件数据。 + - `ctx: BeforeAgentStartContext` —— 第二个参数,是现场信息。 +- 返回部分(`=>` 右边):`Promise | BeforeAgentStartResult | void` + - 这里嵌了好几层"或",慢慢读:整体是三选一—— + 1. `Promise` —— 异步返回,将来兑现的可能是"一个结果"或"什么都不返回(void)"。 + 2. `BeforeAgentStartResult` —— 同步直接返回一个结果。 + 3. `void` —— 同步地什么都不返回。 + - 翻译成人话:**你的钩子函数,可以异步也可以同步,可以返回一段要注入的内容、也可以什么都不返回(那就不注入)。** 框架把所有合理写法都允许了,给你最大自由。 + +--- + +## 第 105-124 行:钩子之二 —— "工具调用前" `before_tool_call` + +这是**安全机制的核心钩子**:在每个工具真正执行**之前**触发,给我们一个"拦下来不让它跑"的机会。 + +```typescript +export interface BeforeToolCallEvent { + toolName: string; + params: Record; +} +``` + +`BeforeToolCallEvent`——事件数据: + +- `toolName: string;` —— 即将被调用的工具名字(比如 `"ros2_publish"`)。 +- `params: Record;` —— AI 准备传给那个工具的参数。我们可以**检查**这些参数,判断危不危险。 + +```typescript +export interface BeforeToolCallResult { + block?: boolean; + blockReason?: string; +} +``` + +`BeforeToolCallResult`——我们钩子返回的东西: + +- `block?: boolean;` —— 可选。如果设为 `true`,就**拦截**这次工具调用(不让它执行)。 +- `blockReason?: string;` —— 可选。拦截的理由(文字)。这段理由会被回给 AI,AI 再解释给用户听(比如"速度超过安全上限,已阻止")。 + +```typescript +export interface BeforeToolCallContext { + agentId?: string; + sessionKey?: string; + toolName: string; +} +``` + +`BeforeToolCallContext`——现场信息,含会话标识和工具名,略过。 + +```typescript +export type BeforeToolCallHandler = ( + event: BeforeToolCallEvent, + ctx: BeforeToolCallContext, +) => Promise | BeforeToolCallResult | void; +``` + +和前面那个钩子的函数类型**结构完全一样**,只是把类型换成了 `BeforeToolCall...` 系列。返回部分同样是"异步/同步、返回结果/不返回"四种组合都允许。 + +- **返回一个结果**(里面 `block: true`)→ 拦截工具。 +- **返回 `void`(什么都不返回)** → 放行,工具照常执行。 + +这就是安全校验的工作方式:检查参数 → 危险就返回拦截结果 → 安全就什么都不返回。 + +--- + +## 第 128-138 行:总入口 `OpenClawPluginApi`(最重要的接口) + +前面定义的所有类型,都是为了这个"总工具箱"。OpenClaw 运行时塞给我们的那个 `api` 对象,就符合这个接口。 + +```typescript +export interface OpenClawPluginApi { + pluginConfig?: Record; + logger: PluginLogger; + + registerTool(tool: AgentTool, opts?: { name?: string; names?: string[]; optional?: boolean }): void; + registerService(service: PluginService): void; + registerCommand(command: PluginCommand): void; + + on(hookName: "before_agent_start", handler: BeforeAgentStartHandler): void; + on(hookName: "before_tool_call", handler: BeforeToolCallHandler): void; +} +``` + +逐成员拆解: + +- `pluginConfig?: Record;` —— 用户在界面上填的**原始配置**(可选,未知结构)。注意它是"生的"——还没校验过,所以类型是 `unknown`。我们后面会用 `config.ts` 里的工具去校验它。 +- `logger: PluginLogger;` —— 那个日志器(`info/warn/error` 三件套)。 + +接下来三个是**注册方法**——"向 OpenClaw 登记我们提供的东西": + +- `registerTool(tool: AgentTool, opts?: {...}): void;` —— 注册一个工具。 + - 第一个参数 `tool: AgentTool` 就是我们上面讲过的工具定义。 + - 第二个参数 `opts?` 是可选的额外选项,它的类型直接写在原地:`{ name?: string; names?: string[]; optional?: boolean }`。 + - **语法小课堂:类型可以"就地内联"写。** 不一定非得先 `interface` 再引用,简单的对象类型可以直接写在花括号里。这里表示"一个对象,可能有 `name`(文字)、`names`(文字数组)、`optional`(布尔),都可选"。 + - 返回 `void`:登记完不返回东西。 +- `registerService(service: PluginService): void;` —— 注册一个服务(前面的 `PluginService`)。 +- `registerCommand(command: PluginCommand): void;` —— 注册一个 `/` 命令。 + +最后两个 `on(...)` 是**挂钩子**的方法。注意它出现了**两次**,名字都叫 `on`: + +- `on(hookName: "before_agent_start", handler: BeforeAgentStartHandler): void;` +- `on(hookName: "before_tool_call", handler: BeforeToolCallHandler): void;` + +**语法小课堂:为什么同名 `on` 写两遍?这叫"函数重载(overload)"。** +同一个方法,根据第一个参数传的具体值不同,第二个参数要求的类型也不同: + +- 如果你调用 `api.on("before_agent_start", ...)`(第一个参数正好是字面量 `"before_agent_start"`),那么第二个参数 `handler` 必须是 `BeforeAgentStartHandler` 类型。 +- 如果你调用 `api.on("before_tool_call", ...)`,第二个参数就必须是 `BeforeToolCallHandler` 类型。 + +TS 会根据你传的钩子名,自动要求你提供对应正确类型的处理函数,传错了会在编译期报错。这就是字面量类型(`"before_agent_start"` 当类型用)的威力——它让方法"看名下菜"。 + +--- + +## 整章回顾:这个文件到底说了什么? + +用一句话总结:**它画出了 OpenClaw 和我们插件之间的全部"接口契约"。** + +把这些类型按用途归类,你的脑子里就有一张地图了: + +| 类别 | 类型 | 作用 | +|---|---|---| +| 日志 | `PluginLogger` | 打印 info/warn/error | +| 工具 | `AgentTool` / `ToolResult` / `ToolContent` | 定义 AI 可调用的工具、它的返回值、返回内容(文字或图片) | +| 服务 | `PluginService` / `ServiceContext` | 定义有启停生命周期的后台服务 | +| 命令 | `PluginCommand` / `CommandContext` / `CommandResult` | 定义用户敲 `/xxx` 触发的命令 | +| 钩子 | `BeforeAgentStart*` / `BeforeToolCall*` | 定义"会话前注入上下文""工具执行前拦截"两个时机 | +| 总入口 | `OpenClawPluginApi` | 把上面全部串起来:`registerTool/Service/Command` 注册东西,`on` 挂钩子 | + +**你只要记住一件事**:整份文件没有一行"真正干活的代码",全是"形状描述"。真正的实现由 OpenClaw 在运行时提供。我们后面写的每个文件,本质都是在"填这些形状"——造一个符合 `AgentTool` 的工具、写一个符合 `PluginService` 的服务,等等。 + +**语法点回顾清单**(这一章你新学到的): +- `/* */`、`/** */`、`//` 三种注释 +- `import type { X } from "..."` 只导入类型 +- `export` 导出 +- `interface` 定义对象形状;`type` 更万能、能表达"或" +- `string` / `number` / `boolean` / `void` / `unknown` 基础类型 +- `字段: 类型`、`参数: 类型`、`): 返回类型` 的标注位置 +- `?` 表示可选(参数/字段/方法) +- `[]` 表示数组 +- `Record` 表示键值对象 +- `Promise` 表示"将来才有的值" +- `|` 联合类型("或") +- `"具体值"` 当类型用 = 字面量类型 +- 判别联合(用一个标签字段区分多种形状) +- 函数类型写法 `(参数) => 返回` +- 函数重载(同名方法写多个签名) + +下一份:[`config.ts` 逐行详解 →](02-config.ts.md) + + + +--- + + +# 逐行详解 ②:`config.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/config.ts](../../extensions/openclaw-plugin/src/config.ts) +> +> 推荐阅读顺序第 2 个文件。它负责一件事:**定义"用户能填哪些配置、每项的默认值是什么",并提供一个函数把用户填的原始数据校验成可靠的配置对象。** + +--- + +## 先理解这个文件要解决的问题 + +用户在界面上会填一堆配置:连接机器人的地址、速度上限、机器人名字……但用户填的东西**不可信**: + +- 可能漏填(那就该用默认值)。 +- 可能填错类型(该填数字却填了文字)。 +- 可能填了我们不认识的模式。 + +我们需要一个"守门员":把用户填的"生数据"检查一遍,缺的补上默认值,错的直接拒绝。这个文件用一个叫 **Zod** 的库来当这个守门员。 + +**什么是 Zod?** 它是一个"运行时数据校验库"。你先用它描述"我期望的数据长什么样"(这份描述叫 **schema / 模式**),然后把真实数据丢给它检验。它和上一章的 `interface` 有个根本区别: + +- `interface` 只在**开发期/编译期**检查,程序真正运行时它已经被删掉了,**管不到运行时来的真实数据**(比如用户输入)。 +- Zod 是真正的代码,在**运行时**执行,能拦住运行时进来的脏数据。 + +所以:`interface` 管"我们自己写的代码对不对",Zod 管"外面进来的数据对不对"。两者配合。 + +--- + +## 第 1 行:导入 Zod + +```typescript +import { z } from "zod"; +``` + +- `import { z } from "zod"` —— 从名为 `"zod"` 的库里,导入一个叫 `z` 的东西。 +- 注意这里**没有** `type` 关键字(对比上一章的 `import type`)。因为 `z` 不是类型,而是一个**真实的对象**,我们要在运行时真的调用它身上的方法(`z.object(...)`、`z.string()` 等)。它必须留到运行时,不能被删掉。 +- `z` 是 Zod 库约定俗成的名字,可以理解成"Zod 的工具台",所有构建 schema 的工具都挂在它身上。 + +--- + +## 第 3-7 行:一个小 schema —— ICE 服务器 + +```typescript +const IceServerSchema = z.object({ + urls: z.union([z.string(), z.array(z.string())]), + username: z.string().optional(), + credential: z.string().optional(), +}); +``` + +**语法小课堂:`const` 是什么?** +`const` 声明一个**常量**——一个起了名字、且**之后不会被重新赋值**的变量。`const IceServerSchema = ...` 就是"造一个叫 `IceServerSchema` 的常量,它的值是右边那一坨"。声明变量还有 `let`(可改)和过时的 `var`,本项目几乎只用 `const`,因为"不可变"更安全、更可预测。 + +> 这个 `IceServerSchema` 没有 `export`,所以只在本文件内部用——它是给下面 webrtc 配置当"零件"的。 + +逐行看里面: + +- `z.object({ ... })` —— 调用 `z` 的 `object` 方法,"我要描述一个**对象**,它有以下这些字段"。花括号里就是字段清单。 +- `urls: z.union([z.string(), z.array(z.string())]),` —— 描述一个叫 `urls` 的字段。 + - `z.union([...])` —— "联合",表示"这个字段可以是下面几种类型之一"(对应上一章 TS 的 `|`,这里是 Zod 的运行时版本)。 + - `[z.string(), z.array(z.string())]` —— 方括号是一个**数组**,里面列出允许的两种形态: + - `z.string()` —— 一个字符串(比如单个地址 `"stun:..."`)。 + - `z.array(z.string())` —— 一个"字符串的数组"(比如多个地址 `["...", "..."]`)。 + - 合起来:`urls` 既可以是一个地址,也可以是一串地址。 +- `username: z.string().optional(),` —— 字段 `username`,类型是字符串,但 **`.optional()`** 表示"可以不填"。 + - **语法小课堂:`.方法()` 这种"点号链式调用"是什么?** + `z.string()` 先得到一个"字符串 schema",然后在它后面接 `.optional()`,是在这个 schema 的基础上"再加一条规则:可省略"。这种"造好一个东西后用点号继续在它身上加工"的写法叫**链式调用(chaining)**,整份文件大量使用。可以读成"字符串、并且可选"。 +- `credential: z.string().optional(),` —— 同理,字段 `credential`(密码/凭据),字符串、可选。 + +这个小 schema 描述的是 WebRTC 用到的 STUN/TURN 服务器配置,现在不用懂它的网络含义,重点是**看懂 Zod 描述数据的写法**。 + +--- + +## 第 9 行:主配置 schema 开始 + +```typescript +export const RosClawConfigSchema = z.object({ +``` + +- `export const RosClawConfigSchema = z.object({` —— 导出一个常量 `RosClawConfigSchema`,它是一个 Zod 对象 schema。这是**整个插件的配置总表**。 +- 花括号 `{` 开始列字段,一直到第 63 行的 `})` 才结束。中间分成 `transport / rosbridge / local / webrtc / robot / safety` 六大块,每块自己又是一个 `z.object`(对象套对象,叫"嵌套")。 + +下面逐块讲。 + +--- + +## 第 10-14 行:`transport`(传输模式) + +```typescript + transport: z + .object({ + mode: z.enum(["rosbridge", "local", "webrtc"]).default("rosbridge"), + }) + .default({}), +``` + +- `transport: z.object({ ... }).default({}),` —— 一个叫 `transport` 的字段,它本身又是个对象。 + - 注意这里把链式调用**换行**写了:`z` 在一行,`.object({...})` 一段,`.default({})` 又一段。换行只是排版,连起来读就是 `z.object({...}).default({})`。 +- 里面只有一个字段 `mode`: + - `z.enum(["rosbridge", "local", "webrtc"])` —— **枚举**。意思是"这个字段的值**只能是**列出的这几个之一",填别的就报错。这里限定传输模式只能三选一。 + - `.default("rosbridge")` —— 如果用户没填 `mode`,就默认用 `"rosbridge"`。 +- 外层的 `.default({})` 是这一块设计上的**关键技巧**: + - `{}` 是一个**空对象**(什么字段都没有)。 + - `.default({})` 表示"如果用户连 `transport` 这一整块都没填,就当他填了个空对象 `{}`"。 + - 而空对象会触发里面每个字段各自的 `.default(...)`——于是 `mode` 又变回 `"rosbridge"`。 + - **效果**:一层层的默认值像多米诺骨牌一样被触发,最终保证"即使用户什么都不填,也能得到一份完整、合理的配置"。这就是文档里说的"`parseConfig({})` 也能正常工作"的原理。 + +--- + +## 第 16-22 行:`rosbridge`(WebSocket 连接配置) + +```typescript + rosbridge: z + .object({ + url: z.string().default("ws://localhost:9090"), + reconnect: z.boolean().default(true), + reconnectInterval: z.number().default(3000), + }) + .default({}), +``` + +- `url: z.string().default("ws://localhost:9090"),` —— 连接地址,字符串,默认是本机的 `ws://localhost:9090`(`ws://` 是 WebSocket 协议的前缀,`localhost` 指本机,`9090` 是端口号,是 rosbridge 的默认端口)。 +- `reconnect: z.boolean().default(true),` —— 是否自动重连,布尔值,默认 `true`(开启)。 +- `reconnectInterval: z.number().default(3000),` —— 重连间隔,数字,默认 `3000`(单位毫秒,即 3 秒)。 +- 末尾同样 `.default({})`,整块可省略。 + +--- + +## 第 24-28 行:`local`(本机直连 DDS) + +```typescript + local: z + .object({ + domainId: z.number().default(0), + }) + .default({}), +``` + +- `domainId: z.number().default(0),` —— ROS2 的"域 ID",数字,默认 `0`。(ROS2 用域 ID 把不同机器人/系统隔离开,默认大家都在 0 号域。)现在记住"它是一个数字配置项"即可。 +- 整块可省略(`.default({})`)。 + +--- + +## 第 30-40 行:`webrtc`(点对点远程连接) + +```typescript + webrtc: z + .object({ + signalingUrl: z.string().default(""), + apiUrl: z.string().default(""), + robotId: z.string().default(""), + robotKey: z.string().default(""), + iceServers: z + .array(IceServerSchema) + .default([{ urls: "stun:stun.l.google.com:19302" }]), + }) + .default({}), +``` + +- 前四个字段 `signalingUrl / apiUrl / robotId / robotKey` 都是字符串,默认值都是 `""`(**空字符串**,即"一个字都没有的文字")。默认留空意味着"用户不配 WebRTC 就不填,这些值是空的"。 +- `iceServers: z.array(IceServerSchema).default([...]),` —— 这里把前面定义的小 schema 用上了: + - `z.array(IceServerSchema)` —— "一个数组,里面每个元素都必须符合 `IceServerSchema` 的形状"。这就是为什么前面要先单独定义那个小 schema——为了在这里当"数组元素的模板"复用。 + - `.default([{ urls: "stun:stun.l.google.com:19302" }])` —— 默认值是一个**含一个元素的数组**:`[ {...} ]`。那一个元素 `{ urls: "stun:stun.l.google.com:19302" }` 是个对象,提供了一个公共的 Google STUN 服务器地址。 +- 整块可省略。 + +--- + +## 第 42-47 行:`robot`(机器人身份) + +```typescript + robot: z + .object({ + name: z.string().default("Robot"), + namespace: z.string().default(""), + }) + .default({}), +``` + +- `name: z.string().default("Robot"),` —— 机器人名字,字符串,默认 `"Robot"`。这个名字会出现在注入给 AI 的提示里("你正连接着一台叫 XXX 的机器人")。 +- `namespace: z.string().default(""),` —— 命名空间,字符串,默认空。 + - ROS2 用"命名空间"给话题名加前缀,实现多机器人隔离。比如命名空间是 `/robot1`,那它的速度话题就是 `/robot1/cmd_vel` 而不是 `/cmd_vel`。默认空表示不加前缀。 + +--- + +## 第 49-62 行:`safety`(安全限制)—— 含一层嵌套 + +```typescript + safety: z + .object({ + maxLinearVelocity: z.number().default(1.0), + maxAngularVelocity: z.number().default(1.5), + workspaceLimits: z + .object({ + xMin: z.number().default(-10), + xMax: z.number().default(10), + yMin: z.number().default(-10), + yMax: z.number().default(10), + }) + .default({}), + }) + .default({}), +``` + +- `maxLinearVelocity: z.number().default(1.0),` —— **最大线速度**(直线行进速度),数字,默认 `1.0`(米/秒)。安全钩子会用它来拦截超速指令。 +- `maxAngularVelocity: z.number().default(1.5),` —— **最大角速度**(转弯/旋转速度),数字,默认 `1.5`(弧度/秒)。 +- `workspaceLimits: z.object({ ... }).default({}),` —— **工作空间边界**,它本身又是一个嵌套对象(对象套对象套对象,到这里是第三层了)。里面四个数字定义了一个矩形活动范围: + - `xMin: z.number().default(-10),` —— X 方向最小值,默认 `-10`。 + - `xMax: z.number().default(10),` —— X 方向最大值,默认 `10`。 + - `yMin / yMax` 同理,Y 方向的 `-10` 到 `10`。 + - 即默认允许机器人在一个 20×20 的方形区域内活动。 + - **语法小课堂:负数 `-10` 怎么读?** `-` 是负号,`-10` 就是负十。没什么特别,只是数字。 +- 这一块同样层层 `.default({})`,全可省略。 + +--- + +## 第 63 行:主 schema 结束 + +```typescript +}); +``` + +- `}` 关闭最外层 `z.object({` 的花括号。 +- `)` 关闭 `z.object(` 的圆括号。 +- `;` 语句结束。 + +到这里,`RosClawConfigSchema` 这份"配置总表"就定义完了。 + +--- + +## 第 65 行:从 schema **反推**出 TS 类型 + +```typescript +export type RosClawConfig = z.infer; +``` + +这一行很巧妙,是 Zod 最受欢迎的特性,逐部分拆: + +- `export type RosClawConfig = ...` —— 导出一个**类型**叫 `RosClawConfig`。 +- `z.infer<...>` —— `infer` 意思是"推断"。`z.infer<某个schema>` 的作用是:**自动从一个 Zod schema 推算出对应的 TS 静态类型**。 +- `typeof RosClawConfigSchema` —— **语法小课堂:`typeof` 是什么?** + - `RosClawConfigSchema` 是一个**值**(一个真实存在的常量对象)。 + - 但 `z.infer<...>` 尖括号里需要的是一个**类型**。 + - `typeof 某个值` 这个写法,作用是"取出这个值的类型",把值"翻译"成类型,好让它能放进尖括号里。 +- 整句合起来:**"看看 `RosClawConfigSchema` 这个 schema 描述的数据长啥样,自动生成一个对应的 TS 类型,命名为 `RosClawConfig`。"** + +**为什么要这样做?** 这是为了**"单一事实来源"**:我们只在一个地方(Zod schema)描述配置结构,TS 类型自动跟着生成。如果以后改了 schema(比如加一个配置项),对应的 TS 类型会**自动更新**,永远不会和 schema 不一致。如果手动再写一遍 `interface`,就得改两处、容易忘记其一。 + +推断出来的 `RosClawConfig` 大致等价于: + +```typescript +type RosClawConfig = { + transport: { mode: "rosbridge" | "local" | "webrtc" }; + rosbridge: { url: string; reconnect: boolean; reconnectInterval: number }; + local: { domainId: number }; + // ...等等 +}; +``` + +注意:因为所有字段都有 `.default()`,推断出来的类型里这些字段都是**必有**的(不是可选的)——因为经过校验后它们一定有值了。 + +--- + +## 第 67-73 行:守门员函数 `parseConfig` + +```typescript +/** + * Parse and validate raw plugin config against the RosClaw schema. + * Returns a fully-defaulted, typed config object. + */ +export function parseConfig(raw: Record): RosClawConfig { + return RosClawConfigSchema.parse(raw); +} +``` + +先看注释(第 67-70 行): +> 根据 RosClaw schema 解析并校验"生的"插件配置。返回一个"补全了所有默认值的、带类型的"配置对象。 + +再看函数本体: + +- `export function parseConfig(...)` —— **语法小课堂:`function` 怎么定义函数?** + - `function` 是定义函数的关键字。 + - `parseConfig` 是函数名。 + - 括号里是参数,`): 类型` 是返回类型,花括号 `{ }` 里是函数体(真正执行的代码)。 +- 参数 `raw: Record` —— 接收一个"生的"配置对象。回忆上一章:`Record` = "键是文字、值类型未知的对象",正好描述"用户填的、还没校验的脏数据"。参数名取 `raw`(生的、未加工的)很贴切。 +- 返回类型 `: RosClawConfig` —— 承诺"我会返回一个符合 `RosClawConfig` 类型的、干净的配置对象"。 +- 函数体只有一行: + - `return RosClawConfigSchema.parse(raw);` + - **语法小课堂:`return` 是什么?** `return` 表示"把后面这个值作为函数的结果交出去,并结束函数"。 + - `RosClawConfigSchema.parse(raw)` —— 调用 schema 的 `.parse()` 方法,把生数据 `raw` 丢进去校验。`.parse()` 会做三件事: + 1. **校验**:检查每个字段类型对不对、枚举值合不合法。 + 2. **补默认值**:缺的字段用 `.default(...)` 补上。 + 3. **要么成功返回干净对象,要么直接抛出异常**:如果数据有无法接受的错误(比如 `mode` 填了 `"foobar"` 这种不在枚举里的值),`.parse()` 会**抛出错误(throw)**,整个插件加载就会失败并报错。 + - 这正是文档里强调的"**Zod 解析失败会直接抛出异常——插件不会带着错误配置悄悄运行**"。宁可一开始就响亮地报错,也不要带着坏配置偷偷跑起来导致更难查的问题。 + +> **`throw`(抛出异常)是什么?** 当代码遇到无法继续的错误,它可以"抛出"一个异常,就像拉响警报。如果没人"接住"(用 `try/catch`,后面文件会见到),程序就会中断并打印错误。这里我们故意不接住——配置错了就该让插件启动失败。 + +--- + +## 整章回顾 + +这个文件做了三件事: + +1. **用 Zod 描述配置结构**(`RosClawConfigSchema`)——六大块配置,每项都带默认值,层层 `.default({})` 保证"全空也能跑"。 +2. **从 schema 自动推断 TS 类型**(`RosClawConfig`)——一处定义,类型自动同步,杜绝不一致。 +3. **提供守门员函数**(`parseConfig`)——把用户的脏数据校验+补全成干净配置,错就报错。 + +它和上一章的关系:上一章 `OpenClawPluginApi` 里有个 `pluginConfig?: Record`(用户填的生配置)。我们拿到它之后,第一件事就是丢给这里的 `parseConfig` 洗干净,之后全程用洗干净的 `RosClawConfig`。这个交接动作发生在下一个要读的关键文件 `index.ts` 里。 + +**语法点回顾清单**(本章新增): +- `const` / `let` / `var`:声明变量,本项目偏爱 `const`(不可变) +- 不带 `type` 的 `import { z } from "zod"`:导入运行时真实存在的值 +- Zod 基础:`z.object` / `z.string` / `z.number` / `z.boolean` / `z.enum` / `z.array` / `z.union` +- 链式调用 `.方法().方法()`,尤其 `.optional()` 和 `.default(值)` +- 空对象 `{}`、空字符串 `""`、数组字面量 `[...]` +- `z.infer`:从 schema 反推类型 +- `typeof 值`:把"值"翻译成"类型" +- `function 名字(参数): 返回类型 { ... }`:定义函数 +- `return`:交出结果 +- `throw` / 抛出异常的概念(Zod 校验失败时) + +下一份:[`transport/types.ts` 逐行详解 →](03-transport-types.ts.md)(第三批开始进入传输层) + + + +--- + + +# 逐行详解 ③:`transport/types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/types.ts](../../extensions/openclaw-plugin/src/transport/types.ts) +> +> 推荐阅读顺序第 3 个文件。它是**传输层的"数据字典"**——把"发布一条消息要带哪些信息""调用服务的结果长什么样""三种连接模式各自的配置"全部用类型定义好。和第①篇 `plugin-api.ts` 一样,这里**全是类型声明,没有一行干活的逻辑**。 + +--- + +## 先搞清楚"传输层"是干什么的 + +回忆一下整个项目的链路: + +``` +用户 → OpenClaw → RosClaw 插件 → (某种连接方式) → ROS2 机器人 +``` + +那个"某种连接方式"就是**传输层**。它可能是 WebSocket(rosbridge)、可能是本机直连(DDS)、可能是 WebRTC。不管底层用哪种,上层的工具代码都希望"用一样的方式去发布消息、调用服务"。 + +要做到"用一样的方式",就得先约定好**统一的数据格式**——发布消息时该提供什么、得到的结果长什么样。这个文件就是定义这些数据格式的。它本身不连接任何东西,只是"词汇表"。 + +> 第 1-3 行的 JSDoc 注释:「RosClaw 传输抽象层的共享类型」。"共享(shared)"是关键词——这些类型会被传输层的很多文件 `import` 复用。 + +--- + +## 第 7 行:连接状态 `ConnectionStatus` + +```typescript +export type ConnectionStatus = "disconnected" | "connecting" | "connected"; +``` + +- 用 `type` 定义一个类型 `ConnectionStatus`。 +- 右边是三个**字面量类型**用 `|`(或)连起来——回忆第①篇:把具体的字符串当类型用,表示"值只能恰好是这几个词之一"。 +- 含义:连接状态**只能是**这三种之一: + - `"disconnected"` —— 已断开 + - `"connecting"` —— 连接中 + - `"connected"` —— 已连上 +- 为什么不直接用 `string`?因为如果用 `string`,谁都能填 `"abc"` 这种乱七八糟的状态。限定成这三个,写错了 TS 立刻报错。这是字面量联合类型最常见的用途:**当"有限的几个选项"用**(相当于一个轻量级枚举)。 + +--- + +## 第 9 行:消息处理器 `MessageHandler` + +```typescript +export type MessageHandler = (msg: Record) => void; +``` + +- 这是一个**函数类型**(回忆第①篇:`(参数) => 返回类型` 的写法描述"一个函数长什么样")。 +- 拆解: + - 参数 `msg: Record` —— 收到的消息,是个"键是文字、值未知"的对象(因为 ROS2 消息可以是任意结构)。 + - 返回 `void` —— 处理完不返回任何值。 +- 含义:**"消息处理器"是一种函数,它接收一条消息、处理它、不返回东西。** 后面订阅话题时,我们会写一个这样的函数交给传输层:"每来一条消息,就用我这个函数处理"。这种"事后被调用的函数"通俗叫**回调函数(callback)**。 + +> **什么是回调?** 你把一个函数"交出去",不是自己马上调用它,而是让别人"将来在合适的时机替你调用"。就像你留个电话号码给餐厅:"菜好了打这个号码"——电话号码就是回调,餐厅将来会"回拨"。 + +--- + +## 第 11 行:连接状态处理器 `ConnectionHandler` + +```typescript +export type ConnectionHandler = (status: ConnectionStatus) => void; +``` + +- 同样是函数类型(回调)。 +- 参数 `status: ConnectionStatus` —— 接收一个连接状态(就是上面那三选一)。 +- 返回 `void`。 +- 含义:**一种"连接状态变化时被调用"的回调函数。** 连接从"连接中"变成"已连上"时,传输层会调用我们登记的这种函数,并把新状态传进来。我们就能据此打日志、刷新界面等。 + +--- + +## 第 13-16 行:订阅句柄 `Subscription` + +```typescript +/** Returned by subscribe(); call unsubscribe() to stop receiving messages. */ +export interface Subscription { + unsubscribe(): void; +} +``` + +- 先看注释:「由 subscribe() 返回;调用 unsubscribe() 来停止接收消息」。 +- `interface Subscription` —— 定义一个对象形状。 +- 里面只有一个方法 `unsubscribe(): void;` —— "取消订阅",无参数、无返回。 +- 含义:当你订阅一个话题,传输层会给你一个 `Subscription` 对象(就像给你一张"订阅凭证")。等你不想再收消息了,就调用这张凭证上的 `unsubscribe()` 来退订。 +- **设计要点**:为什么要返回一个"句柄对象",而不是用话题名字来退订?因为同一个话题**可能被订阅好几次**。如果用话题名退订,会分不清要退哪一个。而每次订阅返回一个独立的 `Subscription`,就能**精确退订其中某一个**,互不影响。(文档第 372-377 行也强调了这点。) + +> **"句柄(handle)"是什么?** 一个代表"某个资源/操作"的小对象,你拿着它就能对那个资源做操作。这里 `Subscription` 就是"这次订阅"的句柄,拿着它能取消这次订阅。 + +--- + +## 第 20-24 行:发布选项 `PublishOptions` + +```typescript +export interface PublishOptions { + topic: string; + type: string; + msg: Record; +} +``` + +发布一条消息到话题时要提供的信息: + +- `topic: string;` —— 话题名(比如 `"/cmd_vel"`),必填。 +- `type: string;` —— 消息类型(比如 `"geometry_msgs/msg/Twist"`),必填。ROS2 里每个话题都有固定的消息类型。 +- `msg: Record;` —— 消息体本身,是个任意结构的对象(因为不同类型的消息字段完全不同)。 + +> **为什么把参数打包成一个"选项对象",而不是写成三个独立参数?** +> 对比两种写法: +> - `publish(topic, type, msg)` —— 调用时得记住顺序,容易传错。 +> - `publish({ topic, type, msg })` —— 调用时每个值都有名字,一目了然,且以后加新选项不影响老代码。 +> 本项目统一用"选项对象"风格,所以才有这么多 `XxxOptions` 接口。 + +--- + +## 第 28-33 行:订阅选项 `SubscribeOptions` + +```typescript +export interface SubscribeOptions { + topic: string; + type?: string; + throttleRate?: number; + queueLength?: number; +} +``` + +订阅话题时的选项: + +- `topic: string;` —— 话题名,必填。 +- `type?: string;` —— 消息类型,**可选**(注意 `?`)。为什么可选?因为 rosbridge 通常能自己推断出话题类型,不一定要我们指定。 +- `throttleRate?: number;` —— **节流速率**,可选,单位毫秒。意思是"两条消息之间至少间隔这么久"。用于高频话题(如里程计 `/odom` 每秒几十条),限制频率避免被淹没。 +- `queueLength?: number;` —— 队列长度,可选。rosbridge 服务端为这个订阅缓存多少条消息。 + +--- + +## 第 37-46 行:服务调用的选项和结果 + +ROS2 的"服务(service)"是**请求-响应**模式:你发一个请求,它给你一个回应(类似打电话问一件事、对方答复)。 + +```typescript +export interface ServiceCallOptions { + service: string; + type?: string; + args?: Record; +} +``` + +调用服务的选项: + +- `service: string;` —— 服务名,必填。 +- `type?: string;` —— 服务类型,可选。 +- `args?: Record;` —— 请求参数,可选的任意对象(有的服务不需要参数)。 + +```typescript +export interface ServiceCallResult { + result: boolean; + values?: Record; +} +``` + +服务返回的结果: + +- `result: boolean;` —— 是否成功(`true`/`false`),必有。 +- `values?: Record;` —— 服务返回的实际数据,可选(有的服务只表示成功失败,没有数据要返回)。 + +--- + +## 第 50-60 行:动作的选项和结果 + +ROS2 的"动作(action)"用于**耗时较长、还能反馈进度、可中途取消**的任务(比如"导航到某点",可能要走几十秒,途中不断报告进度)。 + +```typescript +export interface ActionGoalOptions { + action: string; + actionType: string; + args?: Record; + onFeedback?: (feedback: Record) => void; +} +``` + +发送动作目标的选项: + +- `action: string;` —— 动作名,必填。 +- `actionType: string;` —— 动作类型,**必填**(注意这里没有 `?`,和服务不同——动作类型不能省略)。 +- `args?: Record;` —— 目标参数,可选(比如目标坐标)。 +- `onFeedback?: (feedback: Record) => void;` —— **可选的进度回调**。 + - 它的类型是一个函数(回调):接收一个 `feedback` 对象、返回 `void`。 + - 如果你提供了这个回调,那么动作服务器每推送一次进度,传输层就会调用你这个函数把进度交给你。 + - 如果不提供(可选),就单纯等动作做完、不接收中途进度。 + +```typescript +export interface ActionResult { + result: boolean; + values?: Record; +} +``` + +动作完成后的结果——结构和 `ServiceCallResult` 一模一样:`result`(成功与否)+ 可选的 `values`(结果数据)。 + +--- + +## 第 64-77 行:三种"自省"信息 + +"自省(introspection)"指**让机器人告诉我们它有哪些能力**——有哪些话题、服务、动作可用。这三个接口形状完全相同,都是"名字 + 类型": + +```typescript +export interface TopicInfo { + name: string; + type: string; +} + +export interface ServiceInfo { + name: string; + type: string; +} + +export interface ActionInfo { + name: string; + type: string; +} +``` + +- `TopicInfo` —— 一个话题的信息:`name`(如 `"/cmd_vel"`)+ `type`(如 `"geometry_msgs/msg/Twist"`)。 +- `ServiceInfo` —— 一个服务的信息,同样是名字 + 类型。 +- `ActionInfo` —— 一个动作的信息,同样是名字 + 类型。 + +> **为什么三个长得一样还要分别定义?** 虽然现在形状相同,但它们**含义不同**(一个是话题、一个是服务、一个是动作)。分开定义,代码读起来语义更清楚,而且将来某一个要加字段时不会牵连另外两个。 + +--- + +## 第 81-113 行:三种传输模式各自的配置 + +接下来定义三种连接模式各自需要的配置。**注意每个里面都有一个 `mode` 字段,且值是一个固定的字符串**——这是后面要讲的"判别联合"的关键。 + +### 第 81-89 行:rosbridge 模式配置 + +```typescript +export interface RosbridgeTransportConfig { + mode: "rosbridge"; + rosbridge: { + url: string; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; + }; +} +``` + +- `mode: "rosbridge";` —— **判别字段**。它的类型是字面量 `"rosbridge"`,意思是"这个对象的 `mode` 必须恰好是 `"rosbridge"`"。这个字段就像贴在盒子上的标签。 +- `rosbridge: { ... };` —— 一个**内联的嵌套对象类型**(回忆第①篇:简单对象类型可以直接就地写在花括号里,不必单独 `interface`)。里面: + - `url: string;` —— 连接地址,必填。 + - `reconnect?: boolean;` —— 是否重连,可选。 + - `reconnectInterval?: number;` —— 重连间隔,可选。 + - `maxReconnectAttempts?: number;` —— 最多重连几次,可选。 + +### 第 91-96 行:local 模式配置 + +```typescript +export interface LocalTransportConfig { + mode: "local"; + local?: { + domainId?: number; + }; +} +``` + +- `mode: "local";` —— 标签固定为 `"local"`。 +- `local?: { domainId?: number };` —— 可选的本地配置,里面 `domainId` 也可选。注意整个 `local` 字段都带 `?`——因为本地模式可以完全用默认值,啥也不配。 + +### 第 98-107 行:webrtc 模式配置 + +```typescript +export interface WebRTCTransportConfig { + mode: "webrtc"; + webrtc: { + signalingUrl: string; + apiUrl: string; + robotId: string; + robotKey: string; + iceServers?: RTCIceServerConfig[]; + }; +} +``` + +- `mode: "webrtc";` —— 标签固定为 `"webrtc"`。 +- `webrtc: { ... };` —— WebRTC 的配置,前四个字段都是必填字符串(信令地址、API 地址、机器人 ID、密钥),最后: + - `iceServers?: RTCIceServerConfig[];` —— 可选的 ICE 服务器**数组**(注意 `[]`),数组元素的类型是下面紧接着定义的 `RTCIceServerConfig`。 + +### 第 109-113 行:ICE 服务器配置 `RTCIceServerConfig` + +```typescript +export interface RTCIceServerConfig { + urls: string | string[]; + username?: string; + credential?: string; +} +``` + +- `urls: string | string[];` —— 地址,类型是 `string | string[]`,即"一个字符串 **或** 一个字符串数组"(对应第②篇 Zod 里那个 `z.union`)。 +- `username?` / `credential?` —— 可选的用户名和凭据。 + +> 你会发现这个接口和第②篇 `config.ts` 里的 `IceServerSchema` 描述的是同一种东西——一个是 Zod 运行时校验版,一个是 TS 类型版。 + +--- + +## 第 115-119 行:把三者合成"判别联合" `TransportConfig`(本章重点) + +```typescript +/** Discriminated union of all transport configurations. */ +export type TransportConfig = + | RosbridgeTransportConfig + | LocalTransportConfig + | WebRTCTransportConfig; +``` + +- 注释:「所有传输配置的判别联合」。 +- 用 `type` 定义 `TransportConfig`,它是上面三个配置接口用 `|`(或)连起来——意思是"一份传输配置,是这三种之一"。 + +**重点理解"判别联合(discriminated union)"——为什么它这么好用?** + +注意三个接口的共同点:它们都有一个 `mode` 字段,且值各不相同(`"rosbridge"` / `"local"` / `"webrtc"`)。这个充当"标签"的字段叫**判别字段(discriminant)**。 + +它带来的好处是:**TypeScript 能根据 `mode` 的值,自动推断出当前到底是哪一种配置**。看个例子(这正是后面 `factory.ts` 和 `service.ts` 会写的代码): + +```typescript +function 处理(config: TransportConfig) { + if (config.mode === "rosbridge") { + // 在这个 if 内部,TS 100% 确定 config 是 RosbridgeTransportConfig + // 所以你能安全访问 config.rosbridge.url,TS 不会报错 + console.log(config.rosbridge.url); + } else if (config.mode === "webrtc") { + // 这里 TS 又确定 config 是 WebRTCTransportConfig + console.log(config.webrtc.robotId); + } +} +``` + +这种"根据一个标签字段,自动缩小到具体类型"的能力叫**类型收窄(narrowing)**。它让我们写多模式处理代码时又安全又省心——不会出现"在 rosbridge 分支里手滑访问了 webrtc 才有的字段"这种错误,TS 会当场拦下。 + +--- + +## 整章回顾 + +这个文件是传输层的"数据字典",把所有跨模式共享的数据结构定义清楚,分几类: + +| 类别 | 类型 | 作用 | +|---|---|---| +| 连接 | `ConnectionStatus` / `MessageHandler` / `ConnectionHandler` / `Subscription` | 连接状态、两种回调函数、订阅句柄 | +| 发布 | `PublishOptions` | 发布消息要带的信息 | +| 订阅 | `SubscribeOptions` | 订阅话题的选项(含节流/队列) | +| 服务 | `ServiceCallOptions` / `ServiceCallResult` | 请求-响应式调用 | +| 动作 | `ActionGoalOptions` / `ActionResult` | 长任务,含进度回调 `onFeedback` | +| 自省 | `TopicInfo` / `ServiceInfo` / `ActionInfo` | 机器人能力发现 | +| 配置 | `RosbridgeTransportConfig` / `LocalTransportConfig` / `WebRTCTransportConfig` / `RTCIceServerConfig` / `TransportConfig` | 三种模式配置 + 判别联合 | + +这些类型本身不"做"任何事,但它们是下一篇 `transport.ts`(统一接口)和整个传输层所有文件的"共同语言"。读懂它们,后面的代码才有意义。 + +**语法点回顾清单**(本章新增/巩固): +- 字面量联合当"轻量枚举"用:`"a" | "b" | "c"` +- 回调函数 / 函数类型:`(参数) => void` +- "句柄对象"的设计思路(返回一个能操作资源的小对象) +- "选项对象"参数风格(`XxxOptions`)vs 散参数 +- 内联嵌套对象类型 `字段: { 子字段: 类型 }` +- `string | string[]`:单个值或数组 +- **判别联合**:用共同的标签字段(如 `mode`)区分多种形状 +- **类型收窄**:`if (x.mode === "...")` 后 TS 自动确定具体类型 + +下一份:[`transport/transport.ts` 逐行详解 →](04-transport.ts.md)(传输层的统一接口契约) + + + +--- + + +# 逐行详解 ④:`transport/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/transport.ts](../../extensions/openclaw-plugin/src/transport/transport.ts) +> +> 推荐阅读顺序第 4 个文件。它定义了**整个传输层的"统一接口" `RosTransport`**——这是全项目最重要的一个接口之一。它依旧是纯类型声明(没有实现),但它是所有传输模式(rosbridge / local / webrtc)都必须遵守的"契约"。 + +--- + +## 先理解这个文件的地位 + +回忆第③篇讲的:传输层有三种底层实现方式,但上层工具希望"用一样的方式操作"。 + +这个文件就是定义那个"一样的方式"——它写出一份接口 `RosTransport`,规定"任何一个传输实现,都必须提供这些方法(连接、发布、订阅、调用服务……)"。 + +打个比方: + +- `RosTransport` 是**插座标准**(规定插孔形状、电压)。 +- rosbridge、local、webrtc 三种实现是**三种不同的发电方式**(火电、水电、太阳能)。 +- 上层工具是**电器**。 + +只要三种发电方式都按同一个插座标准供电,电器插上去就能用,根本不关心电是怎么发的。这就是"接口/抽象"最核心的价值:**让使用者和实现者解耦**。 + +--- + +## 第 1-15 行:导入一堆类型 + +```typescript +import type { + ConnectionStatus, + ConnectionHandler, + Subscription, + PublishOptions, + SubscribeOptions, + ServiceCallOptions, + ServiceCallResult, + ActionGoalOptions, + ActionResult, + TopicInfo, + ServiceInfo, + ActionInfo, + MessageHandler, +} from "./types.js"; +``` + +- `import type { ... } from "./types.js"` —— 从同目录的 `types.js` 导入一批**类型**(`import type` 表示只拿类型,回忆第①篇)。 +- 花括号里换行列出了 13 个名字——全是上一篇 `types.ts` 里定义的那些类型。这里把它们都拿过来,因为下面定义接口时要用到。 +- **语法小课堂:为什么是 `"./types.js"` 而不是 `"./types.ts"`?** + - `./` 表示"当前目录"(`../` 表示"上一级目录")。所以 `./types` 指"本文件旁边那个 types 文件"。 + - 后缀写 `.js` 而不是 `.ts`,看起来很反直觉(我们明明导入的是 `.ts` 文件)。这是现代 ESM(ES 模块)标准 + 本项目 `NodeNext` 模块解析的规矩:**导入路径要写最终运行时的扩展名 `.js`**,即便源文件是 `.ts`。TS 编译时会自动对应过去。你现在只要记住"本项目内部导入一律写 `.js` 后缀"即可,照抄不会错。 + +--- + +## 第 17-23 行:接口的文档注释 + +```typescript +/** + * Unified transport interface for ROS2 communication. + * + * All deployment modes (local DDS, rosbridge WebSocket, WebRTC data channel) + * implement this interface so that plugin tools work identically regardless + * of the underlying transport. + */ +``` + +翻译: +> ROS2 通信的统一传输接口。所有部署模式(本地 DDS、rosbridge WebSocket、WebRTC 数据通道)都实现这个接口,这样插件工具无论底层用哪种传输,操作方式都完全一致。 + +这正是上面"插座标准"那个比方的官方表述。 + +--- + +## 第 24 行:接口声明开始 + +```typescript +export interface RosTransport { +``` + +- 导出一个接口 `RosTransport`。从这里到第 70 行的 `}` 之间,列出所有"任何传输实现都必须提供的方法"。 +- 接口里只写**方法的签名**(名字、参数、返回类型),不写方法体——因为这是"要求",不是"实现"。具体怎么连、怎么发,是各个实现文件的事。 + +下面按功能分组逐方法看。每个方法前面都有一行 `/** ... */` 注释说明它干嘛。 + +--- + +## 第 25-37 行:连接生命周期(4 个方法) + +```typescript + // --- Connection lifecycle --- + + /** Establish the transport connection. */ + connect(): Promise; +``` + +- `connect(): Promise;` —— 「建立传输连接」。 + - 无参数。 + - 返回 `Promise`:这是个**异步**操作(连接需要时间),将来完成、但不返回具体值。 + - 回忆第①篇:`Promise` 是"将来才有的结果",`void` 是"没有结果",合起来就是"将来会做完,但不给你东西"。 + +```typescript + /** Gracefully close the transport connection. */ + disconnect(): Promise; +``` + +- `disconnect(): Promise;` —— 「优雅地关闭连接」。同样异步、无返回值。"优雅地(gracefully)"意味着会做好清理(通知服务端、释放资源),而不是粗暴切断。 + +```typescript + /** Get current connection status. */ + getStatus(): ConnectionStatus; +``` + +- `getStatus(): ConnectionStatus;` —— 「获取当前连接状态」。 + - 注意返回类型是 `ConnectionStatus`(上一篇那个三选一),**不是 `Promise`**——因为查询当前状态是**瞬间**就能拿到的,不需要等待,所以是同步方法。 + - **对比体会**:`connect()` 要等所以返回 `Promise`;`getStatus()` 立刻有答案所以直接返回值。是否包 `Promise`,取决于"这事要不要等"。 + +```typescript + /** Register a connection status change handler. Returns a cleanup function. */ + onConnection(handler: ConnectionHandler): () => void; +``` + +- `onConnection(handler: ConnectionHandler): () => void;` —— 「注册一个连接状态变化的处理器。返回一个清理函数」。逐部分看: + - 参数 `handler: ConnectionHandler` —— 接收一个回调(上一篇定义的 `ConnectionHandler`,即 `(status) => void`)。意思是"你给我一个函数,状态一变我就调用它"。 + - 返回类型 `() => void` —— **重点**:返回的是**一个函数**! + - `() => void` 是函数类型:无参数、无返回值的函数。 + - 注释说这是"cleanup function(清理函数)"。意思是:你登记了一个监听器,`onConnection` 给你回一个"取消登记"的小函数。将来你不想再听了,调用这个返回的函数就能注销。 + - **语法小课堂:函数可以"返回另一个函数"。** 在 JS/TS 里函数是"一等公民"——可以当参数传、可以当返回值返回、可以存进变量。这里 `onConnection` 接收一个函数(`handler`)又返回一个函数(清理器),是非常典型的用法。和上一篇 `Subscription.unsubscribe()` 是同一个思路(给你一个"取消"的手段),只不过这里用"返回一个函数"实现,那里用"返回一个带方法的对象"实现。 + +--- + +## 第 39-45 行:话题(2 个方法) + +```typescript + // --- Topics --- + + /** Publish a message to a ROS2 topic. */ + publish(options: PublishOptions): void; +``` + +- `publish(options: PublishOptions): void;` —— 「向 ROS2 话题发布一条消息」。 + - 参数 `options: PublishOptions` —— 一个发布选项对象(上一篇定义的,含 `topic`/`type`/`msg`)。 + - 返回 `void`,而且**不是 `Promise`**!这很关键:发布是"**发完即忘(fire-and-forget)**"——把消息扔出去就完事,不等待机器人确认。ROS2 话题本来就是这种"广播"语义,没有回执。所以它既不异步也无返回。 + +```typescript + /** Subscribe to a ROS2 topic. Returns a Subscription handle. */ + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription; +``` + +- `subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription;` —— 「订阅一个话题,返回一个订阅句柄」。 + - 两个参数: + 1. `options: SubscribeOptions` —— 订阅选项(话题名、可选的类型/节流等)。 + 2. `handler: MessageHandler` —— 消息回调(上一篇定义的 `(msg) => void`)。每来一条消息就调用它。 + - 返回 `Subscription` —— 那个"订阅凭证"句柄(上一篇定义),拿着它可以 `unsubscribe()` 退订。 + - 同样**不是 `Promise`**:订阅这个动作是即时建立的,之后消息靠回调陆续送来,不需要"等一个结果"。 + +--- + +## 第 47-50 行:服务(1 个方法) + +```typescript + // --- Services --- + + /** Call a ROS2 service and return the result. */ + callService(options: ServiceCallOptions): Promise; +``` + +- `callService(options: ServiceCallOptions): Promise;` —— 「调用一个 ROS2 服务并返回结果」。 + - 参数 `options: ServiceCallOptions` —— 服务调用选项(服务名、类型、参数)。 + - 返回 `Promise` —— **这次是 `Promise` 了**!因为服务是"请求-响应":发出请求后要**等**对方答复,所以异步;将来兑现时给你一个 `ServiceCallResult`(含 `result` 成功与否、`values` 返回数据)。 + - **再次对比**:`publish` 不等(`void`),`callService` 要等(`Promise<结果>`)。是不是 `Promise`,永远取决于"需不需要等一个回应"。 + +--- + +## 第 52-58 行:动作(2 个方法) + +```typescript + // --- Actions --- + + /** Send a goal to a ROS2 action server. */ + sendActionGoal(options: ActionGoalOptions): Promise; +``` + +- `sendActionGoal(options: ActionGoalOptions): Promise;` —— 「向动作服务器发送一个目标」。 + - 参数 `options: ActionGoalOptions` —— 动作目标选项(动作名、类型、参数,以及可选的进度回调 `onFeedback`)。 + - 返回 `Promise` —— 异步,等动作**完全做完**后兑现,给你最终结果 `ActionResult`。 + - (动作可能耗时几十秒到几分钟,所以这个 `Promise` 可能要等很久。中途的进度则通过 `onFeedback` 回调实时给你,不走这个返回值。) + +```typescript + /** Cancel an in-progress action goal. */ + cancelActionGoal(action: string): Promise; +``` + +- `cancelActionGoal(action: string): Promise;` —— 「取消一个进行中的动作目标」。 + - 参数 `action: string` —— 要取消哪个动作(用动作名指定)。注意这里参数直接是个字符串,不是选项对象——因为只需要一个信息,没必要包装。 + - 返回 `Promise` —— 异步(取消请求要发给服务端并等其受理),做完不返回具体值。 + +--- + +## 第 60-69 行:自省(3 个方法) + +```typescript + // --- Introspection --- + + /** List all available ROS2 topics. */ + listTopics(): Promise; +``` + +- `listTopics(): Promise;` —— 「列出所有可用话题」。 + - 无参数。 + - 返回 `Promise` —— 异步(要向机器人查询),兑现时给一个 `TopicInfo` **数组**(注意 `[]`),即一串"话题名+类型"。 + +```typescript + /** List all available ROS2 services. */ + listServices(): Promise; + + /** List all available ROS2 action servers. */ + listActions(): Promise; +} +``` + +- `listServices(): Promise;` —— 列出所有服务,返回 `ServiceInfo` 数组。 +- `listActions(): Promise;` —— 列出所有动作,返回 `ActionInfo` 数组。 +- 最后的 `}` 关闭 `interface RosTransport` 的花括号,接口定义完毕。 + +这三个方法就是第⑫篇"机器人上下文注入"会用到的——会话开始时并行调用它们,把机器人能力查出来喂给 AI。 + +--- + +## 一张表看懂所有方法(以及"要不要等"的规律) + +| 方法 | 参数 | 返回 | 要等吗? | +|---|---|---|---| +| `connect()` | 无 | `Promise` | 要等(连接耗时) | +| `disconnect()` | 无 | `Promise` | 要等 | +| `getStatus()` | 无 | `ConnectionStatus` | **不等**(瞬间查询) | +| `onConnection(handler)` | 回调 | `() => void`(清理函数) | **不等**(登记动作) | +| `publish(options)` | 选项 | `void` | **不等**(发完即忘) | +| `subscribe(options, handler)` | 选项+回调 | `Subscription`(句柄) | **不等**(建立订阅) | +| `callService(options)` | 选项 | `Promise` | 要等(请求-响应) | +| `sendActionGoal(options)` | 选项 | `Promise` | 要等(长任务) | +| `cancelActionGoal(action)` | 字符串 | `Promise` | 要等 | +| `listTopics/Services/Actions()` | 无 | `Promise<...[]>` | 要等(远程查询) | + +**规律总结**:凡是"需要等机器人/服务端回应"的,返回 `Promise`;凡是"本地瞬间完成或发完不管"的,直接返回值。读接口时养成习惯——看到 `Promise` 就知道"这步要 `await` 等待",看到直接返回就知道"这步立刻有结果"。 + +--- + +## 整章回顾 + +- `RosTransport` 是**全项目传输层的统一契约**。它用纯接口规定了 13 个方法,覆盖:连接生命周期、话题、服务、动作、自省五大块。 +- 它的价值是**解耦**:上层工具只认这个接口,底层换成 rosbridge / local / webrtc 任何一种都行,工具代码一行不用改。 +- 后面你会看到: + - `rosbridge/adapter.ts` 写 `class RosbridgeTransport implements RosTransport`(用 `implements` 声明"我实现了这份契约")。 + - `local/transport.ts`、`webrtc/transport.ts` 同样 `implements RosTransport`。 + - 三者方法签名必须和这里完全对得上,否则 TS 报错——这就是接口"强制约束实现"的作用。 + +**语法点回顾清单**(本章新增/巩固): +- 内部导入路径写 `.js` 后缀(即便源文件是 `.ts`)、`./` 当前目录、`../` 上级目录 +- 接口里只写方法签名、不写方法体 +- "返回一个函数":`(): () => void`(函数是一等公民,可当返回值) +- 同步 vs 异步的判断:看返回值是不是 `Promise`,本质看"要不要等回应" +- 用注释(`/** */`)给每个方法写一句说明的习惯 + +下一份:[`transport/rosbridge/types.ts` 逐行详解 →](05-rosbridge-types.ts.md)(开始进入唯一完整实现的 rosbridge,先看它的协议消息格式) + + + +--- + + +# 逐行详解 ⑤:`transport/rosbridge/types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/types.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/types.ts) +> +> 推荐阅读顺序第 5 个文件。它把 **rosbridge 协议**里"在 WebSocket 上来回传的每一种消息长什么样"用类型定义出来。理解它,你才能看懂下一篇 `client.ts` 里收发的那些 `{ op: "..." }` 对象。 + +--- + +## 先理解 rosbridge 协议是什么 + +我们的插件和机器人之间隔着一个叫 **rosbridge_server** 的中间件,它把 ROS2 的世界"翻译"成 WebSocket 上的 **JSON 消息**。双方约定:每条消息都是一个 JSON 对象,且都有一个 `op` 字段(operation,操作类型),用 `op` 来表示"这条消息要干什么"。 + +比如: + +```json +{ "op": "publish", "topic": "/cmd_vel", "msg": { ... } } // 我要发布 +{ "op": "subscribe", "topic": "/odom" } // 我要订阅 +{ "op": "call_service", "id": "x_1", "service": "/reset" } // 我要调用服务 +``` + +这个文件就是把这些消息格式逐一写成 TS 接口。 + +> 第 1-4 行注释,连同 `@see` 给了官方协议文档链接。`@see` 是 JSDoc 标记,意思是"参见此处"。 + +--- + +## 第 8-15 行:客户端选项 & 连接状态 + +```typescript +export interface RosbridgeClientOptions { + url: string; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export type ConnectionStatus = "disconnected" | "connecting" | "connected"; +``` + +- `RosbridgeClientOptions` —— 创建 WebSocket 客户端时的配置:`url`(必填,连接地址)+ 三个可选项(是否重连、重连间隔、最多重连几次)。下一篇 `client.ts` 的构造函数就吃这个。 +- `ConnectionStatus` —— 和第③篇那个一模一样的三选一状态。(这里又定义了一遍,是因为 rosbridge 模块想"自给自足",不强依赖上层类型。) + +--- + +## 第 19-22 行:所有消息的"基类" `RosbridgeMessage` + +```typescript +export interface RosbridgeMessage { + op: string; + id?: string; +} +``` + +- `op: string;` —— 每条 rosbridge 消息**都有**的操作类型字段。 +- `id?: string;` —— 可选的消息 ID(用于把"请求"和"响应"配对,后面服务/动作会用到)。 + +这个接口是后面所有具体消息的"共同底座"。下面马上用到一个新语法把它"继承"过去。 + +--- + +## 第 26-31 行:发布消息 `PublishMessage`(第一次见 `extends`) + +```typescript +export interface PublishMessage extends RosbridgeMessage { + op: "publish"; + topic: string; + msg: Record; + type?: string; +} +``` + +**语法小课堂:`interface A extends B` 是"接口继承"。** +`extends` 意思是"扩展/继承"。`PublishMessage extends RosbridgeMessage` 表示:"`PublishMessage` 拥有 `RosbridgeMessage` 的所有字段(`op`、`id?`),并在此基础上**再加/收紧**一些字段。" 这样就不用把 `op`、`id` 在每个消息里重写一遍——继承自基座即可。 + +逐字段看: + +- `op: "publish";` —— **覆盖收紧**。基座里 `op` 是宽泛的 `string`,这里把它收紧成字面量 `"publish"`,意思是"这种消息的 `op` 必须恰好是 `publish`"。这又是判别字段(回忆第③篇判别联合)。 + - **语法小课堂:子接口可以"收紧"父接口的字段。** `"publish"` 是 `string` 的一个具体子集,所以这种收紧是合法的——把"任意文字"缩小为"只能是 publish 这个文字"。 +- `topic: string;` —— 发布到哪个话题。 +- `msg: Record;` —— 消息体(任意结构)。 +- `type?: string;` —— 可选的消息类型。 + +合起来,`PublishMessage` 实际拥有的字段是:`op`(="publish")、`id?`(继承来的)、`topic`、`msg`、`type?`。 + +--- + +## 第 33-46 行:订阅 / 取消订阅 + +```typescript +export interface SubscribeMessage extends RosbridgeMessage { + op: "subscribe"; + topic: string; + type?: string; + throttle_rate?: number; + queue_length?: number; + fragment_size?: number; + compression?: string; +} +``` + +- `op: "subscribe";` —— 标签固定为 `"subscribe"`。 +- `topic` 必填,其余全可选。 +- **注意字段名的风格**:`throttle_rate`、`queue_length`、`fragment_size` 用了**下划线命名(snake_case)**,而不是项目里常见的**驼峰命名(camelCase,如 `throttleRate`)**。 + - **语法小课堂:命名风格。** `camelCase`(小驼峰,单词间用大写字母分隔,如 `throttleRate`)是 JS/TS 的习惯;`snake_case`(蛇形,单词间用下划线,如 `throttle_rate`)是很多其它语言/协议的习惯。 + - **为什么这里用下划线?** 因为这些字段**必须严格匹配 rosbridge 协议规定的名字**——协议用的就是 `throttle_rate`。我们的内部类型(第③篇 `SubscribeOptions`)用 `throttleRate`(驼峰),到了这一层贴着协议就得改成 `throttle_rate`。这个"驼峰 ↔ 下划线"的转换,下一批的 `topics.ts` 会做。 + +```typescript +export interface UnsubscribeMessage extends RosbridgeMessage { + op: "unsubscribe"; + topic: string; +} +``` + +- 取消订阅:标签 `"unsubscribe"` + 一个 `topic`。极简。 + +```typescript +export interface TopicMessage extends RosbridgeMessage { + op: "publish"; + topic: string; + msg: Record; +} +``` + +- `TopicMessage` —— **服务端推给我们的话题消息**。它的 `op` 也是 `"publish"`(rosbridge 协议里,服务端把订阅到的消息也用 `publish` 操作推回来)。结构和 `PublishMessage` 几乎一样,只是少了 `type`。语义上它代表"收到的消息"而非"我要发的消息"。 + +--- + +## 第 56-68 行:服务调用 / 响应 + +```typescript +export interface ServiceCallMessage extends RosbridgeMessage { + op: "call_service"; + service: string; + args?: Record; + type?: string; +} +``` + +- 调用服务的请求:标签 `"call_service"`,带服务名、可选参数、可选类型。它会继承 `id?`——而且服务调用**强烈依赖** `id`,因为要靠它把响应配对回来。 + +```typescript +export interface ServiceResponseMessage extends RosbridgeMessage { + op: "service_response"; + service: string; + values?: Record; + result: boolean; +} +``` + +- 服务的响应:标签 `"service_response"`,带服务名、可选的返回数据 `values`、以及 `result`(成功与否)。服务端回这条时会带上和请求相同的 `id`,我们据此知道"这是哪个请求的回复"。 + +--- + +## 第 72-95 行:动作的四种消息 + +动作(长任务)有四种消息:发目标、收进度、收结果、取消。 + +```typescript +export interface ActionGoalMessage extends RosbridgeMessage { + op: "send_action_goal"; + action: string; + action_type: string; + args?: Record; +} +``` + +- 发送动作目标:标签 `"send_action_goal"`,带动作名、动作类型(`action_type`,又是下划线命名贴协议)、可选参数。 + +```typescript +export interface ActionFeedbackMessage extends RosbridgeMessage { + op: "action_feedback"; + action: string; + values: Record; +} +``` + +- 进度反馈(服务端推来):标签 `"action_feedback"`,带动作名和 `values`(这里 `values` **没有** `?`,是必有的——既然推了反馈,就一定带内容)。 + +```typescript +export interface ActionResultMessage extends RosbridgeMessage { + op: "action_result"; + action: string; + values?: Record; + result: boolean; +} +``` + +- 最终结果:标签 `"action_result"`,结构和服务响应类似(可选 `values` + `result`)。同样靠 `id` 配对。 + +```typescript +export interface ActionCancelMessage extends RosbridgeMessage { + op: "cancel_action_goal"; + action: string; +} +``` + +- 取消动作:标签 `"cancel_action_goal"` + 动作名。 + +--- + +## 第 99-112 行:自省信息(又是三个一样的) + +```typescript +export interface TopicInfo { name: string; type: string; } +export interface ServiceInfo { name: string; type: string; } +export interface ActionInfo { name: string; type: string; } +``` + +和第③篇 `transport/types.ts` 里那三个同名接口结构相同(名字 + 类型)。rosbridge 模块自己再定义一份,保持模块独立。 + +--- + +## 第 116-118 行:两个回调类型 + +```typescript +export type MessageHandler = (msg: Record) => void; +export type ConnectionHandler = (status: ConnectionStatus) => void; +``` + +和第③篇那两个回调类型一模一样:消息处理器、连接状态处理器。下一篇 `client.ts` 会大量用到。 + +--- + +## 整章回顾 + +这个文件是 rosbridge 协议的"TypeScript 镜像"。核心规律只有一条: + +> **每种消息都继承自 `RosbridgeMessage`(拿到 `op`、`id?`),再用字面量把 `op` 收紧成自己专属的操作名,并加上该消息特有的字段。** + +| 我们 → 服务端(请求) | 服务端 → 我们(响应/推送) | +|---|---| +| `PublishMessage` (op=publish) | `TopicMessage` (op=publish,收到的话题消息) | +| `SubscribeMessage` (op=subscribe) | `ServiceResponseMessage` (op=service_response) | +| `UnsubscribeMessage` (op=unsubscribe) | `ActionFeedbackMessage` (op=action_feedback) | +| `ServiceCallMessage` (op=call_service) | `ActionResultMessage` (op=action_result) | +| `ActionGoalMessage` (op=send_action_goal) | | +| `ActionCancelMessage` (op=cancel_action_goal) | | + +记住这张"消息对照表",下一篇 `client.ts` 里 `switch (msg.op)` 处理各种 `case` 时你就一眼能对上号。 + +**语法点回顾清单**(本章新增): +- `interface A extends B`:接口继承(拿到 B 的所有字段再扩展) +- 子接口"收紧"父字段:把 `op: string` 收紧为 `op: "publish"` +- 命名风格:`camelCase`(驼峰,JS 习惯)vs `snake_case`(下划线,贴协议) +- `@see` JSDoc 标记 + +下一份:[`transport/rosbridge/client.ts` 逐行详解 →](06-rosbridge-client.ts.md)(全项目最复杂的文件,第一次大量出现真正的执行逻辑) + + + +--- + + +# 逐行详解 ⑥:`transport/rosbridge/client.ts`(最难的一篇) + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/client.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/client.ts) +> +> 推荐阅读顺序第 6 个文件,也是**全项目最复杂、最值得细读**的一个。它是底层 WebSocket 连接的完整管理者,负责四件事:**①连接与重连 ②消息路由 ③请求-响应追踪 ④重连后恢复订阅**。 +> +> 这是第一篇出现大量"真正执行逻辑"的文件。语法密度很高,所以我先用一节把贯穿全文的几个大概念讲透,再逐行走。**别急,慢慢来。** + +--- + +## 开篇预备:5 个贯穿全文件的大语法 + +读这个文件前,先建立这 5 个概念,后面就顺了。 + +### 预备 1:什么是"类(class)"? + +前面我们用 `interface` 描述"形状",但形状不会自己干活。**`class`(类)才是"既有数据、又有能做事的方法、还能被造出实例"的东西。** + +打个比方:`class` 是"图纸"(比如汽车设计图),按图纸造出来的具体东西叫"**实例(instance)**"或"对象"(比如一辆真车)。同一个 class 能造很多实例。 + +```typescript +class 计数器 { + count = 0; // 这是"字段"——每个实例自带的数据 + increase() { // 这是"方法"——实例能做的事 + this.count = this.count + 1; + } +} + +const c = new 计数器(); // new 关键字:按图纸造一个实例 +c.increase(); // 调用方法,c.count 变成 1 +``` + +- **`new`** 关键字用来"造一个实例"。 +- 实例自带数据(字段)和能力(方法)。 + +本文件就是定义了一个 `class RosbridgeClient`——一个能管理 WebSocket 连接的"机器"。 + +### 预备 2:`this` 是什么? + +在 class 的方法内部,**`this` 指"当前这个实例自己"**。`this.count` 就是"我这个实例的 count 字段",`this.connect()` 就是"调用我自己的 connect 方法"。 + +为什么需要 `this`?因为同一个 class 能造很多实例,每个实例的数据各不相同。方法里必须用 `this` 才能指明"操作的是我这个实例的数据,不是别人的"。 + +**只要看到 `this.xxx`,就读成"我自己的 xxx"。** 这是本文件出现最频繁的写法。 + +### 预备 3:`Map` 和 `Set` 是什么? + +它们是 JS 内置的两种"容器": + +- **`Map`(映射表)**:存"键 → 值"的对应关系,像一本字典。 + - `map.set(键, 值)` 存一条;`map.get(键)` 取值;`map.has(键)` 问"有没有这个键";`map.delete(键)` 删一条;`map.keys()` 拿所有键。 +- **`Set`(集合)**:存"一堆不重复的值",像一个无序、去重的清单。 + - `set.add(值)` 加一个;`set.delete(值)` 删一个;`set.has(值)` 问"在不在"。 + - 同一个值加两次只算一个(自动去重)。 + +本文件用 `Map` 记"话题名 → 哪些处理器"、用 `Set` 装"一组处理器"。 + +### 预备 4:`Promise` 的"构造"用法 + +第①篇讲过 `Promise` 是"将来才有的结果"。之前我们只是"接收"别人给的 Promise,这里要**亲手造一个**: + +```typescript +new Promise((resolve, reject) => { + // 这里写"异步操作" + // 成功时调用 resolve(结果) —— 兑现承诺 + // 失败时调用 reject(错误) —— 拒绝承诺 +}); +``` + +- `new Promise(...)` 造一个承诺。 +- 括号里传一个函数,这个函数会**立刻被执行**,它收到两个工具: + - `resolve` —— 你调用它,就表示"成功了",Promise 兑现,`await` 它的人就拿到结果继续。 + - `reject` —— 你调用它,表示"失败了",Promise 被拒绝,`await` 它的人会收到一个错误(异常)。 +- 这套机制专门用来**把"基于回调/事件的老式异步"包装成现代的 Promise**。WebSocket 是基于事件的(`onopen`、`onclose`……),所以这里要手动包一层。 + +### 预备 5:`setTimeout` 定时器 + +- `setTimeout(函数, 毫秒)` —— "等这么多毫秒后,执行一次这个函数"。它返回一个"定时器句柄"。 +- `clearTimeout(句柄)` —— "取消那个还没触发的定时器"。 + +本文件用它做"连接超时""请求超时""延迟重连"。 + +好,预备知识到此。下面逐行走。 + +--- + +## 第 1-8 行:导入 + +```typescript +import WebSocket from "ws"; +import type { + RosbridgeClientOptions, + ConnectionStatus, + RosbridgeMessage, + MessageHandler, + ConnectionHandler, +} from "./types.js"; +``` + +- `import WebSocket from "ws";` —— 从第三方库 `"ws"` 导入 `WebSocket`。 + - **语法小课堂:这是"默认导入"。** 注意它**没有花括号**(对比第②篇 `import { z }` 有花括号)。 + - `import { 名字 }` —— **具名导入**:拿这个库导出的、叫这个名字的东西。 + - `import 名字 from` —— **默认导入**:拿这个库的"默认导出"(一个库只能有一个默认导出),名字随你起。 + - `"ws"` 是 Node.js 上最常用的 WebSocket 库(浏览器自带 WebSocket,但 Node.js 服务端需要这个库补上)。 + - 注意这一行**没有** `type`——因为我们要在运行时真的 `new WebSocket(...)`,它是真实代码。 +- 第二段 `import type { ... } from "./types.js"` —— 从上一篇的 `types.ts` 导入 5 个类型(只导类型,故有 `type`)。 + +--- + +## 第 10-15 行:辅助接口 `PendingRequest` + +```typescript +/** Pending request/response tracker for service calls and action goals. */ +export interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} +``` + +注释:「用于服务调用和动作目标的"待响应请求"追踪器」。 + +这个接口描述"一个还在等回应的请求"需要记住哪三样东西: + +- `resolve: (value: unknown) => void;` —— 一个"兑现"函数(来自某个 Promise)。等回应到了,调用它就能让那个 Promise 成功。 +- `reject: (reason: Error) => void;` —— 一个"拒绝"函数。出错/超时就调用它让 Promise 失败。 + - **为什么要把 `resolve`/`reject` 存起来?** 这是本文件最核心的设计:我们发出一个服务请求时,造一个 Promise 并把它的 `resolve`/`reject` **存进一张表**;将来回应从 WebSocket 飘回来时,再从表里找出对应的 `resolve` 来兑现。这样就把"事件"和"Promise"接起来了(预备 4 的实战)。 +- `timer: ReturnType;` —— 这个请求的超时定时器句柄。 + - **语法小课堂:`ReturnType` 是什么?** 拆开看: + - `typeof setTimeout` —— 取 `setTimeout` 这个函数的类型(第②篇讲过 `typeof 值` 把值翻译成类型)。 + - `ReturnType<...>` —— TS 内置的"取一个函数的**返回值类型**"的工具。 + - 合起来:`setTimeout` 返回的那个定时器句柄是什么类型,这里就是什么类型。(在 Node.js 和浏览器里这个类型不一样,用 `ReturnType` 自动适配,不用写死。) + +--- + +## 第 17-21 行:类声明开始 + +```typescript +/** + * WebSocket client for the rosbridge protocol. + * Handles connection lifecycle, reconnection, and message routing. + */ +export class RosbridgeClient { +``` + +- 注释:「rosbridge 协议的 WebSocket 客户端。处理连接生命周期、重连和消息路由。」 +- `export class RosbridgeClient {` —— 导出一个类(预备 1)。从这里到第 317 行的 `}` 都是它的内容。 + +--- + +## 第 22-31 行:类的字段(实例自带的数据) + +```typescript + private ws: WebSocket | null = null; + private options: Required; + private status: ConnectionStatus = "disconnected"; + private messageHandlers = new Map>(); + private connectionHandlers = new Set(); + private pendingRequests = new Map(); + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private intentionalClose = false; + private idCounter = 0; +``` + +这是这个 class 的"随身数据"。每造一个 `RosbridgeClient` 实例,它就自带这 10 个字段。 + +**语法小课堂:`private` 是什么?** +`private` 表示"私有"——这个字段**只能在 class 内部用 `this.xxx` 访问,外部碰不到**。这是"封装":把内部细节藏起来,只对外暴露该暴露的方法。外面的人不该直接动 `ws`,只能调用 `connect()` 等公开方法。 + +逐字段看: + +- `private ws: WebSocket | null = null;` —— 真正的 WebSocket 连接对象。 + - 类型 `WebSocket | null`:要么是一个连接,要么是 `null`(空)。 + - **语法小课堂:`null` 是什么?** `null` 表示"空、没有东西"。一开始还没连接,所以是 `null`;连上后才赋成真正的 WebSocket。`= null` 是给字段的**初始值**。 + - 为什么类型要带 `| null`?因为它确实会在"没有"和"有"两种状态间切换,类型如实反映,TS 才会提醒你"用之前先检查是不是 null"。 +- `private options: Required;` —— 配置选项。 + - **语法小课堂:`Required` 是什么?** TS 内置工具,把一个类型里**所有可选字段变成必填**。`RosbridgeClientOptions` 里 `reconnect?`、`reconnectInterval?` 等是可选的;`Required<...>` 把它们全变必填。 + - 为什么?因为构造函数会给所有可选项都补上默认值(马上看到),补完之后它们就一定有值了,类型如实反映成"全必填",后面用起来不用再判断 `undefined`。 + - 注意这个字段**没有** `= 初始值`——因为它在构造函数里赋值(必须赋,否则 TS 报错)。 +- `private status: ConnectionStatus = "disconnected";` —— 当前连接状态,初始 `"disconnected"`(断开)。 +- `private messageHandlers = new Map>();` —— **消息处理器表**(预备 3 的 Map)。 + - 类型 `Map>`:键是 `string`(话题名),值是 `Set`(这个话题的一组处理器)。 + - 即"每个话题名 → 一堆订阅它的回调函数"。为什么值是 `Set` 而不是单个函数?因为同一话题可能被订阅多次,要存多个处理器,且去重。 + - `= new Map<...>()` 初始化成一个空 Map。 +- `private connectionHandlers = new Set();` —— **连接状态处理器集合**(一个 Set)。所有想知道"连接状态变了"的回调都丢这里。 +- `private pendingRequests = new Map();` —— **待响应请求表**(核心)。键是请求 ID,值是上面定义的 `PendingRequest`(含 resolve/reject/timer)。 +- `private reconnectAttempts = 0;` —— 已经重连了几次,初始 0。(用于指数退避计算。) +- `private reconnectTimer: ReturnType | null = null;` —— 重连定时器句柄,初始 `null`(没有在排队的重连)。 +- `private intentionalClose = false;` —— "是不是我主动关的",初始 `false`。 + - 用途:WebSocket 关闭时要判断——是用户主动断开(那就别重连),还是意外掉线(那就自动重连)。 +- `private idCounter = 0;` —— 生成唯一 ID 用的计数器,初始 0。每发一个请求就 +1。 + +--- + +## 第 33-40 行:构造函数 `constructor` + +```typescript + constructor(options: RosbridgeClientOptions) { + this.options = { + url: options.url, + reconnect: options.reconnect ?? true, + reconnectInterval: options.reconnectInterval ?? 3000, + maxReconnectAttempts: options.maxReconnectAttempts ?? 10, + }; + } +``` + +**语法小课堂:`constructor` 是什么?** +`constructor`(构造函数)是 class 里一个特殊方法,**在 `new RosbridgeClient(...)` 那一刻自动执行一次**,用来做"初始化"。它接收的参数,就是 `new` 时括号里传的东西。 + +- `constructor(options: RosbridgeClientOptions)` —— 造实例时要传一个配置对象 `options`。 +- 函数体:把传进来的 `options` 整理后,存到 `this.options`(我自己的 options 字段)。逐字段: + - `url: options.url,` —— url 直接照搬(它是必填的)。 + - `reconnect: options.reconnect ?? true,` —— **重点看 `??`**。 + - **语法小课堂:`??` 是"空值合并运算符"。** `a ?? b` 的意思是"如果 `a` 是 `null` 或 `undefined`(即'没有值'),就用 `b`,否则用 `a`"。 + - 所以 `options.reconnect ?? true`:用户传了 `reconnect` 就用用户的,没传(`undefined`)就用默认值 `true`。 + - **这就是"给可选项补默认值"的标准手法。** 补完之后 `this.options.reconnect` 一定有值,正好对应上面 `Required<...>` 把它变必填。 + - `reconnectInterval: options.reconnectInterval ?? 3000,` —— 没传就默认 3000 毫秒。 + - `maxReconnectAttempts: options.maxReconnectAttempts ?? 10,` —— 没传就默认最多重连 10 次。 + +> 你可能问:第②篇 `config.ts` 不是已经用 Zod 补过默认值了吗?这里为什么又补一遍?因为 `RosbridgeClient` 是个独立、自包含的类,它不假设别人一定帮它补好了默认值——自己再兜一次底,更健壮。 + +--- + +## 第 42-108 行:`connect()` —— 连接(含超时与重连触发) + +这是本文件最长的方法。它要"发起 WebSocket 连接,并把这个基于事件的过程包装成一个 Promise"。 + +```typescript + /** Connect to the rosbridge WebSocket server. */ + async connect(): Promise { + if (this.status === "connected") return; +``` + +- `async connect(): Promise` —— 一个**异步方法**,返回 `Promise`。 + - **语法小课堂:`async` 是什么?** 写在函数前的 `async` 表示"这是个异步函数"。它有两个效果:①函数自动返回一个 Promise;②函数内部可以用 `await` 等待别的 Promise。这里我们主要用它的"返回 Promise"特性。 +- `if (this.status === "connected") return;` —— **守卫语句**。 + - **语法小课堂:`if (条件) 语句` 是条件判断。** 条件为真才执行后面的语句。 + - **语法小课堂:`===` 是"严格相等"。** 判断两边是不是完全相等(值和类型都相同)。JS 里要用三个等号 `===`,不要用两个 `==`(`==` 有奇怪的隐式转换规则,容易出 bug,本项目一律用 `===`)。 + - 整句意思:如果已经连上了,就直接 `return`(什么都不做、提前结束)。避免重复连接。 + +```typescript + this.intentionalClose = false; + this.setStatus("connecting"); +``` + +- `this.intentionalClose = false;` —— 重置"主动关闭"标记(这次是要连接,不是要关)。 +- `this.setStatus("connecting");` —— 把状态改成"连接中"(`setStatus` 是下面定义的方法,它顺便会通知所有监听器)。 + +```typescript + return new Promise((resolve, reject) => { +``` + +- `return new Promise((resolve, reject) => { ... })` —— 造一个 Promise 并返回它(预备 4)。 + - 谁 `await connect()`,谁就会一直等,直到下面代码里调用了 `resolve()`(连上了)或 `reject(...)`(连接失败)。 + - `(resolve, reject) => { ... }` 是传给 Promise 的那个"立即执行的函数",箭头函数写法。 + +```typescript + const connectTimeout = setTimeout(() => { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + reject(new Error(`Connection to ${this.options.url} timed out`)); + }, 10_000); +``` + +- 这是**连接超时保护**:设一个 10 秒的定时器,如果 10 秒内还没连上,就放弃。 +- `const connectTimeout = setTimeout(() => { ... }, 10_000);` —— 设一个定时器(预备 5),10 秒后执行里面的函数。把句柄存进 `connectTimeout`,以便连上后取消它。 + - **语法小课堂:`10_000` 里的下划线是什么?** 纯粹是"数字分隔符",让大数字好读。`10_000` 就是 `10000`(一万),下划线被忽略。这里 10000 毫秒 = 10 秒。 +- 定时器到点后做的事: + - `if (this.ws) { this.ws.close(); this.ws = null; }` —— 如果已经创建了 WebSocket,就关掉它并置空。`if (this.ws)` 利用了"非 null 即为真"——`this.ws` 不是 `null` 时条件成立。 + - `reject(new Error(\`Connection to ${this.options.url} timed out\`));` —— 拒绝 Promise,报"连接超时"。 + - **语法小课堂:`new Error("...")` 是什么?** 造一个"错误对象",里面装一句错误描述。配合 `reject` 或 `throw` 用,告诉接收方"出了什么错"。 + - **语法小课堂:反引号 `` ` `` 和 `${...}` 是"模板字符串"。** 用反引号包起来的字符串里,可以用 `${表达式}` 嵌入变量的值。这里 `` `Connection to ${this.options.url} timed out` `` 会把 `this.options.url` 的实际地址填进去,得到类似 `"Connection to ws://localhost:9090 timed out"`。比用 `+` 拼接字符串清爽得多。 + +```typescript + try { + this.ws = new WebSocket(this.options.url); + } catch (err) { + clearTimeout(connectTimeout); + this.setStatus("disconnected"); + reject(err); + return; + } +``` + +- **语法小课堂:`try { ... } catch (err) { ... }` 是"异常捕获"。** + - `try` 块里放"可能出错的代码"。 + - 如果里面**抛出异常**,程序不会崩,而是跳到 `catch` 块,把错误对象给 `err`,让你处理。 +- 这里:尝试 `new WebSocket(地址)` 创建连接对象。如果连"创建"这一步就抛错(比如地址格式非法): + - `clearTimeout(connectTimeout);` —— 取消那个 10 秒超时定时器(不然它过会还会触发)。 + - `this.setStatus("disconnected");` —— 状态改回断开。 + - `reject(err);` —— 用捕获到的错误拒绝 Promise。 + - `return;` —— 提前结束这个 Promise 函数,别再往下走。 + +接下来是 WebSocket 的**四个事件回调**。WebSocket 是事件驱动的:连上了触发 `onopen`、来消息触发 `onmessage`、出错触发 `onerror`、关闭触发 `onclose`。我们给每个事件挂一个处理函数。 + +```typescript + this.ws.onopen = () => { + clearTimeout(connectTimeout); + this.reconnectAttempts = 0; + this.setStatus("connected"); + resolve(); + }; +``` + +- `this.ws.onopen = () => { ... };` —— 给"连接成功"事件挂回调。`onopen` 触发说明连上了: + - `clearTimeout(connectTimeout);` —— 取消超时定时器(已经连上,不需要超时了)。 + - `this.reconnectAttempts = 0;` —— 重连次数清零(连上了,之前的失败记录作废)。 + - `this.setStatus("connected");` —— 状态改成"已连接"。 + - `resolve();` —— **兑现 Promise!** 此刻 `await connect()` 的人才会拿到结果、继续往下走。 + +```typescript + this.ws.onmessage = (event) => { + const data = typeof event.data === "string" + ? event.data + : event.data.toString(); + this.handleMessage(data); + }; +``` + +- `this.ws.onmessage = (event) => { ... };` —— 给"收到消息"事件挂回调。每来一条消息触发一次,消息装在 `event` 里。 + - `const data = typeof event.data === "string" ? event.data : event.data.toString();` + - **语法小课堂:`条件 ? A : B` 是"三元运算符",等于一个迷你 if-else。** 读作"如果条件成立就取 A,否则取 B"。 + - `typeof event.data === "string"` —— 判断收到的数据是不是字符串。(`typeof 值` 在运行时会返回该值的类型名,如 `"string"`、`"number"`。注意这和第②篇类型层面的 `typeof` 是两个不同场景的同名用法。) + - 整句:如果数据本来就是字符串,直接用;否则(可能是二进制 Buffer)调 `.toString()` 转成字符串。无论哪种,最终 `data` 都是字符串。 + - `this.handleMessage(data);` —— 把这条消息文本交给自己的 `handleMessage` 方法去分类处理(后面会细看)。 + +```typescript + this.ws.onerror = (_event) => { + clearTimeout(connectTimeout); + if (this.status === "connecting") { + this.ws = null; + this.setStatus("disconnected"); + reject(new Error(`WebSocket error connecting to ${this.options.url}`)); + } + }; +``` + +- `this.ws.onerror = (_event) => { ... };` —— "出错"事件回调。 + - **语法小课堂:参数名前的下划线 `_event`。** 参数我们其实用不到(不关心具体错误事件内容),但 WebSocket 调用回调时会传它。约定俗成地在参数名前加 `_`,表示"我知道有这个参数,但我故意不用它",避免 lint 工具警告。 + - `if (this.status === "connecting")` —— 只有在"连接中"出错才特殊处理: + - 置空 ws、状态改断开、`reject` 报错——让正在 `await` 的 `connect()` 失败。 + - 如果是连上之后才出错,这里不 reject(因为那时 connect 的 Promise 早就 resolve 过了,一个 Promise 只能兑现或拒绝一次,再调用无效)。连上后的错误交给 `onclose` 处理。 + +```typescript + this.ws.onclose = () => { + clearTimeout(connectTimeout); + this.ws = null; + + if (this.status === "connecting") { + this.setStatus("disconnected"); + reject(new Error(`WebSocket closed during connection to ${this.options.url}`)); + return; + } + + this.setStatus("disconnected"); + this.rejectAllPending(new Error("WebSocket connection closed")); + + if (!this.intentionalClose && this.options.reconnect) { + this.attemptReconnect(); + } + }; +``` + +- `this.ws.onclose = () => { ... };` —— "连接关闭"事件回调(最重要的善后逻辑)。 + - `clearTimeout(connectTimeout);` + `this.ws = null;` —— 清超时定时器、置空连接。 + - 第一种情况——**在连接过程中就关了**: + - `if (this.status === "connecting") { ... reject(...); return; }` —— 说明还没连上就被关,视为连接失败:状态置断开、`reject` 报错、`return` 结束。 + - 第二种情况——**连上之后才断开**(代码走到下面): + - `this.setStatus("disconnected");` —— 状态改断开。 + - `this.rejectAllPending(new Error("WebSocket connection closed"));` —— **把所有"还在等回应的请求"全部拒绝**(连都断了,它们不可能再有回应,必须让它们失败,否则会一直挂着)。这个方法在文件末尾。 + - `if (!this.intentionalClose && this.options.reconnect) { this.attemptReconnect(); }` —— **判断要不要自动重连**: + - **语法小课堂:`!` 是"逻辑非"。** `!x` 把真变假、假变真。`!this.intentionalClose` 意思是"不是主动关闭的"。 + - **语法小课堂:`&&` 是"逻辑与"。** `A && B` 表示"A 和 B 都成立"才算成立。 + - 整句:如果"不是我主动关的" **并且** "配置允许重连",就调用 `attemptReconnect()` 启动自动重连。(用户主动 `disconnect()` 时会把 `intentionalClose` 设成 `true`,于是这里 `!true` = `false`,就不会重连——这正是我们想要的。) + +到这里,`connect()` 这个又长又关键的方法就读完了。**核心思想**:把 WebSocket 那套 `onopen/onmessage/onerror/onclose` 事件,巧妙地接到一个 Promise 的 `resolve/reject` 上,让外部能用 `await connect()` 优雅地等待。 + +--- + +## 第 110-136 行:`disconnect()` —— 优雅断开 + +```typescript + /** Disconnect from the rosbridge server. */ + async disconnect(): Promise { + this.intentionalClose = true; +``` + +- `this.intentionalClose = true;` —— **关键**:标记"这是我主动关的"。这样上面 `onclose` 里的重连判断 `!this.intentionalClose` 就会是 `false`,**不会触发自动重连**。 + +```typescript + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } +``` + +- 如果当前正排着一个"待执行的重连定时器",就取消它并置空。(既然要主动断开,就别再自动重连了。) + +```typescript + this.rejectAllPending(new Error("Client disconnected")); +``` + +- 把所有等待中的请求全部拒绝(报"客户端已断开")。理由同前:连接要没了,别让它们傻等。 + +```typescript + if (this.ws) { + const ws = this.ws; + this.ws = null; + + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + await new Promise((resolve) => { + ws.onclose = () => resolve(); + ws.close(); + // Force-resolve after 2s if server doesn't ack the close + setTimeout(resolve, 2000); + }); + } + } +``` + +- `if (this.ws) { ... }` —— 只有当前确实有连接时才处理。 + - `const ws = this.ws; this.ws = null;` —— 先把连接存到本地变量 `ws`,再把 `this.ws` 置空。 + - **为什么先存到本地变量?** 因为下面要异步等待,期间 `this.ws` 可能被别处改动;先抓一份到本地 `ws`,确保后面操作的是同一个连接对象,不受干扰。这是处理异步时常见的小心思。 + - `if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)` —— 检查连接是不是"开着"或"正在连"。 + - `readyState` 是 WebSocket 的内置属性,表示当前处于哪个阶段;`WebSocket.OPEN`、`WebSocket.CONNECTING` 是预定义的常量。 + - `||` 是**逻辑或**(A 或 B 任一成立即可)。 + - 如果是,就 `await` 一个新 Promise,优雅等待关闭完成: + - `ws.onclose = () => resolve();` —— 重新挂一个 onclose:连接真正关闭时兑现这个 Promise。 + - **语法小课堂:`() => resolve()` 这种箭头函数。** 它是一个"无参数、执行 `resolve()`"的小函数。等价于写 `() => { resolve(); }`,单行可省略花括号和分号。 + - `ws.close();` —— 真正发起关闭。 + - `setTimeout(resolve, 2000);` —— **兜底**:万一服务端不响应关闭、`onclose` 迟迟不触发,2 秒后强制 `resolve`,免得 `disconnect()` 永远卡住。 + - 注意这里直接传 `resolve` 而不是 `() => resolve()`——因为 `setTimeout` 到点会自动调用它,两种写法效果一样。 + - **语法小课堂:`await` 终于出场。** `await 某个Promise` 的意思是"在这里暂停,等这个 Promise 兑现/拒绝后再继续"。因为本方法是 `async`,才允许用 `await`。所以 `await new Promise(...)` 让 `disconnect()` 真正等到连接关闭(或 2 秒兜底)才往下走。 + +```typescript + this.setStatus("disconnected"); + } +``` + +- 最后把状态置为断开,方法结束。 + +--- + +## 第 138-149 行:`send()` 与 `nextId()` + +```typescript + /** Send a rosbridge protocol message. */ + send(message: RosbridgeMessage & Record): void { + if (!this.ws || this.status !== "connected") { + throw new Error("Not connected to rosbridge server"); + } + this.ws.send(JSON.stringify(message)); + } +``` + +- `send(message: RosbridgeMessage & Record): void` —— 发送一条 rosbridge 消息。 + - **语法小课堂:参数类型里的 `&` 是"交叉类型"。** `A & B` 表示"同时满足 A 和 B 两种类型"。这里 `RosbridgeMessage & Record` 意思是"既要有 `RosbridgeMessage` 规定的 `op`/`id`,又允许带任意其它字段"。因为不同消息额外字段不同,用 `& Record` 留出灵活空间。 + - `if (!this.ws || this.status !== "connected") { throw ... }` —— **守卫**:如果没有连接(`!this.ws`)**或**状态不是已连接(`this.status !== "connected"`,`!==` 是"严格不等"),就抛异常。不能往一个不存在/没连好的连接上发数据。 + - `this.ws.send(JSON.stringify(message));` —— 真正发送。 + - **语法小课堂:`JSON.stringify(对象)` 把一个 JS 对象变成 JSON 字符串。** WebSocket 只能传字符串/二进制,所以发之前要把对象"序列化"成文本。它的反操作是 `JSON.parse(字符串)`(把文本变回对象),下面 `handleMessage` 会用到。 + +```typescript + /** Generate a unique message ID. */ + nextId(prefix = "rosclaw"): string { + return `${prefix}_${++this.idCounter}`; + } +``` + +- `nextId(prefix = "rosclaw"): string` —— 生成一个唯一 ID(用来配对请求和响应)。 + - **语法小课堂:`prefix = "rosclaw"` 是"默认参数值"。** 调用时不传 `prefix` 就用 `"rosclaw"`,传了就用传的。 + - `return \`${prefix}_${++this.idCounter}\`;` —— 用模板字符串拼出 ID,形如 `"service_1"`、`"action_2"`。 + - **语法小课堂:`++this.idCounter` 是"前置自增"。** `++x` 表示"先把 x 加 1,再用加完的值"。所以第一次调用,`idCounter` 从 0 变 1,用的就是 1,得到 `xxx_1`;下次得到 `xxx_2`……保证每个 ID 都不重复。 + - (对比:`x++` 是"后置自增",先用旧值再加 1。这里用前置 `++this.idCounter`,所以从 1 开始而不是 0。) + +--- + +## 第 151-168 行:`onMessage()` 与 `onConnection()` —— 登记回调 + +```typescript + /** Subscribe to messages on a specific topic. */ + onMessage(topic: string, handler: MessageHandler): () => void { + if (!this.messageHandlers.has(topic)) { + this.messageHandlers.set(topic, new Set()); + } + this.messageHandlers.get(topic)!.add(handler); + return () => { + this.messageHandlers.get(topic)?.delete(handler); + }; + } +``` + +- `onMessage(topic, handler): () => void` —— 为某话题登记一个消息处理器。返回一个"取消登记"的函数(回忆第④篇:返回清理函数)。 + - `if (!this.messageHandlers.has(topic)) { this.messageHandlers.set(topic, new Set()); }` —— 如果这个话题在表里还没有对应的 Set,就先建一个空 Set 放进去。(确保下一步能往里加。) + - `this.messageHandlers.get(topic)!.add(handler);` —— 取出这个话题的 Set,往里加这个处理器。 + - **语法小课堂:`!` 这里是"非空断言"。** 注意这个 `!` 紧贴在 `.get(topic)` 后面、`.add` 前面,和前面"逻辑非"的 `!` 位置不同、含义也不同。`map.get(key)` 的类型是"值 或 undefined"(万一没这个键就是 undefined)。我们刚刚才确保过它一定有,所以用 `!` 告诉 TS:"我保证它不是 undefined,别报警"。它只是消除类型警告,运行时不做任何事。 + - 返回的清理函数: + - `return () => { this.messageHandlers.get(topic)?.delete(handler); };` —— 调用它就把这个处理器从 Set 里删掉。 + - **语法小课堂:`?.` 是"可选链"。** `a?.b` 意思是"如果 `a` 存在就取 `a.b`,如果 `a` 是 null/undefined 就整体返回 undefined、不报错"。这里 `this.messageHandlers.get(topic)?.delete(handler)`:万一这个话题的 Set 已经没了(get 返回 undefined),`?.` 让它安静地跳过,不会因为"对 undefined 调用 .delete"而崩溃。 + - 对比上一行的 `!`(我担保有,强行用)和这里的 `?.`(不确定有没有,没有就算了)——两种应对"可能是 undefined"的不同策略。 + +```typescript + /** Register a connection status change handler. */ + onConnection(handler: ConnectionHandler): () => void { + this.connectionHandlers.add(handler); + return () => { + this.connectionHandlers.delete(handler); + }; + } +``` + +- `onConnection(handler)` —— 登记一个"连接状态变化"回调。把 handler 加进 `connectionHandlers` 这个 Set,返回一个"删掉它"的清理函数。比上面简单,因为连接处理器不分话题,直接一个 Set 管全部。 + +--- + +## 第 170-173 行:`getStatus()` + +```typescript + /** Get current connection status. */ + getStatus(): ConnectionStatus { + return this.status; + } +``` + +- 直接返回当前状态。简单的"读取器"。注意它不是 `async`、不返回 Promise——查状态是瞬间的(呼应第④篇"要不要等"的规律)。 + +--- + +## 第 175-206 行:请求追踪三件套 `registerPending` / `resolvePending` / `rejectPending` + +这是把"事件"接回"Promise"的核心机制(呼应预备 4 和 `PendingRequest` 接口)。 + +```typescript + registerPending(id: string, resolve: (value: unknown) => void, reject: (reason: Error) => void, timeoutMs = 30_000): void { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pendingRequests.set(id, { resolve, reject, timer }); + } +``` + +- `registerPending(id, resolve, reject, timeoutMs = 30_000)` —— **登记一个"等待响应"的请求**。 + - 参数:请求 `id`、它对应 Promise 的 `resolve`/`reject`、超时毫秒数(默认 30 秒)。 + - `const timer = setTimeout(() => { ... }, timeoutMs);` —— 设一个超时定时器:如果到点了还没等到响应,就: + - `this.pendingRequests.delete(id);` —— 从表里删掉这条(不再等)。 + - `reject(new Error(\`Request ${id} timed out after ${timeoutMs}ms\`));` —— 拒绝对应 Promise,报超时。 + - `this.pendingRequests.set(id, { resolve, reject, timer });` —— 把 `{ resolve, reject, timer }` 三件套存进表,键是 `id`。 + - **语法小课堂:`{ resolve, reject, timer }` 是"对象简写"。** 当字段名和变量名相同时,可以省略 `字段: 值`,直接写变量名。它等价于 `{ resolve: resolve, reject: reject, timer: timer }`。 + +```typescript + resolvePending(id: string, result: unknown): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.resolve(result); + } + } +``` + +- `resolvePending(id, result)` —— **响应到了,兑现对应的请求**。 + - `const pending = this.pendingRequests.get(id);` —— 按 id 从表里取出那条登记。 + - `if (pending) { ... }` —— 如果找到了(不是 undefined): + - `clearTimeout(pending.timer);` —— 取消它的超时定时器(已经有响应了,不需要超时了)。 + - `this.pendingRequests.delete(id);` —— 从表里删除(处理完毕)。 + - `pending.resolve(result);` —— **调用当初存的 `resolve`,把响应结果交出去!** 此刻,当初发请求时 `await` 的那个地方就拿到结果、继续执行了。这就是"事件 → Promise"的接合点。 + +```typescript + rejectPending(id: string, error: Error): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.reject(error); + } + } +``` + +- `rejectPending(id, error)` —— 和上面几乎一样,只是最后调用 `pending.reject(error)`(让对应 Promise 失败)而不是 resolve。用于"明确知道这个请求失败了"的场景。 + +--- + +## 第 208-213 行:`setStatus()` —— 改状态并广播 + +```typescript + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const handler of this.connectionHandlers) { + handler(status); + } + } +``` + +- `private setStatus(status)` —— 私有方法,统一改连接状态。 + - `this.status = status;` —— 更新状态字段。 + - `for (const handler of this.connectionHandlers) { handler(status); }` —— **挨个通知所有登记过的连接处理器**。 + - **语法小课堂:`for (const x of 集合) { ... }` 是"遍历循环"。** 它把集合(数组/Set/Map 等)里的元素一个个取出来,每次叫 `x`,执行一遍循环体。这里把 `connectionHandlers` 这个 Set 里的每个处理器取出来叫 `handler`。 + - `handler(status);` —— 调用这个处理器,把新状态传给它。 + - 效果:状态一变,所有关心的人都立刻收到通知。前面 `connect`/`disconnect` 里那些 `this.setStatus(...)` 都会触发这个广播。 + +--- + +## 第 215-273 行:`handleMessage()` —— 消息路由(核心中的核心) + +每条从服务端飘来的消息都经过这里分类、分发。 + +```typescript + /** Route an incoming rosbridge message to the appropriate handler. */ + private handleMessage(data: string): void { + let msg: Record; + try { + msg = JSON.parse(data) as Record; + } catch { + return; // Ignore malformed messages + } +``` + +- `private handleMessage(data: string)` —— 私有方法,收到的原始文本 `data` 进来。 + - `let msg: Record;` —— 声明一个变量 `msg`(先不赋值)。 + - **语法小课堂:为什么这里用 `let` 不用 `const`?** 因为 `const` 必须声明时就赋值且不能再改;这里要"先声明、在 try 里才赋值",所以用可重新赋值的 `let`。 + - `try { msg = JSON.parse(data) as Record; } catch { return; }` + - `JSON.parse(data)` —— 把文本解析回对象(`JSON.stringify` 的逆操作)。 + - `as Record` —— **语法小课堂:`as 类型` 是"类型断言"。** `JSON.parse` 返回的类型是 `any`(什么都行),我们用 `as` 告诉 TS"把它当作一个键值对象看待"。断言只影响类型检查,不改变运行时的值。 + - `catch { return; }` —— **如果解析失败**(收到的不是合法 JSON),就直接 `return` 忽略这条坏消息,不让它搞崩整个客户端。注意这个 `catch` 后面没写 `(err)`——因为我们不关心错误内容,新语法允许省略。 + +```typescript + const op = msg.op as string | undefined; + if (!op) return; +``` + +- `const op = msg.op as string | undefined;` —— 取出消息的 `op` 字段,断言成"字符串或 undefined"。 +- `if (!op) return;` —— 如果没有 `op`(`undefined` 或空字符串都算"假",`!op` 为真),直接忽略。没有 op 的消息我们不认识。 + +```typescript + switch (op) { +``` + +- **语法小课堂:`switch (值) { case A: ... }` 是"多分支选择"。** 它根据 `值` 等于哪个 `case` 后面的常量,跳到对应分支执行。比写一长串 `if-else if` 更清爽。这里根据 `op` 是什么,分别处理不同消息。 + +```typescript + case "publish": { + // Incoming topic message — route to topic subscribers + const topic = msg.topic as string; + const payload = msg.msg as Record; + const handlers = this.messageHandlers.get(topic); + if (handlers) { + for (const handler of handlers) { + handler(payload); + } + } + break; + } +``` + +- `case "publish": { ... break; }` —— 当 `op` 是 `"publish"`(服务端推来一条话题消息): + - **语法小课堂:`case` 后面的 `{ }`。** 给 case 包一对花括号,是为了开一个独立的"作用域",让里面 `const topic` 等变量名只在本 case 有效,不和别的 case 撞名。 + - `const topic = msg.topic as string;` —— 取出话题名。 + - `const payload = msg.msg as Record;` —— 取出消息体(`payload` 意为"载荷/内容")。 + - `const handlers = this.messageHandlers.get(topic);` —— 从处理器表里查这个话题登记了哪些处理器。 + - `if (handlers) { for (const handler of handlers) { handler(payload); } }` —— 如果有处理器,就**挨个调用**,把消息内容 `payload` 发给每一个。这就是"消息分发"——谁订阅了这个话题,谁就被叫到。 + - `break;` —— **语法小课堂:`break` 跳出 switch。** 处理完这个 case 就退出 switch,不会"漏"到下一个 case。(漏到下一个是别的语言常见 bug 来源,所以每个 case 末尾都要 break。) + +```typescript + case "service_response": { + // Response to a call_service request + const id = msg.id as string | undefined; + if (id) { + this.resolvePending(id, msg); + } + break; + } +``` + +- `case "service_response":` —— 服务调用的响应回来了: + - 取出 `id`,如果有,就 `this.resolvePending(id, msg)`——按 id 找到当初那个等待的 Promise 并兑现它,把整条响应 `msg` 作为结果交回去。 + - **这就是请求-响应配对的闭环**:发请求时 `registerPending(id, ...)` 存入,响应回来时 `resolvePending(id, msg)` 取出兑现。 + +```typescript + case "action_result": { + // Final result of an action goal + const id = msg.id as string | undefined; + if (id) { + this.resolvePending(id, msg); + } + break; + } +``` + +- `case "action_result":` —— 动作的**最终结果**回来了。逻辑和服务响应完全一样:按 id 兑现对应 Promise。(动作的"等到做完"就靠这个。) + +```typescript + case "action_feedback": { + // Intermediate feedback for an action goal — route to feedback handlers + const id = msg.id as string | undefined; + if (id) { + const handlers = this.messageHandlers.get(`__action_feedback__${id}`); + if (handlers) { + for (const handler of handlers) { + handler(msg as Record); + } + } + } + break; + } + } + } +``` + +- `case "action_feedback":` —— 动作的**中途进度反馈**(这不是最终结果,所以不能 resolve Promise,而要分发给"进度回调"): + - 取出 `id`。 + - `const handlers = this.messageHandlers.get(\`__action_feedback__${id}\`);` —— **关键技巧**:进度反馈复用了 `messageHandlers` 这张表,但用一个**特殊的键** `__action_feedback__加上id` 来存,避免和普通话题名撞车。 + - 为什么?因为 feedback 的 `op` 是 `"action_feedback"` 不是 `"publish"`,走不了普通话题分发;又因为同一时间可能有多个动作在跑,要用 `id` 区分各自的进度。用 `__action_feedback__{id}` 这个独特的键正好两全。(下一批 `actions.ts` 登记 feedback 时用的就是同一个键。) + - 找到对应处理器后,挨个调用,把整条 feedback 消息发过去。 + - 最后两个 `}`:第一个关 switch,第二个关 `handleMessage` 方法。 + +> **小结 `handleMessage` 的分流逻辑**(对照第⑤篇的消息表): +> - `publish`(话题消息)→ 按话题名找订阅者分发。 +> - `service_response` / `action_result`(一锤子的响应)→ 按 id 兑现 Promise。 +> - `action_feedback`(连续的进度)→ 按特殊键找进度回调分发。 + +--- + +## 第 275-307 行:`attemptReconnect()` —— 指数退避重连 + +```typescript + /** Attempt to reconnect with exponential backoff. */ + private attemptReconnect(): void { + if (this.intentionalClose) return; + if (this.reconnectAttempts >= this.options.maxReconnectAttempts) return; + + this.reconnectAttempts++; +``` + +- `private attemptReconnect()` —— 尝试重连。 + - `if (this.intentionalClose) return;` —— 如果是主动关的,不重连。 + - `if (this.reconnectAttempts >= this.options.maxReconnectAttempts) return;` —— 如果已经试够了最大次数(`>=` 是"大于等于"),放弃。 + - `this.reconnectAttempts++;` —— 重连次数加 1(这里用后置 `++`,加不加在这无所谓,因为不取它的返回值)。 + +```typescript + // Exponential backoff: interval * 2^(attempt-1), capped at 30s + const delay = Math.min( + this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), + 30_000, + ); +``` + +- 计算这次要等多久才重连,用**指数退避**策略(越失败越多、等得越久,避免疯狂重试把服务端打垮): + - `Math.pow(2, this.reconnectAttempts - 1)` —— **语法小课堂:`Math.pow(底, 指数)` 是"乘方"。** 这里算 `2 的 (重连次数-1) 次方`:第 1 次失败 = `2^0 = 1`,第 2 次 = `2^1 = 2`,第 3 次 = `2^2 = 4`……翻倍增长。 + - `this.options.reconnectInterval * 那个乘方` —— 乘上基础间隔(默认 3000 毫秒)。于是:3000 → 6000 → 12000 → 24000…… + - `Math.min(那个值, 30_000)` —— **语法小课堂:`Math.min(a, b)` 取两者中较小的。** 这里给延迟设了**上限 30 秒**:无论翻倍到多大,最多等 30 秒。 + - 最终 `delay` = "本次重连前要等待的毫秒数"。 + +```typescript + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + if (this.intentionalClose) return; + + try { + await this.connect(); + // Re-subscribe to all active topics on successful reconnect + for (const topic of this.messageHandlers.keys()) { + if (topic.startsWith("__action_feedback__")) continue; + this.send({ + op: "subscribe", + id: this.nextId("resub"), + topic, + }); + } + } catch { + // connect() failed — onclose will trigger another attemptReconnect + } + }, delay); + } +``` + +- `this.reconnectTimer = setTimeout(async () => { ... }, delay);` —— 设一个延迟 `delay` 毫秒的定时器,到点执行重连。 + - **语法小课堂:`setTimeout(async () => {...})`——定时器回调本身是 `async`。** 因为里面要 `await this.connect()`,所以这个箭头函数前面要加 `async`。 + - 把定时器句柄存进 `this.reconnectTimer`(前面 `disconnect` 时能取消它)。 +- 定时器到点后: + - `this.reconnectTimer = null;` —— 清掉句柄(这个定时器已经触发了)。 + - `if (this.intentionalClose) return;` —— 等待期间如果用户主动断开了,就别连了。 + - `try { ... } catch { ... }` 包住重连尝试: + - `await this.connect();` —— 重新连接,等它完成。 + - 连上之后,**恢复所有订阅**(关键!因为服务端重启/重连后不记得我们之前订阅过什么): + - `for (const topic of this.messageHandlers.keys()) { ... }` —— 遍历处理器表里所有的话题键(`.keys()` 拿到 Map 的所有键)。 + - `if (topic.startsWith("__action_feedback__")) continue;` —— **语法小课堂:`字符串.startsWith("前缀")` 判断是否以某前缀开头;`continue` 跳过本次循环、进入下一个。** 这里跳过那些"动作进度"的特殊键(它们不是真话题,不该重新订阅)。 + - `this.send({ op: "subscribe", id: this.nextId("resub"), topic });` —— 对每个真话题,重新发一条 `subscribe` 消息给服务端。`id` 用 `nextId("resub")` 生成(resub = re-subscribe,重订阅)。`topic` 用了对象简写(字段名等于变量名)。 + - `catch { }` —— **如果 `connect()` 这次又失败了**,catch 里什么都不做。为什么?因为连接失败会触发 `onclose`,而 `onclose` 里又会调用 `attemptReconnect()` 发起下一轮重连。所以这里不用管,让那条链路自然接力即可。(注释也这么说。) + +> **整个重连机制串起来**:连接断开 → `onclose` 判断需重连 → `attemptReconnect` 算退避延迟 → 定时器到点 → `connect` 重连 → 成功则恢复订阅 / 失败则 `onclose` 再次触发 → 循环,直到连上或达到最大次数。 + +--- + +## 第 309-316 行:`rejectAllPending()` —— 清空所有等待请求 + +```typescript + /** Reject all pending requests (used on disconnect/close). */ + private rejectAllPending(error: Error): void { + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } +} +``` + +- `private rejectAllPending(error)` —— 把所有"还在等回应"的请求全部用同一个错误拒绝。连接断开时调用(前面 `disconnect` 和 `onclose` 都用过)。 + - `for (const [id, pending] of this.pendingRequests) { ... }` —— 遍历整张待处理表。 + - **语法小课堂:`const [id, pending]` 是"数组解构"。** 遍历一个 `Map` 时,每个元素是 `[键, 值]` 这样一个两元素数组。`const [id, pending]` 把这个数组**当场拆开**:第一个给 `id`,第二个给 `pending`。比写 `entry[0]`、`entry[1]` 直观。(这里 `id` 其实没用到,但解构时要占位。) + - `clearTimeout(pending.timer);` —— 取消它的超时定时器。 + - `pending.reject(error);` —— 拒绝它对应的 Promise,让等待方收到错误。 + - `this.pendingRequests.clear();` —— `Map.clear()` 把整张表清空。 +- 最后的 `}` 关闭整个 `class RosbridgeClient`。 + +--- + +## 整章回顾:这个类的四大职责 + +读完这 317 行,回头看它确实只干四件事,现在每件都对应得上具体代码了: + +| 职责 | 涉及方法 | 核心机制 | +|---|---|---| +| ① 连接与重连 | `connect` / `disconnect` / `attemptReconnect` / `onclose` | 把 WebSocket 事件包成 Promise;指数退避重连;`intentionalClose` 区分主动/被动断开 | +| ② 消息路由 | `handleMessage` / `setStatus` | `switch (op)` 按消息类型分流;`messageHandlers` 表按话题/特殊键分发 | +| ③ 请求-响应追踪 | `registerPending` / `resolvePending` / `rejectPending` / `nextId` | 把 `resolve`/`reject` 存进 `pendingRequests` 表,靠 `id` 配对 | +| ④ 重连后恢复订阅 | `attemptReconnect` 里的重订阅循环 | 遍历 `messageHandlers` 的键,重发 `subscribe` | + +**它和外界的关系**:它是"底层引擎",本身不认识 `RosTransport` 接口(第④篇)。下一批的 `adapter.ts` 会把这个引擎包装成符合 `RosTransport` 的样子,再供上层工具使用。 + +**语法点回顾清单**(本章新增,量很大,可当字典查): +- `class` / `new` / 实例 / `this` / `constructor` +- `private` 私有字段、字段默认值、`Required`、`ReturnType` +- 默认导入 `import X from "..."` vs 具名导入 `import { X }` +- `Map`(`set`/`get`/`has`/`delete`/`keys`/`clear`)与 `Set`(`add`/`delete`) +- `new Promise((resolve, reject) => {...})` 手动构造、`resolve`/`reject` +- `async` / `await` +- `setTimeout` / `clearTimeout`、数字分隔符 `10_000` +- `if`、`===`/`!==`、`!`(逻辑非)、`&&`、`||`、三元 `? :` +- `null`、`?.`(可选链)、`!`(非空断言)、`??`(空值合并) +- 模板字符串 `` `...${x}...` ``、`new Error(...)` +- `JSON.stringify` / `JSON.parse`、`as`(类型断言) +- `switch`/`case`/`break`、`for...of`、`continue` +- `&`(交叉类型)、对象简写 `{ a, b }`、数组解构 `const [x, y]` +- `++x`(前置自增)、`Math.min`/`Math.pow`/`startsWith` +- 默认参数 `prefix = "..."`、`_event`(故意不用的参数) + +> 这一篇信息量是前面几篇的总和。**建议你配合真实源码,把 `connect` 和 `handleMessage` 两个方法反复读两三遍**——把它们吃透,后面的文件都会变简单。 + +下一份:[`transport/rosbridge/topics.ts` 逐行详解 →](07-rosbridge-topics.ts.md)(话题发布/订阅的具体封装,比这篇轻松很多) + + + +--- + + +# 逐行详解 ⑦:`transport/rosbridge/topics.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/topics.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/topics.ts) +> +> 推荐阅读顺序第 7 个文件。上一篇 `client.ts`(最难那篇)已经把"底层管子"——连接、收发、配对——全造好了。从这篇开始,我们看的是**架在 `client` 之上的三个小帮手**:话题、服务、动作。它们都很轻松,因为脏活累活 `client` 全干完了,它们只是"组装一条消息,丢给 `client.send`"。 + +--- + +## 先理解这个文件的定位 + +回忆第④篇那个比方:`client` 是"插座背后的电路"。而这篇 `topics.ts` 提供两个**包装类**: + +- `TopicPublisher`(发布器)——专门负责"往某个话题发消息"。 +- `TopicSubscriber`(订阅器)——专门负责"订阅某个话题、收消息、退订"。 + +它们都很薄:自己不连接、不解析,只是把"发布""订阅""退订"这几个动作,翻译成 `client` 认识的那种 `{ op: "..." }` 消息。可以理解为"专用遥控器"——一个只管发布、一个只管订阅。 + +--- + +## 第 1-2 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { MessageHandler } from "./types.js"; +``` + +- `import type { RosbridgeClient } from "./client.js"` —— 从上一篇的 `client.ts` 拿来 `RosbridgeClient` **类型**。注意是 `import type`(回忆第①篇:只借类型当"标注"用,不参与运行)。我们要把 `client` 实例存起来,所以需要它的类型。 +- `import type { MessageHandler }` —— 从 `types.js` 拿来"消息处理器"回调类型(第⑤篇讲过:`(msg) => void`)。订阅时要用它给参数做标注。 + +--- + +## 第 4-12 行:发布器 `TopicPublisher` 的骨架 + +```typescript +/** + * Helper for publishing messages to a ROS2 topic. + */ +export class TopicPublisher { + constructor( + private client: RosbridgeClient, + private topic: string, + private type: string, + ) {} +``` + +- `export class TopicPublisher` —— 定义并导出一个**类**(回忆第⑥篇:`class` 是"模具",用 `new` 造出实例)。 +- `constructor(...)` —— 构造函数,造实例时跑一次。 + +**语法小课堂:构造函数参数里直接写 `private`,是 TS 的"参数属性"简写。** +看这三个参数前面都带了 `private`: + +```typescript +constructor( + private client: RosbridgeClient, + private topic: string, + private type: string, +) {} +``` + +在普通 JS 里,要把构造参数存成实例字段,得手写一遍: + +```typescript +constructor(client, topic, type) { + this.client = client; // 手动搬运 + this.topic = topic; + this.type = type; +} +``` + +而 TS 允许你**在参数前加 `private`(或 `public`/`readonly`)**,它就自动帮你做上面那三行"搬运"——自动声明同名字段并赋值。所以这里虽然构造函数体是空的 `{}`,但 `this.client`、`this.topic`、`this.type` 已经被悄悄建好了。这是 TS 里极常见的省字偷懒写法,第⑥篇的 `client.ts` 里其实也用过,这里专门点明。 + +- 三个参数含义: + - `client` —— 底层那个 rosbridge 客户端(真正发消息的人)。 + - `topic` —— 这个发布器固定服务于哪个话题。 + - `type` —— 该话题的消息类型。 +- **设计意图**:一个 `TopicPublisher` 实例 = "绑定了某话题+某类型的专用发布器"。建好之后,每次发消息只需给消息体,话题和类型它自己记着。 + +--- + +## 第 14-22 行:`publish` 方法 + +```typescript + /** Publish a message to the topic. */ + publish(msg: Record): void { + this.client.send({ + op: "publish", + topic: this.topic, + type: this.type, + msg, + }); + } +} +``` + +- `publish(msg: Record): void` —— 方法:接收一个消息体 `msg`(任意结构的对象),返回 `void`(发完即忘,不等回执——回忆第④篇"要不要等"那条规律,发布是不等的)。 +- 方法体只有一句 `this.client.send({...})`:把一个 `publish` 消息交给底层客户端发出去。 +- 这个对象就是第⑤篇讲的 `PublishMessage` 格式: + - `op: "publish"` —— 固定的操作标签。 + - `topic: this.topic` —— 用实例里记着的话题。 + - `type: this.type` —— 用实例里记着的类型。 + - `msg,` —— **对象简写**(回忆第⑥篇):`msg,` 等价于 `msg: msg`,即"键名叫 `msg`,值就是参数 `msg`"。 +- `}` 关闭类。 + +到此发布器讲完——它就是把"发布"这件事固定成一句 `client.send`,没有任何复杂逻辑。 + +--- + +## 第 25-35 行:订阅器 `TopicSubscriber` 的骨架 + +```typescript +/** + * Helper for subscribing to messages from a ROS2 topic. + */ +export class TopicSubscriber { + private unsubscribeFromClient: (() => void) | null = null; + + constructor( + private client: RosbridgeClient, + private topic: string, + private type?: string, + ) {} +``` + +- 又一个类,负责订阅。构造参数和发布器几乎一样,只是 `type?` 这次是**可选**的(订阅时类型常可省,回忆第③篇 `SubscribeOptions`)。 +- 多了一个字段,值得细看: + +```typescript +private unsubscribeFromClient: (() => void) | null = null; +``` + +**逐部分拆解这一行(信息量很大):** + +- `private` —— 私有字段,只在类内部用。 +- `unsubscribeFromClient` —— 字段名,含义是"从 client 那里退订用的那个函数"。 +- `: (() => void) | null` —— 它的**类型**,是个联合类型(回忆第①篇 `|`):要么是"一个无参无返回的函数 `() => void`",要么是 `null`(空)。 + - 为什么要能是 `null`?因为**还没订阅时,根本没有"退订函数"可存**,先用 `null` 占位。订阅之后才把真正的退订函数放进来。 + - **语法小课堂:`(() => void) | null` 里外面那层括号是干嘛的?** 是为了**消除歧义**。如果写成 `() => void | null`,TS 会理解成"一个返回 `void | null` 的函数"(括号管到了返回值上)。加一层括号 `(() => void)`,明确表示"这是个函数类型",再 `| null` 表示"这个函数 **或者** null"。括号在这里决定了 `|` 到底作用在谁身上。 +- `= null` —— **字段初始值**。一开始就是 `null`(未订阅)。 + +> 这个"先放 null、之后填真东西、用完再设回 null"的模式,是管理"可有可无的资源"的经典手法。它和第⑥篇 `client.ts` 里 `reconnectTimer` 那种"定时器句柄存起来以便取消"是一个套路。 + +--- + +## 第 37-46 行:`subscribe` 方法 + +```typescript + /** Subscribe to the topic and receive messages via the handler. */ + subscribe(handler: MessageHandler): void { + this.unsubscribeFromClient = this.client.onMessage(this.topic, handler); + this.client.send({ + op: "subscribe", + id: this.client.nextId("subscribe"), + topic: this.topic, + type: this.type, + }); + } +``` + +- `subscribe(handler: MessageHandler): void` —— 接收一个"消息处理器"回调,返回 `void`。意思是"你给我一个函数,以后每来一条消息我就调用它"。 + +方法体两步,顺序很重要: + +**第一步——先在本地登记好"消息来了交给谁":** +```typescript +this.unsubscribeFromClient = this.client.onMessage(this.topic, handler); +``` +- 调用 `client.onMessage(话题, 回调)`:告诉底层客户端"以后这个话题的消息,都转交给 `handler`"。 +- `onMessage` 会**返回一个退订函数**(回忆第④篇"返回一个函数"的设计),我们把它存进刚才那个字段 `this.unsubscribeFromClient`。于是字段从 `null` 变成了真正的退订函数——以后退订时就靠它。 + +**第二步——再向服务端发出"我要订阅"的请求:** +```typescript +this.client.send({ + op: "subscribe", + id: this.client.nextId("subscribe"), + topic: this.topic, + type: this.type, +}); +``` +- 这是第⑤篇的 `SubscribeMessage` 格式。 +- `id: this.client.nextId("subscribe")` —— 调用客户端的 `nextId` 生成一个唯一编号(第⑥篇讲过,`nextId` 用一个自增计数器产出像 `"subscribe_1"` 这样的串)。 +- `topic`/`type` 用实例里存的。 + +> **为什么先登记本地回调、再发订阅请求?** 因为消息随时可能回来。如果先发请求、还没登记回调,万一服务端"秒回"第一条消息,就没人接住、丢了。先把"接球手"站好,再喊"开始发球",才不会漏。这种"先备好接收方,再触发"的顺序在异步编程里是通用安全准则。 + +--- + +## 第 48-59 行:`unsubscribe` 方法 + +```typescript + /** Unsubscribe from the topic. */ + unsubscribe(): void { + if (this.unsubscribeFromClient) { + this.unsubscribeFromClient(); + this.unsubscribeFromClient = null; + } + this.client.send({ + op: "unsubscribe", + id: this.client.nextId("unsubscribe"), + topic: this.topic, + }); + } +} +``` + +退订也是两步,和订阅正好对称: + +**第一步——清掉本地登记:** +```typescript +if (this.unsubscribeFromClient) { + this.unsubscribeFromClient(); + this.unsubscribeFromClient = null; +} +``` +- `if (this.unsubscribeFromClient)` —— 先判断字段里**确实存了一个退订函数**(不是 `null`)才进去。 + - **语法小课堂:把一个"可能是 null 的值"直接放进 `if`,叫"真值判断(truthy check)"。** JS 里 `null`、`undefined`、`0`、`""`、`false` 都算"假值(falsy)",其余都算"真值(truthy)"。所以 `if (this.unsubscribeFromClient)` 的含义是"如果它不是 null(确实存了个函数)"。这是判空的常用简写,等价于 `if (this.unsubscribeFromClient !== null)`。 +- `this.unsubscribeFromClient();` —— 调用那个退订函数,把第一步登记的本地回调撤掉(注意末尾的 `()` 表示"调用它")。 +- `this.unsubscribeFromClient = null;` —— 调用完把字段设回 `null`,表示"已经退订、没有可退的了"。这样即使有人手滑再调一次 `unsubscribe`,`if` 也不会重复执行。 + +**第二步——通知服务端"我不订了":** +```typescript +this.client.send({ + op: "unsubscribe", + id: this.client.nextId("unsubscribe"), + topic: this.topic, +}); +``` +- 第⑤篇的 `UnsubscribeMessage` 格式,极简:操作标签 + 一个 id + 话题名。 +- `}` 关闭类。 + +> **本地撤回调 + 远程发退订,两件事都要做。** 只撤本地不通知服务端,服务端会继续往这条连接推消息(浪费带宽);只通知服务端不撤本地,万一还有残留消息进来,旧回调可能被误触发。两步都做才干净。 + +--- + +## 整章回顾 + +这篇是上一篇"硬核 `client.ts`"之后的第一口轻松气。两个类的本质都一样: + +> **把"发布/订阅/退订"这种动作,固定成一两句 `client.send({ op: ... })`,并管好与之配套的本地回调登记。** + +| 类 | 职责 | 关键动作 | +|---|---|---| +| `TopicPublisher` | 发布 | 一句 `send({op:"publish",...})`,发完即忘 | +| `TopicSubscriber` | 订阅/退订 | 订阅=先 `onMessage` 登记回调、再 `send` 订阅请求;退订=先撤回调、再 `send` 退订请求 | + +记住两条贯穿全篇的"顺序准则": +1. **订阅**:先备好接收方(`onMessage`),再喊开始(`send subscribe`)。 +2. **退订**:先撤本地(调用并清空退订函数),再通知远端(`send unsubscribe`)。 + +**语法点回顾清单**(本章新增): +- 构造函数参数前写 `private`/`public`:**参数属性**简写(自动声明并赋值字段) +- 对象简写补充:`{ msg }` 即 `{ msg: msg }`(第⑥篇见过,这里再巩固) +- `(() => void) | null`:可空的函数类型字段,外层括号用来消歧义 +- "先放 null、用时填、用完设回 null"管理可选资源的模式 +- 真值判断 `if (someValue)`:非空/非假即进入(判空简写) + +下一份:[`transport/rosbridge/services.ts` 逐行详解 →](08-rosbridge-services.ts.md)(用一个函数完整演示"请求-等待响应"的 Promise 配对) + + + +--- + + +# 逐行详解 ⑧:`transport/rosbridge/services.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/services.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/services.ts) +> +> 推荐阅读顺序第 8 个文件。整个文件只有**一个函数** `callService`,但它是全项目"请求-等待响应"模式最干净的范例。上一篇 `topics.ts` 的发布是"发完即忘",这篇的服务调用是"发出去之后**得等对方答复**"——这中间怎么把"将来才回来的响应"接住,正是本篇的主角。 + +--- + +## 先理解要解决的难题 + +ROS2 的"服务"是请求-响应式的(回忆第③篇打电话的比方)。难点在于:**消息是在 WebSocket 上异步来回的**。我们 `send` 出一个请求后,函数不能"原地卡住等",但调用方又确实想"等到结果再继续"。 + +第⑥篇的 `client` 已经备好了解决工具——`registerPending`:你给它一个 `id` 和"响应回来时该怎么办",它就先记在一个 Map 里;将来对应 `id` 的响应到了,它替你调用。 + +本篇就是把这个工具和 `Promise` 一拼,变魔术:让一个看似"返回结果"的函数,其实返回的是"将来会有结果的承诺"。 + +--- + +## 第 1-2 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { ServiceResponseMessage } from "./types.js"; +``` + +- `RosbridgeClient` —— 底层客户端类型,函数第一个参数要用。 +- `ServiceResponseMessage` —— 第⑤篇定义的"服务响应消息"类型(含 `result` 成功与否、`values` 返回数据)。这就是我们最终想拿到的东西。 + +--- + +## 第 4-13 行:函数的文档注释 + +```typescript +/** + * Call a ROS2 service via rosbridge. + * + * @param client - The rosbridge client instance + * @param service - The service name (e.g., "/my_node/set_parameters") + * @param args - The service request arguments + * @param type - Optional service type + * @param timeoutMs - Request timeout in milliseconds (default 30s) + * @returns The service response + */ +``` + +- **语法小课堂:`@param` 和 `@returns` 是 JSDoc 标记。** 回忆第⑤篇见过的 `@see`。这里: + - `@param 名字 - 说明` —— 逐个解释参数。 + - `@returns 说明` —— 解释返回值。 +- 这些标记不影响运行,但编辑器会读它们:当你在别处调用 `callService` 时,鼠标悬停就能看到这些说明。等于"给函数配了说明书"。 + +--- + +## 第 14-20 行:函数签名 + +```typescript +export async function callService( + client: RosbridgeClient, + service: string, + args?: Record, + type?: string, + timeoutMs = 30_000, +): Promise { +``` + +逐部分拆: + +- `export async function callService(...)` —— 导出一个 **async(异步)函数**(回忆第⑥篇:`async` 表示"这函数里会有等待,它整体返回一个 `Promise`")。 +- 参数列表: + - `client: RosbridgeClient` —— 用哪个客户端去发。 + - `service: string` —— 服务名,必填。 + - `args?: Record` —— 请求参数,**可选**(`?`)。 + - `type?: string` —— 服务类型,可选。 + - `timeoutMs = 30_000` —— **默认参数**(回忆第⑥篇):不传就用 `30_000`(即 30000 毫秒=30 秒;下划线 `30_000` 只是分位好读,第⑥篇讲过)。意思是"等响应最多等 30 秒,超了就算失败"。 +- 返回类型 `: Promise` —— 返回一个"将来会兑现成服务响应"的承诺。因为是 `async` 函数,返回值天然被包进 `Promise`。 + +> **小注意:必填参数在前、可选参数在后。** `args?`、`type?` 这些带 `?` 的必须排在必填参数后面——否则调用时位置对不上。`timeoutMs` 有默认值,也相当于可选,放最后。 + +--- + +## 第 21 行:生成本次请求的唯一 id + +```typescript + const id = client.nextId("service"); +``` + +- 调用 `client.nextId("service")` 拿一个唯一编号,比如 `"service_1"`、`"service_2"`……(第⑥篇讲过 `nextId` 内部用自增计数器)。 +- **这个 `id` 是整篇的灵魂**:请求带着它发出去,响应也会带着相同的 `id` 回来。我们就靠这个号码,把"哪个响应对应哪个请求"配上对。如同快递单号。 + +--- + +## 第 23-30 行:先架好"接住响应"的 Promise(本篇核心) + +```typescript + const responsePromise = new Promise((resolve, reject) => { + client.registerPending( + id, + (result) => resolve(result as ServiceResponseMessage), + reject, + timeoutMs, + ); + }); +``` + +这是全篇最需要嚼的一段。一层层拆: + +**外层:`new Promise<...>((resolve, reject) => {...})`** +- 回忆第⑥篇的"Promise 构造小课堂":`new Promise` 需要你传一个函数,这个函数收到两个开关——`resolve`(成功兑现,把结果交出去)和 `reject`(失败,抛出错误)。 +- `` 指明这个 Promise 将来兑现时给出的值是 `ServiceResponseMessage` 类型。 +- 重点理解:`new Promise` 此刻**并不会立刻有结果**。它只是先创建一个"待兑现的承诺",并把"将来怎样才算成功/失败"的规则登记好。规则就写在那个箭头函数体里。 + +**内层:把 `resolve`/`reject` 交给 `client.registerPending`** +```typescript +client.registerPending( + id, + (result) => resolve(result as ServiceResponseMessage), + reject, + timeoutMs, +); +``` +- `registerPending` 是第⑥篇 `client.ts` 里那个方法:把 `(id → 成功回调 / 失败回调 / 超时)` 记进一个待处理 Map。将来对应 `id` 的响应回来,`client` 就替我们调用成功回调;超时了就调用失败回调。 +- 四个参数: + 1. `id` —— 用哪个号码登记。 + 2. `(result) => resolve(result as ServiceResponseMessage)` —— **成功回调**。当响应回来,`client` 会把响应数据当 `result` 传进来;我们立刻调用 `resolve(...)`,于是外层那个 Promise 就兑现了、把结果交出去。 + - `result as ServiceResponseMessage` —— **类型断言**(回忆第⑥篇 `as`)。`client` 给回来的 `result` 类型比较宽泛,我们"断言"它其实就是服务响应,好让类型对上。 + 3. `reject` —— **失败回调**。直接把 Promise 的 `reject` 交过去——一旦超时或出错,`client` 调用 `reject`,外层 Promise 就变成失败。 + - 注意这里直接写 `reject`,没有包一层箭头函数。因为 `reject` 本身就是个函数,签名正好匹配,直接整个传过去即可(回忆第①篇"函数是一等公民,可当参数传")。 + 4. `timeoutMs` —— 多久没回就算超时。 + +**这一段整体在干嘛?** 一句话:**"建一个空承诺,并约定好——响应到了就兑现它、超时了就让它失败。"** 注意此刻我们还没发请求!只是先把"接球网"张开。 + +> **再次强调顺序**:和上一篇订阅一样——**先登记好怎么接响应,再发请求**。万一服务端秒回,接球网已经张好,不会漏。 + +--- + +## 第 32-38 行:真正发出请求 + +```typescript + client.send({ + op: "call_service", + id, + service, + args, + type, + }); +``` + +- 现在才把请求发出去。这是第⑤篇的 `ServiceCallMessage` 格式: + - `op: "call_service"` —— 操作标签。 + - `id,` —— **对象简写**:带上刚才那个号码(`id: id`)。响应将来会带着同一个 `id` 回来,配对就靠它。 + - `service,` / `args,` / `type,` —— 都是对象简写,把同名参数原样塞进去。 +- 发完这一句,请求就在路上了。但函数还没结束——下面要把"那个待兑现的承诺"返回出去。 + +--- + +## 第 40-41 行:返回承诺 + +```typescript + return responsePromise; +} +``` + +- `return responsePromise;` —— 把第 23 行建的那个 Promise 返回给调用方。 +- **关键理解**:函数到这里就返回了,但 Promise 里**还没有结果**(响应还在路上)。调用方拿到的是一个"待兑现的承诺",它会 `await` 这个承诺——直到响应回来触发 `resolve`,`await` 才拿到结果继续往下;或者超时触发 `reject`,`await` 处抛出错误。 +- `}` 结束函数。 + +> **为什么函数明明 `async`,却不在里面 `await`,而是直接 `return` 一个 Promise?** +> 完全可以。`async` 函数返回一个 Promise 时,外层不会再多包一层(会"摊平")。这里作者用 `async` 主要是表明"这是个异步操作"的语义,并让返回类型自然写成 `Promise<...>`。真正的"等待"逻辑被巧妙地塞进了 `registerPending` 的回调里,而不是用 `await` 写在函数体内——这是处理"事件式异步"(响应靠回调送达,而非靠某个 `await` 的调用)的标准手法。 + +--- + +## 把整个流程串成一张图 + +``` +①拿号 id ──→ ②张开接球网(new Promise + registerPending:约定"id 的响应到了就 resolve") + │ +③发请求 send({op:"call_service", id, ...}) ──→ (消息上路,飞向 rosbridge) + │ +④return responsePromise(把承诺交给调用方,函数结束) + ┊ …时间流逝,调用方在 await… +⑤服务端回了 {op:"service_response", id, ...} + → client 在 Map 里按 id 找到我们登记的成功回调 → 调用 resolve(响应) + → 那个承诺兑现 → 调用方的 await 拿到响应,继续往下 + (若 30 秒没回 → client 触发 reject → 调用方 await 处抛错) +``` + +这套"拿号 → 先登记回调 → 再发请求 → 返回承诺 → 响应回来按号兑现"的五步流程,就是异步请求-响应的通用骨架。下一篇动作(action)几乎是同一套,只是多了"中途进度反馈"。 + +--- + +## 整章回顾 + +- 本篇只有一个函数 `callService`,演示了**如何把"事件式的异步响应"包装成一个干净的 `Promise`**,让调用方能用 `await` 像写同步代码一样等结果。 +- 核心三件套: + 1. `nextId` 拿唯一号——给请求和响应配对用。 + 2. `new Promise` + `registerPending`——先把"响应到了/超时了怎么办"登记好(接球网先张开)。 + 3. `send` 发请求 + `return promise` 交出承诺。 +- 贯穿的安全准则依旧是:**先备好接收(登记回调),再触发(发请求)。** + +**语法点回顾清单**(本章新增/巩固): +- `@param` / `@returns`:JSDoc 给函数参数和返回值写说明书 +- 默认参数 `timeoutMs = 30_000`、必填在前可选在后(巩固) +- `async function` 直接 `return` 一个 Promise(不一定非得在体内 `await`) +- 把 `resolve`/`reject` 透传给底层登记函数,实现"事件→Promise"的桥接 +- 直接传函数名当回调(`reject`)vs 包一层箭头函数(`(result) => resolve(...)`)的区别 +- 类型断言 `as`(巩固,第⑥篇见过) + +下一份:[`transport/rosbridge/actions.ts` 逐行详解 →](09-rosbridge-actions.ts.md)(在本篇的请求-响应骨架上,再叠加"进度反馈"和 `try/finally` 清理) + + + +--- + + +# 逐行详解 ⑨:`transport/rosbridge/actions.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/actions.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/actions.ts) +> +> 推荐阅读顺序第 9 个文件,rosbridge 三小帮手的最后一个。**它就是上一篇 `services.ts` 那套"请求-响应"骨架,再叠两样东西**:①动作是长任务,途中会不断推"进度反馈",要单独接住;②不管成功失败,结束时都得把进度监听清理掉——这引出新语法 `try/finally`。把第⑧篇吃透了,这篇只剩"增量"要学。 + +--- + +## 先理解动作 vs 服务的差别 + +回忆第③篇: +- **服务(service)**=打个电话问一句、对方答一句就完事(一来一回)。 +- **动作(action)**=交代一个大任务("导航到客厅"),它要干几十秒,**途中不断汇报进度**("走了 30%……60%……"),最后才给个总结果,而且**中途可以喊停**。 + +所以动作比服务多两件事要处理: +1. **进度反馈**:任务跑的过程中,服务端会推来一条条 `action_feedback`,得有人接。 +2. **善后清理**:任务一结束(无论成功还是出错),那个"接进度"的监听器要撤掉,别留垃圾。 + +--- + +## 第 1-5 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { + ActionResultMessage, + ActionFeedbackMessage, +} from "./types.js"; +``` + +- `RosbridgeClient` —— 底层客户端类型。 +- `ActionResultMessage` —— 第⑤篇定义的"动作最终结果"消息类型(含 `result`+`values`)。 +- `ActionFeedbackMessage` —— "动作进度反馈"消息类型(含必有的 `values`)。 +- 多个名字用花括号换行列出,是纯排版(回忆第④篇),和写一行没区别。 + +--- + +## 第 7-13 行:动作目标选项 `ActionGoalOptions` + +```typescript +export interface ActionGoalOptions { + action: string; + actionType: string; + args?: Record; + onFeedback?: (feedback: ActionFeedbackMessage) => void; + timeoutMs?: number; +} +``` + +发送一个动作目标要带的信息(和第③篇同名接口几乎一致,这里是 rosbridge 模块自己的版本): + +- `action: string;` —— 动作服务器名,必填。 +- `actionType: string;` —— 动作类型,必填(注意无 `?`,动作类型不能省)。 +- `args?: Record;` —— 目标参数,可选(如目标坐标)。 +- `onFeedback?: (feedback: ActionFeedbackMessage) => void;` —— **可选的进度回调**。它是个函数类型:接收一条进度反馈、返回 `void`。你给了它,就能实时收到进度;不给,就只等最终结果。 +- `timeoutMs?: number;` —— 可选超时(动作很长,下面会看到默认值比服务大得多)。 + +--- + +## 第 15-19 行:`ActionClient` 类骨架 + +```typescript +/** + * Client for sending action goals and receiving feedback/results. + */ +export class ActionClient { + constructor(private client: RosbridgeClient) {} +``` + +- 又一个薄包装类,只存一个底层 `client`(用了第⑦篇讲的 `private` 参数属性简写)。 +- 它对外提供两个方法:`sendGoal`(发目标并等结果)和 `cancelGoal`(取消)。 + +--- + +## 第 21-29 行:`sendGoal` 开头——拿号 + 定超时 + +```typescript + async sendGoal(options: ActionGoalOptions): Promise { + const id = this.client.nextId("action"); + const timeoutMs = options.timeoutMs ?? 120_000; // Actions can be long-running +``` + +- `async sendGoal(options): Promise` —— 异步方法,接收一个选项对象,最终兑现成"动作结果"。 +- `const id = this.client.nextId("action");` —— 和第⑧篇一样,拿唯一号给请求/结果配对。 +- `const timeoutMs = options.timeoutMs ?? 120_000;` —— 定超时: + - **语法小课堂复习:`??`(空值合并)**(第⑥篇讲过)。`options.timeoutMs ?? 120_000` 意思是"如果调用方传了 `timeoutMs` 就用它,没传(是 `undefined`)就用 `120_000`"。 + - `120_000` 毫秒 = 120 秒 = 2 分钟。比服务的 30 秒长 4 倍——注释 `// Actions can be long-running`(动作可能很耗时)正解释了原因。 + - 注意这里**没用第⑧篇那种"参数默认值"写法**(`timeoutMs = 120_000`),而是用 `??` 在函数体内兜底。两种写法效果类似;因为 `timeoutMs` 藏在 `options` 对象里,没法写成参数默认值,只能进来后用 `??` 补。 + +--- + +## 第 31-38 行:登记进度反馈监听(动作独有的部分) + +```typescript + // Register feedback handler if provided + let removeFeedbackHandler: (() => void) | null = null; + if (options.onFeedback) { + const feedbackKey = `__action_feedback__${id}`; + removeFeedbackHandler = this.client.onMessage(feedbackKey, (msg) => { + options.onFeedback!(msg as unknown as ActionFeedbackMessage); + }); + } +``` + +这一段是动作比服务**多出来**的处理,逐行看: + +```typescript +let removeFeedbackHandler: (() => void) | null = null; +``` +- 和第⑦篇订阅器里那个字段同款:一个"可空的退订函数"。先 `null`,待会儿若登记了监听就填进来,最后清理时调用它。 +- 用 `let` 不用 `const`,因为它的值后面要被改(从 `null` 变成函数)。 + +```typescript +if (options.onFeedback) { +``` +- 真值判断(第⑦篇讲过):只有**调用方确实传了 `onFeedback` 回调**,才需要费劲去登记进度监听。没传就整段跳过,省事。 + +```typescript +const feedbackKey = `__action_feedback__${id}`; +``` +- 拼一个**专门给"这次动作的进度"用的频道名**。用了模板字符串(第⑥篇):`__action_feedback__` 前缀 + 这次的 `id`。 +- 为什么要这么个怪名字?因为进度反馈和最终结果是**两路消息**:最终结果按 `id` 走 `registerPending`(下面会写),进度则单独挂在这个 `__action_feedback__` 频道上,互不干扰。`client` 内部会把进度反馈消息路由到这个 key。前后双下划线只是约定俗成表示"内部专用、别乱碰"。 + +```typescript +removeFeedbackHandler = this.client.onMessage(feedbackKey, (msg) => { + options.onFeedback!(msg as unknown as ActionFeedbackMessage); +}); +``` +- `this.client.onMessage(频道, 回调)` —— 在那个进度频道上登记一个回调,并把返回的退订函数存进 `removeFeedbackHandler`(同第⑦篇套路)。 +- 回调体 `options.onFeedback!(msg as unknown as ActionFeedbackMessage)`:每来一条进度,就转交给调用方给的 `onFeedback`。这里有两个新点: + - **语法小课堂:`options.onFeedback!` 里的 `!` 是"非空断言"。** 回忆第⑥篇区分过:`!` 跟在值后面表示"我担保它不是 null/undefined"。`onFeedback` 类型是可选的(`?`,可能 `undefined`),但我们是在 `if (options.onFeedback)` 成立的分支里,明知它一定存在,于是用 `!` 告诉 TS"放心,这里它必有",免得 TS 唠叨。(注意:这 `!` 是断言,**不是**逻辑取反——逻辑取反的 `!` 写在值**前面**,如 `!flag`。) + - **语法小课堂:`msg as unknown as ActionFeedbackMessage` 是"双重类型断言"。** 单个 `as` 是类型断言(第⑥篇)。这里连用两次 `as unknown as X`,是一种"强行改类型"的手法:当源类型和目标类型差得太远、TS 不允许直接 `as` 时,先 `as unknown`(退到"什么都可能"的 `unknown`),再 `as ActionFeedbackMessage`(断成目标类型)。相当于"先清空 TS 的成见,再重新指定"。属于不得已的强转,能少用就少用,这里是为了把宽泛的 `msg` 贴成具体的反馈类型。 + +> 整段小结:**只有当调用方想看进度时,才开一条专用频道接进度,并把退订函数存好备用。** + +--- + +## 第 40-48 行:架好"等最终结果"的 Promise + +```typescript + // Create promise that resolves on action_result + const resultPromise = new Promise((resolve, reject) => { + this.client.registerPending( + id, + (result) => resolve(result as ActionResultMessage), + reject, + timeoutMs, + ); + }); +``` + +这段和第⑧篇 `services.ts` 里那段**几乎一字不差**,只是类型从 `ServiceResponseMessage` 换成 `ActionResultMessage`: +- `new Promise` 建一个待兑现承诺; +- `registerPending(id, 成功回调, reject, timeoutMs)` 登记"按 `id` 等最终结果,超时则失败"。 + +如果第⑧篇看懂了,这里直接跳过即可——**最终结果走的就是和服务一模一样的"按 id 配对"通道**。进度反馈走上面那条独立频道,两不相干。 + +--- + +## 第 50-57 行:发出动作目标 + +```typescript + // Send the goal + this.client.send({ + op: "send_action_goal", + id, + action: options.action, + action_type: options.actionType, + args: options.args, + }); +``` + +- 第⑤篇的 `ActionGoalMessage` 格式: + - `op: "send_action_goal"` —— 操作标签。 + - `id,` —— 带上配对号。 + - `action: options.action` —— 动作名。 + - `action_type: options.actionType` —— **注意命名转换**:选项里是驼峰 `actionType`,这里发给协议时变成下划线 `action_type`(回忆第⑤篇"驼峰↔下划线"——贴协议就得用下划线)。 + - `args: options.args` —— 目标参数。 +- 至此:进度频道开好了、结果承诺张好了、目标也发出去了。剩下就是等。 + +--- + +## 第 59-67 行:等结果 + 无论如何都清理(`try/finally` 登场) + +```typescript + try { + return await resultPromise; + } finally { + // Clean up feedback handler regardless of outcome + if (removeFeedbackHandler) { + removeFeedbackHandler(); + } + } + } +``` + +这是本篇唯一的全新控制结构,重点讲。 + +**语法小课堂:`try { ... } finally { ... }`。** +回忆第②、⑥篇见过 `try/catch`(试着跑,出错就进 `catch`)。这里是它的近亲 `try/finally`: +- `try { ... }` —— 先跑这块。 +- `finally { ... }` —— **不管 `try` 块是正常结束、还是中途出错、还是 `return` 了,`finally` 块都一定会执行。** 它是"善后保证"——保证某些清理动作一定发生。 +- 这里没有 `catch`,意味着"出错我不拦截(让错误照常往外抛),但**抛之前先把清理做了**"。 + +逐句看: + +```typescript +return await resultPromise; +``` +- `await resultPromise` —— 等那个"最终结果"承诺兑现,拿到结果。 + - 成功:动作做完,`registerPending` 触发 `resolve`,这里拿到 `ActionResultMessage`,`return` 出去。 + - 失败/超时:触发 `reject`,`await` 在此**抛出错误**——注意,即便抛错,下面的 `finally` 也照样会先跑。 + +```typescript +} finally { + if (removeFeedbackHandler) { + removeFeedbackHandler(); + } +} +``` +- 无论上面是 `return` 了还是抛错了,都会进到这里。 +- `if (removeFeedbackHandler)` —— 真值判断:当初**有**登记进度监听(不是 null)才需要撤。 +- `removeFeedbackHandler();` —— 调用退订函数,把那条进度频道的监听撤掉。 +- 注释 `regardless of outcome`(无论结果如何)正是 `finally` 的精髓。 + +> **为什么进度清理非得放 `finally`,不能直接写在 `await` 后面?** +> 因为如果动作**失败/超时**,`await` 会抛错,"写在后面的清理代码"就被跳过了,那条进度监听就**泄漏**了(永远挂在那、白占内存)。放进 `finally`,才能保证"成功也好、失败也好,监听一定被撤掉"。这正是 `finally` 存在的意义——**清理类代码的最佳归宿。** + +- 最外层 `}` 结束 `sendGoal`。 + +--- + +## 第 69-80 行:`cancelGoal` 取消动作 + +```typescript + /** + * Cancel an in-progress action goal. + * + * @param action - The action server name + */ + async cancelGoal(action: string): Promise { + this.client.send({ + op: "cancel_action_goal", + id: this.client.nextId("cancel"), + action, + }); + } +} +``` + +- `async cancelGoal(action: string): Promise` —— 取消某个动作,参数就一个动作名(回忆第④篇:只需一条信息时不必包成选项对象),返回 `Promise`。 +- 方法体只发一条第⑤篇的 `ActionCancelMessage`: + - `op: "cancel_action_goal"` —— 操作标签。 + - `id: this.client.nextId("cancel")` —— 给取消请求也配个号(这里取消是"发完即走",没有去 `registerPending` 等回执,所以这个 id 主要用于日志/追踪)。 + - `action,` —— 对象简写,取消哪个动作。 +- 它比 `sendGoal` 简单得多——"喊停"这个动作本身不需要等结果。 +- 最后的 `}` 关闭类。 + +--- + +## 三篇对照:发布 / 服务 / 动作,复杂度递增 + +| | 发布 (⑦) | 服务 (⑧) | 动作 (⑨) | +|---|---|---|---| +| 等回应吗 | 不等(发完即忘) | 等一个响应 | 等最终结果 | +| 配对 id | 无需 | 要(请求↔响应) | 要(目标↔结果) | +| 进度反馈 | 无 | 无 | **有**(独立频道 `__action_feedback__`) | +| 善后清理 | 无 | 无 | **有**(`try/finally` 撤进度监听) | +| 超时默认 | — | 30 秒 | 120 秒(任务长) | + +可以清楚看到:动作 = 服务的请求-响应骨架 + 进度旁路 + finally 清理。一层层叠上去,没有凭空冒出的新东西。 + +--- + +## 整章回顾 + +- `ActionClient` 把"发动作目标"做成两路并行: + - **结果路**:和服务一样,`new Promise` + `registerPending` 按 `id` 等最终 `action_result`。 + - **进度路**:若调用方给了 `onFeedback`,单开一条 `__action_feedback__` 频道实时转交进度。 +- 用 `try { return await … } finally { 撤监听 }` 保证**无论成功失败,进度监听都被清理**——这是 `finally` 最典型的用法。 +- `cancelGoal` 只是发一条取消消息,最简单。 + +至此 rosbridge 的三个小帮手(topics / services / actions)全部讲完。它们各自只懂"组一条消息、交给 client",真正的连接与配对全靠第⑥篇的 `client`。下一篇 `adapter.ts` 会把这三个帮手 + client 拼装起来,正式"实现"第④篇那个 `RosTransport` 统一接口——届时第一次见到 `implements` 关键字。 + +**语法点回顾清单**(本章新增/巩固): +- `try { ... } finally { ... }`:无论是否出错/返回,`finally` 必定执行(清理类代码的归宿) +- `!` 非空断言(值后面)vs `!` 逻辑取反(值前面)的再区分 +- 双重断言 `as unknown as X`:源/目标类型差太远时的强转手法(慎用) +- `??` 在函数体内兜底默认值(vs 第⑧篇的参数默认值写法) +- `let` 用于"值会被改写"的变量(vs `const`) +- 命名转换 `actionType`(驼峰) → `action_type`(下划线贴协议)(巩固第⑤篇) + +下一份:[`transport/rosbridge/adapter.ts` 逐行详解 →](10-rosbridge-adapter.ts.md)(把 client + 三帮手组装成实现 `RosTransport` 的适配器,首见 `implements`) + + + +--- + + +# 逐行详解 ⑩:`transport/rosbridge/adapter.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts) +> +> 推荐阅读顺序第 10 个文件。**这是"零件组装成成品"的一跳**:前面我们有了底层 `client`(⑥)和三个小帮手(⑦⑧⑨),又有了第④篇那张"插座标准" `RosTransport` 接口。这篇就是把零件拼起来,正式声明"我 `RosbridgeTransport` 实现了 `RosTransport` 这份契约"——本篇第一次见到关键字 `implements`。 + +--- + +## 先理解"适配器(adapter)"是什么 + +回忆第④篇的比方:`RosTransport` 是插座标准,rosbridge/local/webrtc 是三种发电方式。但我们之前写的 `client` + 三帮手,它们的"插头形状"并不完全等于插座标准——比如 `client` 有 `send`、帮手有 `publish/subscribe`,但上层工具要的是 `RosTransport` 那 13 个方法。 + +**适配器**就是中间那个"转接头":它对外长成 `RosTransport` 的样子(13 个标准方法),对内则调用 `client` 和三帮手去真正干活。所以这个类很薄——几乎每个方法都是"把 `RosTransport` 的调用翻译成对底层零件的调用"。 + +``` +上层工具 ──(只认 RosTransport 接口)──> RosbridgeTransport(适配器) + │ 内部调用 + ├─ RosbridgeClient(连接/收发) + ├─ TopicPublisher / TopicSubscriber + ├─ callService(函数) + └─ ActionClient +``` + +--- + +## 第 1-21 行:导入一大堆 + +```typescript +import type { RosTransport } from "../transport.js"; +import type { + ConnectionStatus, ConnectionHandler, Subscription, + PublishOptions, SubscribeOptions, ServiceCallOptions, ServiceCallResult, + ActionGoalOptions, ActionResult, TopicInfo, ServiceInfo, ActionInfo, + MessageHandler, +} from "../types.js"; +import { RosbridgeClient } from "./client.js"; +import { TopicPublisher, TopicSubscriber } from "./topics.js"; +import { callService } from "./services.js"; +import { ActionClient } from "./actions.js"; +import type { RosbridgeClientOptions } from "./types.js"; +``` + +注意这里出现了**两种导入**,区别很重要: + +- `import type { ... }` —— 只借**类型**(`RosTransport`、各种 `Options`/`Result`、`ConnectionStatus` 等)。这些只在"标注"里出现,不参与运行(回忆第①篇)。 + - 路径里 `../transport.js`、`../types.js` 用了 `../`(上一级目录),因为本文件在 `rosbridge/` 子目录里,要去上一层拿传输层的公共类型。 +- `import { ... }`(**不带 `type`**)—— 借的是**真正的值/类**:`RosbridgeClient`、`TopicPublisher`、`TopicSubscriber`、`callService`、`ActionClient`。 + - **语法小课堂:`import` vs `import type` 的实质区别。** 带 `type` 的导入编译后会被**完全抹掉**(运行时不存在);不带 `type` 的会真的"加载那个文件、把东西拿进来"。判断标准很简单:**你要 `new` 它、调用它,就用 `import`(值);你只拿它当类型标注,就用 `import type`。** 这里 `RosbridgeClient` 等都要 `new`/调用,所以是值导入。 + - 这些值导入的路径用 `./`(同目录的几个帮手文件)。 + +--- + +## 第 23-30 行:类声明 + 第一次见 `implements` + +```typescript +/** + * RosTransport adapter that wraps the existing RosbridgeClient. + * ... + */ +export class RosbridgeTransport implements RosTransport { +``` + +**语法小课堂:`class X implements Y` —— "类实现接口"。** +- `implements`(实现)后面跟一个接口名。它向 TS 声明:"**我保证提供 `RosTransport` 接口要求的所有方法**,签名一个不差。" +- 如果你漏了某个方法、或某个方法参数/返回类型对不上,TS 会**当场报错**。这就是第④篇说的"接口强制约束实现"——`implements` 就是那道强制开关。 +- 它和 `extends`(第⑤篇接口继承)不同: + - `extends` 是"继承别人的字段/实现"(拿来用)。 + - `implements` 是"承诺满足某个契约"(被检查),它**不会**给你任何现成实现,方法体还得自己一个个写。 +- 直白说:`implements RosTransport` = "TS 啊,请按 `RosTransport` 那张清单挨个检查我,少一个都别放过。" + +下面就是挨个兑现那 13 个方法。 + +--- + +## 第 31-37 行:字段 + 构造函数(组装零件) + +```typescript + private client: RosbridgeClient; + private actionClient: ActionClient; + + constructor(options: RosbridgeClientOptions) { + this.client = new RosbridgeClient(options); + this.actionClient = new ActionClient(this.client); + } +``` + +- 两个私有字段:一个底层 `client`、一个 `actionClient`(动作客户端)。 +- 构造函数: + - `this.client = new RosbridgeClient(options);` —— 用传进来的连接选项 `new` 一个底层客户端(回忆第⑥篇 `RosbridgeClient` 的构造)。 + - `this.actionClient = new ActionClient(this.client);` —— 再 `new` 一个动作客户端,并把刚建的 `client` **喂给它**(回忆第⑨篇 `ActionClient` 构造就吃一个 client)。 +- 这里没用第⑦篇那种"参数属性简写",而是显式写字段 + 在构造体里赋值——因为这两个字段不是直接来自参数,而是 `new` 出来的,没法用简写。 +- **注意发布器/订阅器没有在这里建**——它们在每次 `publish`/`subscribe` 时临时建(见下文),因为每次发布/订阅的话题不同。而 `client`/`actionClient` 是全程共用一个,所以放构造里。 + +--- + +## 第 39-53 行:连接生命周期(直接转发) + +```typescript + async connect(): Promise { + await this.client.connect(); + } + + async disconnect(): Promise { + await this.client.disconnect(); + } + + getStatus(): ConnectionStatus { + return this.client.getStatus(); + } + + onConnection(handler: ConnectionHandler): () => void { + return this.client.onConnection(handler); + } +``` + +这四个方法是最纯粹的"转发(delegation)"——自己不干活,原样转交给 `client`: + +- `connect` / `disconnect` —— `await this.client.connect()`。它们是 `async`,所以用 `await` 等底层做完。(注意:`async` 函数里即便只有一句 `await xxx()`,效果等同于把那个 Promise 返回出去。) +- `getStatus` —— 不是 `async`(回忆第④篇:查状态瞬间完成不用等),直接 `return this.client.getStatus()`。 +- `onConnection` —— 把回调转给 client 登记,并把 client 返回的退订函数 `return` 出去(回忆第④篇"返回一个函数")。 + +> **这就是适配器的典型味道**:方法签名严格按 `RosTransport`(因为 `implements` 在盯着),方法体则一句话甩给底层零件。 + +--- + +## 第 55-58 行:`publish`(临时建发布器) + +```typescript + publish(options: PublishOptions): void { + const publisher = new TopicPublisher(this.client, options.topic, options.type); + publisher.publish(options.msg); + } +``` + +- 接口要求 `publish(options): void`(发完即忘,不等)。 +- 方法体:临时 `new` 一个第⑦篇的 `TopicPublisher`(绑定本次的话题、类型),然后调它的 `.publish(options.msg)` 把消息体发出去。 +- **为什么每次都新建一个发布器?** 因为发布器是"绑定某话题"的轻量对象,建它几乎零成本。不同调用话题不同,临时建最简单,用完即弃。 + +--- + +## 第 60-68 行:`subscribe`(把帮手包装成接口要求的句柄) + +```typescript + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription { + const subscriber = new TopicSubscriber(this.client, options.topic, options.type); + subscriber.subscribe(handler); + return { + unsubscribe() { + subscriber.unsubscribe(); + }, + }; + } +``` + +- 接口要求返回一个 `Subscription` 句柄(回忆第③篇:那个带 `unsubscribe()` 方法的对象)。 +- 方法体: + 1. `new TopicSubscriber(...)` 建一个第⑦篇的订阅器。 + 2. `subscriber.subscribe(handler)` 真正去订阅。 + 3. `return { unsubscribe() { subscriber.unsubscribe(); } };` —— **返回一个现场拼出来的对象**,它正好满足 `Subscription` 接口(有个 `unsubscribe` 方法),方法内部调用订阅器的 `unsubscribe`。 +- **语法小课堂:对象字面量里直接写方法。** `{ unsubscribe() { ... } }` 是"对象里内联一个方法"的简写,等价于 `{ unsubscribe: function() { ... } }`。这里现场造了一个符合 `Subscription` 形状的小对象返回出去。 +- **为什么不直接 `return subscriber`?** 因为 `TopicSubscriber` 有 `subscribe`/`unsubscribe` 等一堆方法,而接口只要"一个能 `unsubscribe` 的东西"。包一层小对象,**只暴露 `unsubscribe`**,把内部的订阅器藏起来——这是"最小暴露"的好习惯。 + +--- + +## 第 70-81 行:`callService`(转发 + 重塑结果形状) + +```typescript + async callService(options: ServiceCallOptions): Promise { + const response = await callService( + this.client, + options.service, + options.args, + options.type, + ); + return { + result: response.result, + values: response.values, + }; + } +``` + +- 调用第⑧篇那个 `callService` 函数(把 `client` 和各参数传进去),`await` 拿到 `response`(类型是 `ServiceResponseMessage`)。 +- 然后**重新组装一个 `ServiceCallResult` 返回**:只挑出 `result` 和 `values` 两个字段。 +- **为什么要重新组装、不直接返回 `response`?** 因为 `response`(rosbridge 内部类型 `ServiceResponseMessage`)字段更多(还带 `op`、`service` 等协议字段),而接口承诺返回的是更干净的 `ServiceCallResult`(只有 `result`+`values`)。适配器在这里**把"内部协议形状"翻译成"对外契约形状"**——这正是适配器的核心职责:屏蔽内部细节。 + +> 注意这里有两个 `callService`:导入进来的**函数** `callService`(第⑧篇的),和当前这个**方法** `callService`(接口要求的)。名字相同但一个是模块级函数、一个是类方法,不冲突。方法体里调用的是那个导入的函数。 + +--- + +## 第 83-96 行:`sendActionGoal`(含一处精巧的回调改造) + +```typescript + async sendActionGoal(options: ActionGoalOptions): Promise { + const response = await this.actionClient.sendGoal({ + action: options.action, + actionType: options.actionType, + args: options.args, + onFeedback: options.onFeedback + ? (feedback) => options.onFeedback!(feedback.values) + : undefined, + }); + return { + result: response.result, + values: response.values, + }; + } +``` + +- 主体和服务一样:调 `this.actionClient.sendGoal(...)`(第⑨篇),`await` 拿结果,再重塑成 `ActionResult` 返回。 +- 重点看 `onFeedback` 这一段——它要解决一个"形状不匹配"问题: + +```typescript +onFeedback: options.onFeedback + ? (feedback) => options.onFeedback!(feedback.values) + : undefined, +``` + +- **语法小课堂:三元运算符 `条件 ? A : B`**(第⑥篇见过,这里再用)。读作"如果 `条件` 成立就取 `A`,否则取 `B`"。 +- 拆解这三段: + - 条件:`options.onFeedback` —— 调用方传了进度回调吗?(真值判断,第⑦篇) + - 成立(`?` 后):`(feedback) => options.onFeedback!(feedback.values)` —— 给 `sendGoal` 一个**改造过的**回调。 + - 不成立(`:` 后):`undefined` —— 没传就给 `undefined`,即不要进度。 +- **那个"改造"是什么意思?** 关键在 `feedback.values`: + - 第⑨篇的 `ActionClient` 给进度回调送来的是**整条** `ActionFeedbackMessage`(含 `op`、`action`、`values` 等)。 + - 但本适配器对外的 `ActionGoalOptions.onFeedback`(第③篇)约定只给**进度数据本身**,也就是 `values` 那部分。 + - 所以这里包一层:底层送来整条 `feedback`,我们只把 `feedback.values` 转交给调用方的回调。又是一次"内部形状→对外形状"的翻译,只不过发生在回调的参数上。 + - `options.onFeedback!` 的 `!` 是非空断言(第⑨篇讲过):在 `? :` 的成立分支里,`onFeedback` 必然存在,用 `!` 让 TS 闭嘴。 + +--- + +## 第 98-100 行:`cancelActionGoal`(转发) + +```typescript + async cancelActionGoal(action: string): Promise { + await this.actionClient.cancelGoal(action); + } +``` + +- 最简单的转发:把取消请求甩给 `actionClient.cancelGoal`(第⑨篇)。 + +--- + +## 第 102-112 行:`listTopics`(首次见数组 `.map`) + +```typescript + async listTopics(): Promise { + const response = await callService( + this.client, + "/rosapi/topics", + {}, + "rosapi/srv/Topics", + ); + const topics = (response.values?.["topics"] as string[]) ?? []; + const types = (response.values?.["types"] as string[]) ?? []; + return topics.map((name, i) => ({ name, type: types[i] ?? "" })); + } +``` + +这是"自省"方法——问机器人"你有哪些话题"。逐行: + +- 调用一个特殊服务 `"/rosapi/topics"`(rosbridge 自带的"查询所有话题"服务),类型 `"rosapi/srv/Topics"`,参数空对象 `{}`。`await` 拿到 `response`。 +- 从响应里取两个并列数组: + +```typescript +const topics = (response.values?.["topics"] as string[]) ?? []; +``` +- 一行里塞了三个语法点,拆开: + - `response.values?.["topics"]` —— **可选链 `?.`**(第⑥篇)+ **方括号取属性**:先安全地拿 `response.values`(它可能 `undefined`),再取里面 `"topics"` 这个键。`?.` 保证 `values` 万一不存在也不会崩,整体返回 `undefined`。 + - **语法小课堂:`对象["键名"]` 和 `对象.键名` 等价**,都是取属性。当键名是变量、或含特殊字符、或像这里来自动态数据时,常用方括号写法。 + - `as string[]` —— 类型断言(第⑥篇):告诉 TS"这个值是字符串数组"。因为 `values` 是 `Record`,取出来是 `unknown`,得断言成具体类型才能用。 + - `?? []` —— 空值合并(第⑥篇):万一前面整串是 `undefined`,就兜底成**空数组**。保证 `topics` 一定是个数组,后面 `.map` 才不会崩。 +- `types` 同理,拿到所有话题的类型数组(和 `topics` 一一对应,第 i 个名字配第 i 个类型)。 + +```typescript +return topics.map((name, i) => ({ name, type: types[i] ?? "" })); +``` +- **语法小课堂:数组的 `.map()` 方法。** `数组.map(函数)` 会**遍历数组每个元素、把它丢给函数、收集每次的返回值、组成一个新数组**。它不改原数组,而是"逐个变形产出新数组"。 + - 这里回调 `(name, i) => (...)` 收两个参数:`name`(当前元素,即话题名)、`i`(它的下标,从 0 开始)。`.map` 的回调第二个参数总是下标。 + - 返回 `({ name, type: types[i] ?? "" })`:把每个话题名变成一个 `TopicInfo` 对象。 + - **语法小课堂:箭头函数返回对象要套圆括号 `({...})`。** 因为箭头函数后面直接跟 `{` 会被当成"函数体的花括号",而不是"对象"。套一层圆括号 `({ ... })` 才表示"我要返回这个对象"。这是新手极易踩的坑。 + - `{ name, ... }` —— `name` 是对象简写(即 `name: name`)。 + - `type: types[i] ?? ""` —— 取第 `i` 个类型;万一对应不上(`types[i]` 是 `undefined`),兜底成空字符串 `""`。 + - 整句:把两个并列数组(名字数组 + 类型数组)"缝"成一个 `TopicInfo` 对象数组。这是处理"平行数组"的经典 `.map` 用法。 + +--- + +## 第 114-124 行:`listServices`(和 listTopics 几乎一样) + +```typescript + async listServices(): Promise { + const response = await callService(this.client, "/rosapi/services", {}, "rosapi/srv/Services"); + const services = (response.values?.["services"] as string[]) ?? []; + const types = (response.values?.["types"] as string[]) ?? []; + return services.map((name, i) => ({ name, type: types[i] ?? "" })); + } +``` + +- 结构和 `listTopics` 一模一样,只是换成查询服务 `"/rosapi/services"`。理解了上一个,这个直接跳过。 + +--- + +## 第 126-147 行:`listActions`(最复杂——靠"猜"凑出动作列表) + +```typescript + async listActions(): Promise { + // rosapi has no built-in action listing. Heuristic: action servers expose + // topics matching */_action/feedback. Extract action names from that pattern. + const topics = await this.listTopics(); + const actions: ActionInfo[] = []; + const feedbackSuffix = "/_action/feedback"; + + for (const topic of topics) { + if (topic.name.endsWith(feedbackSuffix)) { + const actionName = topic.name.slice(0, -feedbackSuffix.length); + // Feedback type is like "pkg/action/Name_FeedbackMessage" + // Extract base action type by stripping "_FeedbackMessage" suffix + let actionType = topic.type; + if (actionType.endsWith("_FeedbackMessage")) { + actionType = actionType.slice(0, -"_FeedbackMessage".length); + } + actions.push({ name: actionName, type: actionType }); + } + } + + return actions; + } +``` + +- 注释先说明难处:rosbridge **没有**"列出所有动作"的现成服务。于是用一个**启发式(heuristic,即"经验性的猜测办法")**:每个动作服务器都会暴露一个名字以 `/_action/feedback` 结尾的话题,**反过来从这些话题名就能倒推出动作名**。 +- `const topics = await this.listTopics();` —— 先复用上面写的方法,拿到所有话题。(一个方法调用本类另一个方法。) +- `const actions: ActionInfo[] = [];` —— 准备一个**空数组**用来装结果。`: ActionInfo[]` 显式标注它是动作信息数组。 +- `const feedbackSuffix = "/_action/feedback";` —— 把要找的后缀存成常量,避免重复写。 + +**`for...of` 遍历每个话题:**(回忆第⑥篇 `for...of`) + +```typescript +if (topic.name.endsWith(feedbackSuffix)) { +``` +- **语法小课堂:`字符串.endsWith(x)`** —— 判断字符串是否以 `x` 结尾,返回 `true`/`false`(回忆第⑥篇见过的 `.startsWith`,这是它的"结尾版")。只挑名字以 `/_action/feedback` 结尾的话题。 + +```typescript +const actionName = topic.name.slice(0, -feedbackSuffix.length); +``` +- **语法小课堂:`字符串.slice(起, 止)` 切片。** 取从下标 `起` 到 `止`(不含 `止`)的一段。 + - `0` —— 从头开始。 + - `-feedbackSuffix.length` —— **负数下标表示"从末尾往回数"**。`feedbackSuffix.length` 是后缀长度(`"/_action/feedback"` 有 17 个字符),`-17` 意思是"切到倒数第 17 个字符为止",**正好把末尾那段后缀砍掉**。 + - 例:`"/nav/_action/feedback"` 砍掉 `/_action/feedback` → 剩 `"/nav"`,这就是动作名。 + +```typescript +let actionType = topic.type; +if (actionType.endsWith("_FeedbackMessage")) { + actionType = actionType.slice(0, -"_FeedbackMessage".length); +} +``` +- 同理,话题的**类型**形如 `"pkg/action/Name_FeedbackMessage"`,把末尾的 `_FeedbackMessage` 砍掉,还原出动作的基础类型。 +- 用 `let`(不是 `const`)因为 `actionType` 的值可能被改写。 +- `-"_FeedbackMessage".length` —— 直接对字符串字面量取 `.length` 当负下标,省得再起个变量。 + +```typescript +actions.push({ name: actionName, type: actionType }); +``` +- **语法小课堂:`数组.push(x)`** —— 往数组末尾追加一个元素。这里把拼好的 `ActionInfo` 塞进结果数组。 + +- 循环结束后 `return actions;` 返回收集到的所有动作。 +- `}` 关闭方法,再一个 `}` 关闭整个类。 + +> **这个方法和前两个 `list` 的区别**:前两个是"调服务直接拿到现成列表再 `.map`";这个是"没有现成服务,只能拿话题列表、用 `for` 循环 + 字符串切割**猜**出来"。注释里 `Heuristic`(启发式)就是诚实地说"这是个经验性的近似办法,不保证 100% 准"。 + +--- + +## 整章回顾 + +`RosbridgeTransport` 是 rosbridge 模式的"成品总装": + +> **对外严格长成 `RosTransport`(靠 `implements` 强制对齐 13 个方法),对内把每个调用翻译给 `client` 和三个帮手,并负责把"内部协议形状"重塑成"对外契约形状"。** + +三类方法模式: +| 模式 | 例子 | 特点 | +|---|---|---| +| 纯转发 | `connect`/`disconnect`/`getStatus`/`onConnection`/`cancelActionGoal` | 一句甩给底层 | +| 转发 + 重塑结果 | `callService`/`sendActionGoal`/`publish`/`subscribe` | 调底层后,把结果/回调改造成接口要求的形状 | +| 自省(查能力) | `listTopics`/`listServices`/`listActions` | 调 rosapi 服务或猜话题,用 `.map`/`for` 整理成信息数组 | + +**语法点回顾清单**(本章新增/巩固): +- `implements`:类承诺实现某接口,TS 强制逐方法检查(vs `extends` 继承) +- `import`(值,要 new/调用)vs `import type`(仅类型,运行时抹除)的判断标准 +- 对象字面量内联方法 `{ unsubscribe() {...} }`、现场造小对象满足接口 +- 三元 `条件 ? A : B`(巩固)、`undefined` 兜底 +- 数组 `.map((元素, 下标) => ...)`:逐个变形产出新数组 +- 箭头函数返回对象要套圆括号 `(({...}))`(易错点) +- `对象["键名"]` 方括号取属性、平行数组"缝合" +- `字符串.endsWith` / `.slice(起, 止)` / 负数下标"从末尾数" +- `数组.push()` 追加元素、`for...of` 收集结果 + +下一份:[`transport/factory.ts` 逐行详解 →](11-transport-factory.ts.md)(用判别联合 + 动态 `import()` 按模式造出对应适配器,并见识 `never` 穷举检查) + + + +--- + + +# 逐行详解 ⑪:`transport/factory.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/factory.ts](../../extensions/openclaw-plugin/src/transport/factory.ts) +> +> 推荐阅读顺序第 11 个文件。它只有一个函数 `createTransport`——**"工厂(factory)"**:你把一份配置交给它,它看里头的 `mode` 字段,造出对应的传输适配器(rosbridge / local / webrtc 之一)。这篇会用上第③篇的"判别联合",并第一次见到两个进阶语法:**动态 `import()`** 和 **`never` 穷举检查**。 + +--- + +## 先理解"工厂函数"是什么 + +回忆第③篇:`TransportConfig` 是个判别联合——它要么是 rosbridge 配置、要么是 local、要么是 webrtc,靠 `mode` 字段区分。第⑩篇我们写好了 rosbridge 的适配器 `RosbridgeTransport`。 + +但上层代码不想自己操心"该 `new` 哪个适配器"。于是有了**工厂**:给它配置,它替你判断 `mode`、`new` 出正确的那一个、返回出来。上层只管说"按这份配置给我一个传输",至于底层是哪种,工厂内部消化。 + +``` +config(带 mode 标签) ──> createTransport ──┬─ mode="rosbridge" → new RosbridgeTransport + ├─ mode="local" → new LocalTransport + └─ mode="webrtc" → new WebRTCTransport +``` + +--- + +## 第 1-2 行:导入(全是类型) + +```typescript +import type { TransportConfig } from "./types.js"; +import type { RosTransport } from "./transport.js"; +``` + +- 两个都是 `import type`——只拿类型当标注:`TransportConfig`(入参类型)和 `RosTransport`(返回类型)。 +- **注意**:这里**没有**用普通 `import` 把三个适配器类导进来。这是故意的——下面会用"动态 `import()`"按需加载,原因马上讲。 + +--- + +## 第 4-10 行:函数注释与签名 + +```typescript +/** + * Create a RosTransport instance for the given deployment mode. + * + * Uses dynamic import() to load the correct adapter so that + * unused adapters (and their dependencies) are never loaded. + */ +export async function createTransport(config: TransportConfig): Promise { +``` + +- 注释翻译:「按给定部署模式创建一个 `RosTransport` 实例。**用动态 `import()` 只加载用到的那个适配器**,这样没用到的适配器(及其依赖)永远不会被加载。」——这句注释提前剧透了本篇的设计核心,下面会展开。 +- `export async function createTransport(config: TransportConfig): Promise` —— 导出一个 `async` 函数,吃一份 `TransportConfig`,返回 `Promise`(造适配器涉及动态加载,是异步的,所以包 `Promise`)。 + +--- + +## 第 11 行:用判别联合开 `switch` + +```typescript + switch (config.mode) { +``` + +- 回忆第⑥篇的 `switch`:拿 `config.mode` 的值去逐个 `case` 匹配。 +- 回忆第③篇的**类型收窄**:因为 `TransportConfig` 是判别联合、`mode` 是判别字段,所以进入每个 `case` 后,TS 会**自动确定 `config` 到底是哪种配置**——在 `case "rosbridge"` 里它知道 `config` 是 `RosbridgeTransportConfig`,于是 `config.rosbridge` 能安全访问。这正是判别联合的威力在真实代码里的体现。 + +--- + +## 第 12-15 行:`rosbridge` 分支(首见动态 `import()`) + +```typescript + case "rosbridge": { + const { RosbridgeTransport } = await import("./rosbridge/adapter.js"); + return new RosbridgeTransport(config.rosbridge); + } +``` + +- `case "rosbridge": {` —— 注意 `case` 后面跟了一对花括号 `{ }`。 + - **语法小课堂:给 `case` 套花括号 `{ }` 是为了开一个"块级作用域"。** 因为我们要在 `case` 里用 `const` 声明变量(`RosbridgeTransport`)。如果不套花括号,多个 `case` 里声明同名变量会冲突。套上 `{ }`,每个 `case` 的变量就关在自己的小房间里,互不打架。 + +- `const { RosbridgeTransport } = await import("./rosbridge/adapter.js");` —— 本篇重头戏: + - **语法小课堂:动态 `import()`。** 我们之前见的 `import { X } from "..."`(写在文件顶部)是**静态导入**——文件一加载,所有静态导入的东西立刻全部加载进来。而 `import("...")` 写成**函数调用的样子**,是**动态导入**——它在**代码执行到这一行时**才去加载那个模块,并返回一个 Promise(所以前面要 `await`)。 + - **为什么要动态加载?** 看注释那句话:local 模式依赖 `rclnodejs`、webrtc 模式依赖一堆 WebRTC 库。如果用静态导入把三个适配器都写在文件顶部,那么**无论用户用哪种模式,三个适配器及其全部依赖都会被加载**——哪怕你只用 rosbridge,也被迫加载了 `rclnodejs`(可能根本没装,直接报错)。 + - 用动态 `import()` 后,**只有真正走到某个 `case` 才加载那一个适配器**。用 rosbridge 就只加载 rosbridge 适配器,碰都不碰 local/webrtc。这叫**按需加载(lazy loading)**,省内存、避免为没用的模式安装依赖。 + - `const { RosbridgeTransport } = ...` —— **对象解构**(回忆第⑥篇数组解构,这是对象版):从加载进来的模块对象里,把名为 `RosbridgeTransport` 的导出"拆"出来。等价于"拿到那个模块,取它的 `RosbridgeTransport` 这一项"。 +- `return new RosbridgeTransport(config.rosbridge);` —— `new` 出第⑩篇那个适配器,把 `config.rosbridge`(连接选项)喂给它的构造函数,返回。 + - 这里 `config.rosbridge` 能直接访问,全靠 `case "rosbridge"` 里 TS 已经收窄确定了 `config` 是 rosbridge 配置。 + +--- + +## 第 17-30 行:`local` 分支(含友好的错误处理) + +```typescript + case "local": { + try { + const { LocalTransport } = await import("./local/transport.js"); + return new LocalTransport(config.local); + } catch (e: any) { + if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND") { + throw new Error( + 'Mode A (local) requires the "rclnodejs" package. ' + + "Install it with: pnpm add rclnodejs (with ROS2 workspace sourced)", + ); + } + throw e; + } + } +``` + +- 主体和 rosbridge 一样:动态 `import` local 适配器、`new` 出来返回。但它包了 `try/catch`(回忆第②、⑥篇)来处理一个现实问题——**`rclnodejs` 可能没装**。 +- `catch (e: any)` —— 抓住错误,命名为 `e`。 + - **语法小课堂:`e: any`。** `any` 是 TS 里"放弃类型检查"的逃生类型——标成 `any` 的值你爱怎么用就怎么用,TS 不管。catch 抓到的错误类型默认很模糊,这里标 `any` 图方便,好直接访问 `e.code`。(`any` 应少用,因为它关掉了类型保护,但在 catch 这种"啥错都可能"的场景常见。) +- `if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND")` —— 判断这个错误是不是"模块没找到": + - `e?.code` —— 可选链(第⑥篇):安全取错误的 `code` 字段(万一 `e` 不是对象也不崩)。 + - `=== "ERR_MODULE_NOT_FOUND" || ... === "MODULE_NOT_FOUND"` —— 用 `||`(或,第⑥篇)匹配两种可能的错误码(不同 Node 版本/场景报的码不一样,两个都认)。 +- 若确实是"模块缺失",就 `throw new Error(...)` **抛出一条人话错误**:告诉用户"local 模式需要 `rclnodejs` 包,请这样安装"。 + - 字符串用 `+` 拼接(回忆:`'...' + "..."` 把两段字符串接起来)。这比直接把 Node 原始的 `ERR_MODULE_NOT_FOUND` 甩给用户友好得多。 +- `throw e;` —— 如果错误**不是**模块缺失(是别的问题),就**原样把它再抛出去**,别吞掉。 +- **设计要点**:只有 local 分支做了这层"翻译错误",因为只有它依赖一个"可能没装"的可选包 `rclnodejs`。rosbridge 是核心、依赖必装,不用这层保护。 + +--- + +## 第 32-35 行:`webrtc` 分支 + +```typescript + case "webrtc": { + const { WebRTCTransport } = await import("./webrtc/transport.js"); + return new WebRTCTransport(config.webrtc); + } +``` + +- 和 rosbridge 分支同款:动态加载 webrtc 适配器、`new` 出来返回。(webrtc 目前是存根,第 30 篇会讲,但工厂这里照常写。) + +--- + +## 第 37-40 行:`default` 分支 + `never` 穷举检查(本篇精华) + +```typescript + default: { + const _exhaustive: never = config; + throw new Error(`Unknown transport mode: ${(_exhaustive as TransportConfig).mode}`); + } + } +} +``` + +- `default:` —— `switch` 的兜底分支(前面所有 `case` 都没匹配上才走这)。 +- `const _exhaustive: never = config;` —— **本篇最烧脑、也最妙的一行**。 + +**语法小课堂:`never` 类型与"穷举检查(exhaustiveness check)"。** +- `never` 是 TS 里一个特殊类型,意思是"**不可能存在的值**"。能赋给 `never` 的,只有"类型上根本不可能到达"的东西。 +- 想想这里的逻辑:`config` 的类型是判别联合 `rosbridge | local | webrtc` 三选一。如果上面三个 `case` 已经把三种 `mode` **全覆盖**了,那么走到 `default` 时,`config` 在 TS 看来**剩下的可能性已经为空**——它的类型被收窄成了 `never`(没有任何一种 mode 没处理)。 +- 此时 `const _exhaustive: never = config;` 能编译通过,因为 `config` 确实是 `never`。✅ +- **关键在于"将来加新模式"时**:假设以后有人往 `TransportConfig` 加了第四种 `mode: "bluetooth"`,却**忘了**在这个 `switch` 里加对应 `case`。那么走到 `default` 时,`config` 还可能是 bluetooth 配置——它的类型**不是 `never`** 了(还剩 bluetooth 没处理)。于是 `const _exhaustive: never = config;` 会**编译报错**:"bluetooth 配置不能赋给 never"。 +- **这就是穷举检查的妙处**:它把"漏处理某个 case"从"运行时才发现的 bug"变成"**编译时立刻报错**"。等于让 TS 站岗:以后谁加了新模式忘了在这里处理,代码根本编译不过,强制你补上。`_exhaustive` 这个变量名(前缀 `_` 表示"我只是用来做检查、不真用它")和注释惯例都在表达这个意图。 + +- `throw new Error(\`Unknown transport mode: ${(_exhaustive as TransportConfig).mode}\`);` —— 万一真在运行时走到了这里(比如配置是从外部 JSON 来的、绕过了类型检查、塞了个非法 mode),就抛错报告这个未知 mode。 + - 模板字符串 `` `...${...}` ``(第⑥篇)插值。 + - `(_exhaustive as TransportConfig).mode` —— `_exhaustive` 类型是 `never`,直接访问 `.mode` 不行(`never` 上没有任何属性),所以先 `as TransportConfig` 断言回联合类型,才能取它的 `.mode` 拼进错误信息。这是"为了打印出那个非法值"的小技巧。 +- 两个 `}` 分别关闭 `default` 块和 `switch`,最后 `}` 关闭函数。 + +--- + +## 把工厂的两个设计亮点拎出来 + +这个小函数浓缩了两个值得记住的工程手法: + +1. **动态 `import()` 按需加载**:只加载用到的那一个适配器,避免为没用的模式背上沉重依赖(尤其 local 的 `rclnodejs`)。判断标准:**当某些模块"很重"或"可能没装"、且不一定会用到时,用动态 import 延迟到真要用时再加载。** + +2. **`never` 穷举检查**:在 `switch` 的 `default` 里写 `const _x: never = 那个判别变量;`,让 TS 在"将来漏处理某个分支"时**编译期就报错**。这是判别联合的标准搭档,几乎所有处理判别联合的 `switch` 都该配一个,等于免费上一道保险。 + +--- + +## 整章回顾 + +- `createTransport` 是传输层的"工厂":吃一份 `TransportConfig`,按 `mode` 造出对应适配器返回。 +- 它把第③篇的**判别联合 + 类型收窄**、第⑩篇的**适配器**,和两个新手法(**动态 import**、**`never` 穷举**)串在一起,是"前面零碎知识汇合"的一篇。 +- local 分支额外做了**友好错误翻译**,因为它依赖可选包 `rclnodejs`。 + +**语法点回顾清单**(本章新增/巩固): +- 动态 `import("...")`:执行到才加载、返回 Promise、按需加载(vs 顶部静态 import) +- 对象解构 `const { X } = 模块`(从模块/对象里拆出某项) +- `case "x": { ... }` 给 case 套花括号开块级作用域(好在里面用 `const`) +- `never` 类型 + 穷举检查:`const _e: never = 判别变量` 让漏分支在编译期报错 +- `catch (e: any)`、`e?.code`、`||` 多条件、`throw new Error` 翻译错误、`throw e` 原样重抛 +- 判别联合在 `switch` 里的类型收窄(巩固第③篇) + +下一份:[`service.ts` 逐行详解 →](12-service.ts.md)(把工厂造出的传输接进插件的"服务",管理连接生命周期) + + + +--- + + +# 逐行详解 ⑫:`service.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/service.ts](../../extensions/openclaw-plugin/src/service.ts) +> +> 推荐阅读顺序第 12 个文件。前面我们把"造传输"的活儿交给了第⑪篇的工厂 `createTransport`。但工厂只负责"造"——造出来之后,谁来保管这个传输?什么时候连上、什么时候断开?运行中想换一种模式怎么办?这些"**生命周期管理**"全在本篇。它是工厂的上一层调用者,也是整个插件与传输层之间的"中枢"。 + +--- + +## 先理解本篇要解决的三件事 + +1. **保管唯一的传输实例**:整个插件只该有**一个**活着的传输连接,所有工具共用它。本篇用一个"模块级变量"把它存起来。 +2. **管连接的开关**:插件启动时连上 ROS2,插件停止时断开——这叫"服务(service)"的生命周期,交给 OpenClaw 托管。 +3. **运行中热切换模式**:用户发 `/transport webrtc` 想换种连法时,先断旧的、再连新的,过程中还得防止"切到一半又来一个切换请求"。 + +这三件事分别对应文件里的三块:模块级状态、`switchTransport`、`registerService`。 + +--- + +## 第 1-6 行:导入 + +```typescript +import type { TransportConfig } from "./transport/types.js"; +import type { RosTransport } from "./transport/transport.js"; +import { createTransport } from "./transport/factory.js"; +import type { OpenClawPluginApi } from "./plugin-api.js"; +import type { PluginLogger } from "./plugin-api.js"; +import type { RosClawConfig } from "./config.js"; +``` + +- 大部分是 `import type`(只借类型,回忆第⑩篇判断标准):`TransportConfig`(第③篇配置)、`RosTransport`(第④篇接口)、`OpenClawPluginApi`/`PluginLogger`(第①篇宿主 API)、`RosClawConfig`(第②篇校验后的配置)。 +- **只有一个是值导入**:`import { createTransport }`——因为下面要**调用**它(工厂函数),不是只拿它当类型标注。判断标准还是那句:要调用/`new` 的用 `import`,只标注的用 `import type`。 + +--- + +## 第 8-15 行:三个"模块级变量"——本篇的状态核心 + +```typescript +/** Shared transport instance for all tools. */ +let transport: RosTransport | null = null; + +/** Tracks the active transport mode. */ +let currentMode: TransportConfig["mode"] | null = null; + +/** Concurrency guard — prevents overlapping switchTransport calls. */ +let switching = false; +``` + +**语法小课堂:模块级变量(module-level state)。** +- 前面我们见过的变量,要么在函数体里(用完就没),要么是类的字段(挂在某个对象实例上)。这三个变量**直接写在文件最外层**,不属于任何函数或类——它们是"**模块级变量**"。 +- 它的特点:**整个文件共享同一份,且只要程序在跑就一直存在**。第一次加载这个文件时它们被创建,之后这个文件里所有函数读到的都是同一份。 +- 这正好满足需求:"整个插件只有一个传输实例"——把它存成模块级变量,谁需要都来这里拿同一个。这其实是**单例(singleton)模式**的最朴素实现:用模块级变量保管唯一实例。 + +逐个看: + +- `let transport: RosTransport | null = null;` + - 类型是 `RosTransport | null`(联合类型,第③篇):要么是个传输实例,要么是 `null`(还没连或已断开)。 + - 初值 `null`——插件刚加载时还没连接。 + - 用 `let` 不用 `const`,因为它会被反复改写(连上时赋实例、断开时赋回 `null`)。 + +- `let currentMode: TransportConfig["mode"] | null = null;` + - **语法小课堂:`TransportConfig["mode"]` 是"索引访问类型(indexed access type)"。** 这是个新写法:用**方括号 + 字段名**,从一个对象类型里"取出某个字段的类型"。 + - `TransportConfig` 是第③篇那个判别联合,它的 `mode` 字段类型是 `"rosbridge" | "local" | "webrtc"`(三个字面量之一)。 + - `TransportConfig["mode"]` 就等于"把 `mode` 那个字段的类型抠出来",结果正是 `"rosbridge" | "local" | "webrtc"`。 + - **为什么不直接写 `"rosbridge" | "local" | "webrtc"`?** 因为那样要重复抄一遍。用 `TransportConfig["mode"]` 等于说"跟着源头走"——以后 `TransportConfig` 加了第四种 mode,这里**自动跟着变**,不用手改。这是"别重复自己"的类型版技巧。 + - 整体 `TransportConfig["mode"] | null`:当前模式(三选一)或 `null`(没连接)。 + +- `let switching = false;` + - 一个布尔"门闩"。没写类型标注——TS 看到初值 `false` 就**自动推断**它是 `boolean`(回忆第②篇:能推断出来就不必手写)。 + - 它的用途:标记"现在是否正有一个切换操作在进行",防止两个切换撞车。下面 `switchTransport` 会用到。 + +--- + +## 第 17-23 行:`getTransport`——取出传输(取不到就报错) + +```typescript +/** Get the active transport. Throws if not connected. */ +export function getTransport(): RosTransport { + if (!transport) { + throw new Error("Transport not initialized. Is the service running?"); + } + return transport; +} +``` + +- 这是给"所有工具"用的取货口:工具想发指令,先来这里拿传输实例。 +- `if (!transport)` —— **逻辑取反 `!`**(回忆第⑥篇:写在值**前面**是取反)。`transport` 是 `RosTransport | null`,当它是 `null` 时 `!transport` 为 `true`,进入分支。即"还没连接"。 +- `throw new Error(...)` —— 没连接就抛错,提示"传输没初始化,服务在跑吗?"。这比返回 `null` 让调用方自己去判空更省事——**取不到就直接报清楚的错**。 +- `return transport;` —— 走到这里说明 `transport` 不是 `null`。 + - **小细节**:经过上面 `if (!transport) throw` 之后,TS 会**自动收窄**——它知道"能走到这行,`transport` 必然不是 `null`",于是返回类型 `RosTransport`(不含 `null`)成立,不用再写 `!` 断言。这是"提前 throw 帮 TS 收窄"的常见手法。 + +--- + +## 第 25-28 行:`getTransportMode`——查当前模式 + +```typescript +/** Get the current transport mode, or null if no transport is active. */ +export function getTransportMode(): TransportConfig["mode"] | null { + return currentMode; +} +``` + +- 最简单的"读一个模块级变量并返回"。返回类型就是上面那个索引访问类型 `TransportConfig["mode"] | null`。 +- 谁会用它?比如 `/transport` 命令想显示"当前用的是哪种模式",就调它。 + +--- + +## 第 30-65 行:`switchTransport`——运行中热切换(本篇主菜) + +这是全篇最有料的函数:用户在聊天里发 `/transport local` 想换种连法,它负责"断旧连新"。 + +### 第 30-38 行:注释 + 签名 + 防撞车检查 + +```typescript +/** + * Switch the active transport at runtime. + * Disconnects the old transport, creates a new one, and connects it. + * No rollback on failure — the user retries with `/transport`. + */ +export async function switchTransport(config: TransportConfig, logger: PluginLogger): Promise { + if (switching) { + throw new Error("A transport switch is already in progress. Please wait."); + } +``` + +- 注释最后一句很诚实:「**失败不回滚**——用户用 `/transport` 重试即可。」意思是如果切到一半连不上,它不会帮你自动切回旧的,而是让你重来。这是个有意的简化设计,写在注释里以免误会。 +- `async ... (config, logger): Promise` —— 异步函数(连接要等),不返回有用值(`Promise`)。收两个参数:目标配置、记日志用的 `logger`。 +- `if (switching) { throw ... }` —— **并发门闩(concurrency guard)的第一半**: + - 进函数先看 `switching`。如果它已经是 `true`,说明"已经有一个切换在进行中",立刻抛错拒绝,让用户等。 + - **为什么要防这个?** 切换是"先断后连"的多步异步操作,中间有 `await`(会让出执行权)。万一用户连点两次 `/transport`,第二次可能在第一次还没切完时就插进来,两个流程交织会把状态搞乱(比如同时改 `transport`)。门闩保证"同一时刻只有一个切换在跑"。 + +### 第 40-47 行:上闩 + 断开旧传输 + +```typescript + switching = true; + try { + // Disconnect old transport + if (transport) { + await transport.disconnect(); + transport = null; + currentMode = null; + } +``` + +- `switching = true;` —— **门闩的第二半**:把闩落下。从这一刻起,再有切换请求进来就会被上面那个 `if` 挡掉。 +- `try {` —— 注意紧接着开了 `try`。配合下面的 `finally` 保证"无论成败,最后一定把闩抬起来"。(回忆第⑨篇 `try/finally`:清理类代码的归宿。这里"清理"的就是那个门闩。) +- `if (transport) { ... }` —— 如果当前**有**活着的传输,先把它收拾掉: + - `await transport.disconnect();` —— 等它断开(第④/⑩篇的 `disconnect`)。 + - `transport = null;` / `currentMode = null;` —— 把两个模块级状态清空,表示"现在没有活动传输"。 + - 注意顺序:**先断开、再清空引用**。 + +### 第 49-61 行:造新的、连上、记录 + +```typescript + // Create and connect new transport + const newTransport = await createTransport(config); + + newTransport.onConnection((status: string) => { + logger.info(`ROS2 transport status: ${status}`); + }); + + await newTransport.connect(); + + transport = newTransport; + currentMode = config.mode; + + logger.info(`ROS2 transport switched to ${config.mode}`); +``` + +- `const newTransport = await createTransport(config);` —— 调第⑪篇的工厂,按新配置造一个传输。`await` 因为工厂是异步的(动态 import)。 + - **注意这里先存进局部变量 `newTransport`,而不是直接赋给模块级的 `transport`。** 为什么?因为接下来的 `connect` 可能失败。如果直接赋给 `transport`,万一连接失败,`transport` 就指向一个"造出来但没连上"的半成品。先放局部变量,**等连接成功了再赋给模块级变量**,更稳妥。 +- `newTransport.onConnection((status: string) => { logger.info(...) });` —— 登记一个"连接状态变化"的回调(第④/⑩篇的 `onConnection`)。 + - 回调 `(status: string) => { logger.info(\`...${status}\`) }`:每次状态变化,往日志写一行。`status: string` 显式标了参数类型。 + - 这是个"只为副作用(记日志)"的回调,不关心它返回的退订函数,所以没接住返回值。 +- `await newTransport.connect();` —— 真正连上去,等它连成功。**如果这一步抛错,下面三行就不会执行**,`transport` 也就不会被赋成这个连不上的实例——这正是"先存局部变量"的好处兑现。 +- 连成功后才提交状态: + - `transport = newTransport;` —— 现在才把新传输放进模块级变量,正式"上岗"。 + - `currentMode = config.mode;` —— 记下当前模式。 + - `logger.info(...)` —— 日志报告切换成功。 + +### 第 62-65 行:`finally` 抬闩 + +```typescript + } finally { + switching = false; + } +} +``` + +- 不管上面 `try` 块是**成功跑完**、还是**中途抛错**(比如连接失败),`finally` 都一定执行。 +- `switching = false;` —— 把门闩抬起来,允许下一次切换。 +- **为什么非得放 `finally`?** 设想:如果把 `switching = false` 写在 `try` 块末尾,那么一旦连接失败抛错,这行就被跳过,门闩永远落着——之后所有切换请求都会被"已有切换在进行"挡掉,等于把功能锁死了。放进 `finally`,才能保证"哪怕这次切换炸了,门闩也会被抬起,不影响下次"。这是第⑨篇 `finally` 用法的又一个真实范例。 + +> **整个 `switchTransport` 的骨架**:上闩 → `try`{ 断旧 → 造新 → 连新 → 提交状态 } → `finally`{ 抬闩 }。门闩防并发、`finally` 保证闩一定抬起,是这类"有临界区的异步操作"的标准写法。 + +--- + +## 第 67-114 行:`registerService`——把连接生命周期托管给 OpenClaw + +这个函数在插件启动时被调一次(下一篇 `index.ts` 会看到),作用是"告诉 OpenClaw:我有个叫 ros2-transport 的服务,开机时这样连、关机时这样断"。 + +### 第 67-72 行:注释 + 签名 + 取模式 + +```typescript +/** + * Register the ROS2 transport connection as an OpenClaw managed service. + * The service handles connection lifecycle (connect on start, disconnect on stop). + */ +export function registerService(api: OpenClawPluginApi, config: RosClawConfig): void { + const mode = config.transport.mode; +``` + +- 不是 `async`——它本身只是"登记"(瞬间完成),真正的异步连接逻辑写在下面 `start`/`stop` 里。 +- 参数:`api`(第①篇宿主提供的接口)、`config`(第②篇校验后的整份配置)。 +- `const mode = config.transport.mode;` —— 从配置里取出用户选定的传输模式,存个短变量备用。 + +### 第 74-90 行:注册服务 + `start` 的开头 + +```typescript + api.registerService({ + id: "ros2-transport", + + async start(_ctx) { + let transportCfg: TransportConfig; + + switch (mode) { + case "rosbridge": + transportCfg = { mode: "rosbridge", rosbridge: config.rosbridge }; + break; + case "local": + transportCfg = { mode: "local", local: config.local }; + break; + case "webrtc": + transportCfg = { mode: "webrtc", webrtc: config.webrtc }; + break; + } +``` + +- `api.registerService({ ... })` —— 调宿主的"注册服务"方法,传一个对象描述这个服务:它有 `id`、`start`、`stop`。 +- `id: "ros2-transport"` —— 给服务起个唯一名字,OpenClaw 用它来识别/管理。 +- `async start(_ctx) { ... }` —— **对象里直接写一个 async 方法**(回忆第⑩篇 `{ unsubscribe(){...} }` 的内联方法写法,这里是它的 async 版)。这是 OpenClaw 在"启动服务"时会替我们调的钩子。 + - **语法小课堂:参数名前的下划线 `_ctx`。** OpenClaw 调 `start` 时会传一个上下文对象进来,但本函数**用不到它**。给没用到的参数名加 `_` 前缀(`_ctx` 而非 `ctx`)是一种**约定**:明示"我知道有这个参数,但故意不用它"。这能避免某些 lint 工具报"未使用变量"的警告,也让读代码的人一眼明白。 +- `let transportCfg: TransportConfig;` —— **声明一个变量但暂不赋值**。 + - **语法小课堂:先声明、后赋值。** 之前的变量都是"声明即赋值"(`const x = ...`)。这里先写 `let transportCfg: TransportConfig;`(只声明、给了类型、没给值),打算在下面的 `switch` 里**根据模式赋不同的值**。这种"一处声明、多分支赋值"必须用 `let`(`const` 要求声明时就赋值)。 + - 因为没初值,**必须显式写类型** `: TransportConfig`,否则 TS 不知道它是什么。 +- `switch (mode) { ... }` —— 按模式拼出对应的 `TransportConfig`: + - 每个 `case` 都把对应那一节配置包成判别联合的一员。比如 `case "rosbridge"` 里赋 `{ mode: "rosbridge", rosbridge: config.rosbridge }`——`mode` 字段是判别标签,`rosbridge` 字段装那一节配置(回忆第③篇判别联合的结构)。 + - 每个 `case` 末尾 `break;`(回忆第⑥篇:防止"贯穿"到下一个 case)。 + - **注意这个 `switch` 没有 `default`**。因为 `mode` 的类型是 `"rosbridge" | "local" | "webrtc"` 三选一,三个 case 已穷尽所有可能,TS 能确认"`transportCfg` 在每条路径上都会被赋值",于是后面用它时不报"可能未赋值"的错。(这和第⑪篇工厂里那个带 `default` + `never` 的 switch 是两种风格:那里要在运行时兜底非法输入,这里输入已被类型限死,可省 default。) + +### 第 92-103 行:连接(`start` 的主体) + +```typescript + api.logger.info(`Connecting to ROS2 via ${mode} transport...`); + + transport = await createTransport(transportCfg); + + transport.onConnection((status: string) => { + api.logger.info(`ROS2 transport status: ${status}`); + }); + + await transport.connect(); + currentMode = mode; + api.logger.info(`ROS2 transport connected (mode: ${mode})`); +``` + +- 这段和 `switchTransport` 的"造新连新"几乎一样,但这里直接赋给模块级 `transport`(开机首连,没有"旧的"要保护,也无需局部变量过渡)。 +- 流程:记日志 → `createTransport` 造 → `onConnection` 登记状态日志回调 → `connect` 连上 → 记 `currentMode` → 报告连接成功。 +- 注意这里用的是 `api.logger`(宿主提供的日志器),而 `switchTransport` 用的是单独传入的 `logger` 参数——两者其实是同一个东西,只是来源不同(一个从 `api` 上取,一个由调用方传入)。 + +### 第 105-114 行:断开(`stop`) + +```typescript + async stop(_ctx) { + if (transport) { + await transport.disconnect(); + transport = null; + currentMode = null; + api.logger.info("ROS2 transport disconnected"); + } + }, + }); +} +``` + +- `async stop(_ctx)` —— OpenClaw 在"停止服务"(如插件卸载、应用关闭)时调它。 +- `if (transport) { ... }` —— 有活动传输才需要断(没有就什么都不做)。 +- 断开三连:`await disconnect()` → `transport = null` → `currentMode = null`,再记一行日志。和 `switchTransport` 里"断旧"那段完全一致。 +- 最后 `});` 关闭 `registerService` 的参数对象和调用,`}` 关闭函数。 + +> **`start`/`stop` 是一对"生命周期钩子"**:你只管写"开机怎么连、关机怎么断",**何时**调它们由 OpenClaw 决定。这就是"托管服务(managed service)"——把连接的开关交给宿主统一调度,插件不用自己操心时机。 + +--- + +## 整章回顾 + +`service.ts` 是插件与传输层之间的"中枢",干三件事: + +| 部分 | 函数 | 职责 | +|---|---|---| +| 状态保管 | 三个模块级变量 | 用单例方式存唯一的 `transport`、当前 `currentMode`、并发门闩 `switching` | +| 取货口 | `getTransport`/`getTransportMode` | 给工具取传输实例、查当前模式(取不到就抛错) | +| 热切换 | `switchTransport` | 运行中"断旧连新",靠门闩防并发、靠 `finally` 保证抬闩 | +| 生命周期托管 | `registerService` | 把"开机连、关机断"登记成 OpenClaw 的 `start`/`stop` 钩子 | + +一条主线:**所有人都通过这个模块拿到那个唯一的传输**,而连接的开关被收拢到 `start`/`stop`/`switchTransport` 三处统一管理。 + +**语法点回顾清单**(本章新增/巩固): +- 模块级变量(写在文件最外层、全文件共享、程序在跑就一直存在)——单例的朴素实现 +- 索引访问类型 `TransportConfig["mode"]`(从对象类型里取某字段的类型,跟着源头自动变) +- 先声明后赋值 `let x: T;` + 在 `switch` 各分支赋值(穷尽分支时可省 `default`) +- 提前 `throw` 帮 TS 收窄(`if (!x) throw` 之后 x 不再含 null) +- 并发门闩:布尔标志 + `try { 上闩... } finally { 抬闩 }` +- 参数名加 `_` 前缀(`_ctx`)表示"故意不用" +- 对象里内联 `async` 方法(`async start(_ctx){...}`)(巩固第⑩篇内联方法) +- "先存局部变量、成功后再提交到共享状态"的稳妥赋值顺序 + +下一份:[`index.ts` 逐行详解 →](13-index.ts.md)(整个插件的总入口 `register()`——把上面这些注册函数一次性串起来) + + + +--- + + +# 逐行详解 ⑬:`index.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/index.ts](../../extensions/openclaw-plugin/src/index.ts) +> +> 推荐阅读顺序第 13 个文件,**也是整个插件的"总入口"**。OpenClaw 加载这个插件时,第一个看的就是它。它只有 40 行,没有任何复杂逻辑——它的全部职责就是:**声明"我是谁",并在被加载时把前面(和后面)写好的各个注册函数挨个调一遍,把插件的各部分装配起来。** 读完这篇,整个传输层 + 插件骨架就闭环了。 + +--- + +## 先理解"插件入口"是什么 + +OpenClaw 是宿主程序,RosClaw 是插进去的扩展。宿主怎么知道"这个插件叫什么、有哪些功能"?靠的就是一个约定:**插件必须默认导出(default export)一个对象**,对象里有 `id`、`name`,还有一个 `register` 方法。 + +- 宿主加载插件时,先读这个对象认识它(id/name)。 +- 然后在合适的时机调用它的 `register(api)`,把一个"宿主能力包" `api` 递给插件。 +- 插件就在 `register` 里"安装自己"——注册工具、注册命令、注册服务……全在这一个方法里完成。 + +所以本篇可以一句话概括:**`register` 就是插件的"安装脚本",逐行调用各部分的注册函数。** + +--- + +## 第 1-8 行:导入一堆注册函数 + +```typescript +import type { OpenClawPluginApi } from "./plugin-api.js"; +import { parseConfig } from "./config.js"; +import { registerService } from "./service.js"; +import { registerTools } from "./tools/index.js"; +import { registerSafetyHook } from "./safety/validator.js"; +import { registerRobotContext } from "./context/robot-context.js"; +import { registerEstopCommand } from "./commands/estop.js"; +import { registerTransportCommand } from "./commands/transport.js"; +``` + +- 第 1 行 `import type { OpenClawPluginApi }` —— 唯一的 `import type`,只拿它当 `register` 参数的类型标注(第①篇那个"宿主能力包"接口)。 +- 其余 7 个全是**值导入**(不带 `type`)——因为它们都要被**调用**: + - `parseConfig`(第②篇)—— 把宿主传来的原始配置校验、补默认值。 + - `registerService`(第⑫篇)—— 注册传输连接服务。 + - `registerTools`(第 14 篇会讲)—— 注册给 AI 用的那一批工具。 + - `registerSafetyHook`(第 22 篇)—— 注册"动手前先安全校验"的钩子。 + - `registerRobotContext`(第 23 篇)—— 注册"开聊前给 AI 注入机器人能力清单"的钩子。 + - `registerEstopCommand`(第 24 篇)—— 注册急停命令 `/estop`。 + - `registerTransportCommand`(第 25 篇)—— 注册切换传输命令 `/transport`。 +- **看这串导入就能预读整个插件的功能版图**:一个服务 + 一批工具 + 两个钩子 + 两个命令。本篇把它们装配到一起,而它们各自的实现是后面几篇的内容。 + +--- + +## 第 10-13 行:默认导出一个插件对象 + +```typescript +/** + * RosClaw — OpenClaw plugin for ROS2 robot control via natural language. + */ +export default { + id: "rosclaw", + name: "RosClaw", +``` + +**语法小课堂:`export default` —— "默认导出"。** +- 回忆第①篇我们见过 `export`(具名导出,可以导出很多个,靠名字区分)。这里是另一种:`export default`——**一个文件只能有一个默认导出**,它是"这个文件最主要的那个东西"。 +- 区别在导入端: + - 具名导出 `export function foo()` → 导入时要写花括号且名字对得上:`import { foo } from "..."`。 + - 默认导出 `export default {...}` → 导入时**不写花括号、名字随便起**:`import 随便什么名 from "..."`。 +- OpenClaw 约定"插件的主体用默认导出",所以这里 `export default` 一个对象,宿主加载时就拿这个对象当作"插件本体"。 +- `export default { ... }` 后面直接跟一个对象字面量 `{ id, name, register }`——这就是交给宿主的插件描述对象。 + +逐字段: +- `id: "rosclaw"` —— 插件的唯一标识(机器读,宿主内部用它区分插件)。 +- `name: "RosClaw"` —— 给人看的显示名。 + +--- + +## 第 17-20 行:`register` 方法签名 + 解析配置 + +```typescript + register(api: OpenClawPluginApi): void { + api.logger.info("RosClaw plugin loading..."); + + const config = parseConfig(api.pluginConfig ?? {}); +``` + +- `register(api: OpenClawPluginApi): void {` —— **对象里内联的一个方法**(回忆第⑩/⑫篇的内联方法写法)。它不是 `async`——注册本身是一串瞬间完成的登记动作,不需要等待。 + - 参数 `api: OpenClawPluginApi` —— 宿主把"能力包"递进来:里面有 `logger`(日志)、`pluginConfig`(用户给这个插件的原始配置)、以及 `registerService`/`registerTool`/`registerCommand` 等一系列"登记口"(第①篇详述过)。 + - 返回 `void`——注册完不返回东西。 +- `api.logger.info("RosClaw plugin loading...");` —— 往日志写一行"插件正在加载",方便排查启动过程。 +- `const config = parseConfig(api.pluginConfig ?? {});` —— **整个插件第一件实事**: + - `api.pluginConfig` —— 用户在 OpenClaw 里给这个插件填的原始配置(可能没填,则为 `undefined`)。 + - `?? {}` —— **空值合并**(第⑥篇):万一用户啥都没配(`undefined`),就用一个**空对象 `{}`** 兜底,免得把 `undefined` 传进去。 + - `parseConfig(...)` —— 调第②篇那个 Zod 校验函数:校验字段、补默认值,吐出一份**干净、完整、类型确定**的 `config`。 + - **这一步必须最先做**,因为下面所有注册函数都要吃这份 `config`。先把配置整理好,后面才能放心用。 + +--- + +## 第 22-36 行:逐个调用注册函数(安装各部分) + +```typescript + // Register the rosbridge WebSocket connection as a managed service + registerService(api, config); + + // Register all ROS2 tools with the AI agent + registerTools(api); + + // Register safety validation hook (before_tool_call) + registerSafetyHook(api, config); + + // Register robot capability injection (before_agent_start) + registerRobotContext(api, config); + + // Register direct commands (bypass AI) + registerEstopCommand(api, config); + registerTransportCommand(api, config); +``` + +这是 `register` 的主体——**就是把导入进来的注册函数挨个调一遍**,每个负责安装插件的一块。注释把每块的用途和触发时机都标清楚了: + +- `registerService(api, config);` —— 注册传输连接服务(第⑫篇)。它内部会向 `api.registerService` 登记 `start`/`stop`,让 OpenClaw 在开机/关机时连/断 ROS2。 +- `registerTools(api);` —— 注册给 AI 代理用的那批 ROS2 工具(发布、订阅、调服务、发动作……第 14–21 篇)。 + - **注意它只吃 `api`、不吃 `config`**——因为工具运行时是通过第⑫篇的 `getTransport()` 现取传输的,不需要在注册时拿配置。其余几个都吃 `config`,因为它们的行为受配置影响(如安全规则、能力清单、默认模式)。 +- `registerSafetyHook(api, config);` —— 注册安全校验钩子(第 22 篇)。注释 `before_tool_call` 点明触发点:**每次 AI 要调工具之前**先过一道安全检查。 +- `registerRobotContext(api, config);` —— 注册机器人上下文注入(第 23 篇)。注释 `before_agent_start`:**每次对话开始前**,把"机器人有哪些话题/服务/动作"喂给 AI,让它知道自己能指挥什么。 +- `registerEstopCommand(api, config);` —— 注册 `/estop` 急停命令(第 24 篇)。 +- `registerTransportCommand(api, config);` —— 注册 `/transport` 切换传输命令(第 25 篇),它内部会调第⑫篇的 `switchTransport`。 + - 注释 `bypass AI`(绕过 AI):这两个是**直接命令**——用户打 `/estop` 立刻急停,不经过 AI 理解、不绕一圈,图的就是快和确定。安全相关的操作就该这样直给。 + +> **顺序有讲究吗?** 这些注册彼此独立(都只是"登记",不立刻执行),所以顺序不太敏感。但作者把 `registerService` 放第一个是合理的——传输是地基,其余功能最终都依赖它。 + +--- + +## 第 38-40 行:收尾日志 + 关括号 + +```typescript + api.logger.info("RosClaw plugin loaded successfully"); + }, +}; +``` + +- `api.logger.info("RosClaw plugin loaded successfully");` —— 全部注册完,写一行"加载成功"。配合开头那行 "loading...",一头一尾把加载过程框起来,日志里一看就知道插件有没有顺利装完。 +- `},` —— 关闭 `register` 方法(逗号是因为它是对象的一个属性,后面可能还有别的属性)。 +- `};` —— 关闭 `export default` 那个对象,分号结束语句。 + +--- + +## 把整个插件的启动流程串起来 + +到这里,从"宿主加载"到"插件就绪"的全链路就清晰了: + +``` +OpenClaw 启动 + │ + ├─ 加载 index.ts,拿到 default export 的插件对象(认识 id="rosclaw") + │ + ├─ 调 plugin.register(api) + │ ├─ parseConfig(api.pluginConfig ?? {}) ← 先把配置校验补全(第②篇) + │ ├─ registerService(api, config) ← 登记传输 start/stop(第⑫篇) + │ ├─ registerTools(api) ← 登记 AI 工具(第14-21篇) + │ ├─ registerSafetyHook(api, config) ← 登记动手前安全校验(第22篇) + │ ├─ registerRobotContext(api, config) ← 登记开聊前能力注入(第23篇) + │ ├─ registerEstopCommand(api, config) ← 登记 /estop(第24篇) + │ └─ registerTransportCommand(api, config) ← 登记 /transport(第25篇) + │ + └─ 之后某时刻,宿主启动那个 service → 调第⑫篇的 start() → 真正连上 ROS2 +``` + +注意一个关键点:**`register` 里全是"登记",没有一个"立刻执行"。** 连接不在这里发生(在 service 的 `start` 里),工具不在这里运行(在 AI 调用时)。`register` 只是把各种"将来某时刻该干什么"先报备给宿主。**真正的活儿都是后续被宿主触发的。** 这是插件式架构的典型节奏:先声明能力,宿主按需调度。 + +--- + +## 整章回顾 + +- `index.ts` 是插件总入口,`export default` 一个 `{ id, name, register }` 对象交给 OpenClaw。 +- `register(api)` 是"安装脚本":先 `parseConfig` 整理配置,再把 7 个注册函数挨个调一遍,把服务、工具、钩子、命令全部登记到宿主。 +- 全程只"登记"不"执行"——真正的连接/工具调用/校验都是后续由宿主在恰当时机触发。 +- 读这一篇的导入清单,等于拿到了**整个插件的功能地图**:1 服务 + 1 批工具 + 2 钩子 + 2 命令,正好对应后面第 14–25 篇。 + +**语法点回顾清单**(本章新增/巩固): +- `export default`:默认导出(一文件至多一个,导入端不带花括号、可任意命名)vs 具名 `export` +- 默认导出一个对象字面量当"插件本体" +- 对象里内联普通方法 `register(api): void {...}`(巩固内联方法) +- `api.pluginConfig ?? {}`:空对象兜底缺失配置(巩固 `??`) +- "先解析配置、再用配置注册各部分"的入口编排顺序 +- 插件式架构的节奏:`register` 只登记,不立即执行 + +--- + +## 阶段性里程碑 🎉 + +读完本篇,你已经掌握了 RosClaw 的**完整骨架**: + +> 配置(②)→ 传输接口(③④)→ rosbridge 协议与实现(⑤⑥⑦⑧⑨⑩)→ 工厂(⑪)→ 服务生命周期(⑫)→ 总入口(⑬) + +也就是说,**"插件如何启动、如何连上机器人、消息如何收发"这条主干你已经全程贯通了。** 从下一篇(第 14 篇)起进入**工具层**——那是 AI 真正用来指挥机器人的一个个具体动作(发布、订阅、调服务……),相对独立、也更轻松。骨架已经吃透,后面是给骨架挂"手脚"。 + +下一份:[`tools/index.ts` 逐行详解 →](14-tools-index.ts.md)(工具层的总装配,以及一个工具的通用结构长什么样) + + + +--- + + +# 逐行详解 ⑭:`tools/index.ts`(含「工具通用结构」总览) + +> 对应源文件:[extensions/openclaw-plugin/src/tools/index.ts](../../extensions/openclaw-plugin/src/tools/index.ts) +> +> 推荐阅读顺序第 14 个文件,**工具层的开篇**。从这里开始进入插件最"接地气"的部分——**工具(tool)**:AI 真正用来指挥机器人的一个个具体动作(发布、订阅、调服务、发动作、查参数……)。本篇文件本身只有 21 行(和第⑬篇 `index.ts` 一个套路:把一堆注册函数串起来),所以我们**借这篇先把"一个工具长什么样"的通用结构讲清楚**,后面第 15–21 篇每个具体工具就只讲它的独特部分,不再重复骨架。 + +--- + +## 先理解"工具"在这套系统里的位置 + +回忆项目架构:用户用自然语言对 AI 说"让机器人前进",AI 听懂后,需要一个**实际能动手的接口**去操作机器人。这个接口就是"工具"。 + +- AI 不会直接发 WebSocket、也不懂 rosbridge 协议。它只会"调用工具"——像点菜一样:「我要调用 `ros2_publish` 工具,参数是话题 `/cmd_vel`、消息 `{前进速度: 0.5}`」。 +- 工具内部才去干脏活:拿到传输实例(第⑫篇 `getTransport`)、把 AI 给的参数翻译成 ROS2 指令发出去、把结果整理成 AI 能读的文本返回。 + +``` +AI(听懂"前进")──调用──> ros2_publish 工具 ──getTransport()──> 传输 ──> 机器人 + │ + 把结果整理成文本 ──返回──> AI(再回复用户) +``` + +所以**工具是 AI 与机器人之间的"动作按钮"**。本插件一共注册了一批这样的按钮,`tools/index.ts` 负责把它们全部装上。 + +--- + +## 第 1-8 行:导入 7 个工具的注册函数 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { registerPublishTool } from "./ros2-publish.js"; +import { registerSubscribeTool } from "./ros2-subscribe.js"; +import { registerServiceTool } from "./ros2-service.js"; +import { registerActionTool } from "./ros2-action.js"; +import { registerParamTools } from "./ros2-param.js"; +import { registerIntrospectTool } from "./ros2-introspect.js"; +import { registerCameraTool } from "./ros2-camera.js"; +``` + +- 第 1 行 `import type { OpenClawPluginApi }`——还是那个宿主能力包,只当类型标注。 +- 其余 7 个是值导入(要调用)。**这串导入就是工具层的功能清单**: + - `registerPublishTool`(第 15 篇)—— 发布消息到话题(发指令)。 + - `registerSubscribeTool`(第 16 篇)—— 订阅话题读一条消息(读传感器/状态)。 + - `registerServiceTool`(第 17 篇)—— 调用服务(请求-响应)。 + - `registerActionTool`(第 18 篇)—— 发动作目标(长任务)。 + - `registerParamTools`(第 19 篇)—— 读写参数(注意是 **Tools** 复数,它一口气注册好几个参数相关工具)。 + - `registerIntrospectTool`(第 20 篇)—— 自省:列出机器人有哪些话题/服务/动作。 + - `registerCameraTool`(第 21 篇)—— 取摄像头图像。 +- 路径都是 `./ros2-xxx.js`(同目录的各工具文件)。 + +--- + +## 第 10-21 行:`registerTools`——挨个装上 + +```typescript +/** + * Register all ROS2 tools with the OpenClaw AI agent. + */ +export function registerTools(api: OpenClawPluginApi): void { + registerPublishTool(api); + registerSubscribeTool(api); + registerServiceTool(api); + registerActionTool(api); + registerParamTools(api); + registerIntrospectTool(api); + registerCameraTool(api); +} +``` + +- 和第⑬篇 `register` 的风格完全一致:一个函数,把导入的注册函数挨个调一遍,每个把自己那个工具登记到宿主。 +- 全部只吃 `api`、不吃 `config`——回忆第⑬篇说过:工具运行时通过 `getTransport()` 现取传输,注册时不需要配置。 +- 这个 `registerTools` 就是第⑬篇 `index.ts` 里 `registerTools(api)` 那一行调用的目标。**两层"挨个调"**:`index.ts` 调各大模块的注册函数,其中 `registerTools` 再调各工具的注册函数。一层套一层,结构清爽。 + +到这里这个文件就讲完了。下面进入本篇重点——**工具的通用结构**。 + +--- + +## 工具通用结构:一个工具长什么样(重点,后面都靠它) + +第 15–21 篇每个工具,剥开看都是同一副骨架。我们现在把这副骨架讲透,后面就能专注于各工具的"肉"。 + +回忆第①篇 `plugin-api.ts` 里定义的工具类型 `AgentTool`: + +```typescript +export interface AgentTool { + name: string; + label: string; + description: string; + parameters: TSchema; + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +} +``` + +也就是说,**注册一个工具,就是给宿主递一个满足这个形状的对象**。它有 4 个"说明字段" + 1 个"干活方法": + +### ① `name`——工具的机器名 + +AI 调用时用的标识,如 `"ros2_publish"`。**必须唯一**,且通常用下划线命名(AI 工具调用的惯例)。 + +### ② `label`——给人看的显示名 + +如 `"ROS2 Publish"`。用在 UI/日志里,方便人辨认。机器不靠它。 + +### ③ `description`——给 AI 看的"使用说明" + +**这是工具里最关键的一段文字**,因为它直接决定 AI 会不会、会怎样使用这个工具。 + +- AI 看不到工具的代码,它**只能靠 `description` 判断"什么时候该用这个工具、参数怎么填"**。 +- 所以这段描述要写得像"对一个聪明但不了解你系统的助手交代任务":说清用途,最好举例。后面会看到每个工具的 description 都带 `e.g.`(例如)举例,就是为了引导 AI 正确使用。 +- 一句话:**`description` 是写给 AI 的提示词,不是写给程序员的注释。** 这是"工具开发"区别于普通函数的最大特点。 + +### ④ `parameters`——参数的"形状定义"(TypeBox) + +声明这个工具接受哪些参数、各是什么类型。它的值是一个 `TSchema`(第①篇提过的 TypeBox 类型)。 + +**语法小课堂:TypeBox 是什么、为什么不用普通类型标注。** +- 我们之前定义"对象形状"用的是 `interface`/`type`(第①篇)。但那是**纯类型**——编译后就消失了,运行时不存在(回忆第①/⑩篇:类型会被抹掉)。 +- 而工具参数有个特殊需求:**运行时也得有这份"形状信息"**。因为宿主要把它发给 AI(让 AI 知道该填什么参数),AI 回填后宿主还要**实际校验**值对不对。这些都发生在运行时,光有"编译期类型"不够。 +- TypeBox(`@sinclair/typebox`)就是解决这个的:你用 `Type.Object({...})`、`Type.String()` 这种**函数调用**来描述形状,它产出的是一个**运行时真实存在的对象**(叫 schema/模式),同时 TS 也能从它推出编译期类型。一份定义,运行时和编译期都能用。 +- 这和第②篇的 **Zod** 是"同类不同款":Zod 也是运行时 schema,用在配置校验;TypeBox 用在工具参数(因为 AI 工具生态更认它)。两者思路一样:**用代码描述形状,运行时可用。** +- 具体的 `Type.Object`/`Type.String`/`Type.Optional` 等怎么写,下一篇(第 15)第一次真正用到时细讲。 + +### ⑤ `execute`——真正干活的方法 + +工具被 AI 调用时,宿主就执行这个方法。它的签名(第①篇): + +```typescript +execute(toolCallId: string, params: Record, signal?: AbortSignal): Promise +``` + +- `toolCallId: string`——这次调用的唯一编号(多数工具用不到,所以后面常写成 `_toolCallId`,回忆第⑫篇 `_` 前缀表示故意不用)。 +- `params: Record`——**AI 填进来的参数**。注意类型是 `Record`(键是字符串、值是"未知")——因为参数是从外部(AI)来的,进来时类型不确定,工具内部要自己断言成具体类型再用(后面会看到 `params["topic"] as string`)。 +- `signal?: AbortSignal`——可选的"取消信号"(用户中途喊停时用),多数工具不处理。 +- 返回 `Promise`——异步返回一个"工具结果"。 + +`ToolResult` 的形状(第①篇): + +```typescript +export interface ToolResult { + content: ToolContent[]; // 给 AI 看的内容(文本或图片) + details?: unknown; // 可选的结构化数据(程序/UI 用) +} +``` + +- `content`——一个数组,每项是 `{ type: "text", text: "..." }`(文本)或 `{ type: "image", data, mimeType }`(图片)。**这是 AI 实际"读到"的工具输出**——AI 据此组织给用户的回复。 +- `details`——可选的原始结构化结果,给程序或仪表盘用,AI 不一定细看。 + +> **一句话记住工具骨架**:4 个说明字段告诉宿主和 AI"我是谁、干嘛用、要什么参数",`execute` 在被调用时"拿参数→干活→把结果包成 content 返回"。**后面每个工具,区别只在 `description` 写什么、`parameters` 要哪些、`execute` 里调传输的哪个方法。** 骨架都一样。 + +--- + +## 整章回顾 + +- `tools/index.ts` 是工具层的总装:`registerTools(api)` 把 7 个工具的注册函数挨个调一遍,被第⑬篇 `index.ts` 调用。 +- **工具的通用结构**(贯穿第 15–21 篇):一个 `AgentTool` 对象 = `name`(机器名)+ `label`(显示名)+ `description`(写给 AI 的使用说明,最关键)+ `parameters`(TypeBox 定义的参数形状,运行时可用)+ `execute`(拿参数、干活、返回 `ToolResult`)。 +- 工具是"AI 与机器人之间的动作按钮":AI 只管按按钮、读返回文本,按钮内部用 `getTransport()` 去操作机器人。 + +**语法点回顾清单**(本章新增/巩固): +- 两层"挨个调注册函数"的装配结构(巩固第⑬篇) +- 工具对象的 `AgentTool` 形状:name/label/description/parameters/execute(回扣第①篇) +- `description` 是写给 AI 的提示词(决定 AI 是否/如何使用工具)——工具开发特有 +- TypeBox(`Type.xxx()` 产出运行时可用的 schema)vs `interface`/`type`(编译期即抹除)的区别、与 Zod(第②篇)的对照 +- `execute(_toolCallId, params): Promise` 的签名、`ToolResult` 的 `content`/`details` + +下一份:[`ros2-publish.ts` 逐行详解 →](15-ros2-publish.ts.md)(第一个真实工具:发布消息。第一次真正写 TypeBox 参数、第一次写 `execute` 体) + + + +--- + + +# 逐行详解 ⑮:`tools/ros2-publish.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-publish.ts](../../extensions/openclaw-plugin/src/tools/ros2-publish.ts) +> +> 推荐阅读顺序第 15 个文件,**第一个真实的工具**。上一篇我们讲了工具的通用骨架,这篇就用最简单的工具——"发布消息到话题"——把骨架填满:第一次真正写 **TypeBox 参数定义**,第一次写 **`execute` 方法体**。它对应第⑩篇适配器的 `publish`,是"发指令给机器人"的入口。吃透这一篇,后面几个工具都是同款换内容。 + +--- + +## 工具速览 + +- **它干什么**:让 AI 往任意 ROS2 话题发一条消息。最典型用途:发速度指令到 `/cmd_vel` 让机器人动起来。 +- **属于哪类**:发布是"发完即忘"(回忆第⑦篇),不等回应。所以这是最简单的工具。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +- `import { Type } from "@sinclair/typebox";` —— **值导入** TypeBox 的 `Type`。它是个对象,上面挂着 `Type.Object`、`Type.String` 等一堆方法,用来描述参数形状(上一篇讲过 TypeBox 的作用)。要调用它,所以是值导入(不带 `type`)。 +- `import type { OpenClawPluginApi }` —— 宿主能力包,类型标注。 +- `import { getTransport } from "../service.js";` —— **值导入** 第⑫篇那个"取传输实例"的函数。工具干活时靠它拿到传输。注意路径 `../service.js`(工具在 `tools/` 子目录,要回上一层)。 + +--- + +## 第 5-9 行:注释 + 注册函数签名 + +```typescript +/** + * Register the ros2_publish tool with the AI agent. + * Allows publishing messages to any ROS2 topic. + */ +export function registerPublishTool(api: OpenClawPluginApi): void { + api.registerTool({ +``` + +- `registerPublishTool(api)` —— 就是第⑭篇 `registerTools` 里调的那个。它只做一件事:调 `api.registerTool({...})`,把一个工具对象递给宿主。 +- `api.registerTool({` —— 开始传那个 `AgentTool` 对象(第⑭篇讲的 5 件套)。下面逐字段填。 + +--- + +## 第 11-15 行:name / label / description + +```typescript + name: "ros2_publish", + label: "ROS2 Publish", + description: + "Publish a message to a ROS2 topic. Use this to send commands to the robot " + + "(e.g., velocity commands to /cmd_vel, navigation goals, etc.).", +``` + +- `name: "ros2_publish"` —— AI 调用时用的机器名(下划线命名)。 +- `label: "ROS2 Publish"` —— 给人看的显示名。 +- `description:` —— **写给 AI 的使用说明**(第⑭篇强调过:这是最关键的字段)。 + - 内容翻译:「往一个 ROS2 话题发布消息。**用它来给机器人发指令**(例如发速度指令到 `/cmd_vel`、发导航目标等)。」 + - 注意它**举了例子**(`e.g.`)——告诉 AI 典型场景,引导它在"用户想让机器人动"时想到用这个工具。 + - **语法点**:这段字符串用 `+` 把两行拼成一整句(回忆第⑪篇字符串 `+` 拼接)。源码里换行只是为了不超宽,拼起来是一句话。 + +--- + +## 第 16-22 行:`parameters`——第一次写 TypeBox 参数 + +```typescript + parameters: Type.Object({ + topic: Type.String({ description: "The ROS2 topic name (e.g., '/cmd_vel')" }), + type: Type.String({ description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" }), + message: Type.Record(Type.String(), Type.Unknown(), { + description: "The message payload matching the ROS2 message type schema", + }), + }), +``` + +这是本篇第一个新东西。逐个拆 TypeBox 的写法: + +**语法小课堂:`Type.Object({...})` —— 描述一个"对象参数"。** +- `Type.Object({ 字段: 字段类型, ... })` 等于说"这个工具收一个对象,里头有这么几个字段"。括号里每个字段配一个"字段类型描述"。 +- 它和 `interface { topic: string; ... }` 表达的意思类似,但它是**函数调用**、产出**运行时可用的 schema**(第⑭篇讲过为什么要这样)。 + +里头三个字段: + +- `topic: Type.String({ description: "..." })` + - **语法小课堂:`Type.String()` —— 描述"这是个字符串"。** 括号里可以传一个选项对象,这里给了 `description`——**注意这个 description 也是写给 AI 看的**,告诉 AI 这个字段填什么("ROS2 话题名,例如 `/cmd_vel`")。每个字段都配描述 + 例子,AI 才知道怎么填。 +- `type: Type.String({ description: "..." })` + - 同样是字符串:ROS2 消息类型,例 `geometry_msgs/msg/Twist`(Twist 是"线速度+角速度"的标准消息类型)。 +- `message: Type.Record(Type.String(), Type.Unknown(), { description: "..." })` + - **语法小课堂:`Type.Record(键类型, 值类型)` —— 描述一个"键值对对象"。** 回忆第①篇的 `Record`——这就是它的 TypeBox 版: + - `Type.String()` 第一个参数:键是字符串。 + - `Type.Unknown()` 第二个参数:值是"未知/任意"(`Type.Unknown()` = TypeBox 版的 `unknown`)。 + - 第三个参数 `{ description: ... }`:照例配说明。 + - 为什么 `message` 用这么宽松的"任意键值对"?因为**不同话题的消息结构千差万别**(速度消息长一个样、导航目标又另一个样),工具没法预先写死,只能说"给我个对象,里面具体啥字段由消息类型决定",把灵活性留给 AI 按 `type` 去填。 + - 这正对应第①篇说的"消息体是任意结构"的设计。 + +> **小结这段**:`parameters` 用 TypeBox 声明"我要三个参数:话题名(字符串)、消息类型(字符串)、消息体(任意对象)",每个都配了给 AI 看的说明和例子。 + +--- + +## 第 24 行:`execute` 方法签名 + +```typescript + async execute(_toolCallId, params) { +``` + +- `async execute(...)` —— 工具被调用时执行的方法(第⑭篇讲的签名),`async` 因为里面可能要等(虽然发布本身不等,但接口统一要求返回 Promise)。 +- `_toolCallId` —— 这次调用的编号,**本工具用不到**,加 `_` 前缀表示故意不用(回忆第⑫篇)。 +- `params` —— AI 填进来的参数对象(类型 `Record`,第⑭篇说过)。 +- 注意这里**没写参数类型和返回类型**——因为 `api.registerTool` 已知道 `execute` 该长什么样(来自 `AgentTool` 接口),TS 能**自动推断**出 `_toolCallId: string`、`params: Record`、返回 `Promise`。这叫"上下文类型推断":写在已知形状的位置上,类型不必重复写。 + +--- + +## 第 25-27 行:取出并断言参数 + +```typescript + const topic = params["topic"] as string; + const type = params["type"] as string; + const message = params["message"] as Record; +``` + +- 从 `params` 里把三个参数取出来。注意两个点: + - **`params["topic"]` 用方括号取属性**(回忆第⑩篇:`对象["键名"]`)。这里用方括号是因为 `params` 的类型是 `Record`(一个宽泛的字符串键映射),用方括号取键更自然。 + - **`as string` 类型断言**(回忆第⑥篇):`params` 的值类型是 `unknown`(外部来的,不确定),取出来得断言成具体类型才能用。 + - 这里为什么敢直接断言?因为参数已经过 `parameters`(TypeBox schema)校验——宿主在调 `execute` 前会按 schema 验过,所以工具内部可以放心断言成声明的类型。**断言是"我知道它一定是这个类型"的承诺,这里有 schema 校验兜底,承诺站得住。** +- 三行分别拿到话题名、消息类型、消息体。 + +--- + +## 第 29-30 行:取传输 + 发布 + +```typescript + const transport = getTransport(); + transport.publish({ topic, type, msg: message }); +``` + +- `const transport = getTransport();` —— 调第⑫篇那个函数拿到当前活动的传输实例(取不到会抛错,第⑫篇讲过)。 +- `transport.publish({ topic, type, msg: message });` —— 调传输的 `publish`(第④篇接口、第⑩篇适配器实现)发布消息。 + - 参数是个 `PublishOptions` 对象(第③篇):`{ topic, type, msg }`。 + - `topic`、`type` 是**对象简写**(即 `topic: topic`,回忆第⑥篇)。 + - `msg: message` —— **注意这里改了名**:本工具的参数叫 `message`,但 `PublishOptions` 的字段叫 `msg`,所以写 `msg: message`(把 `message` 装进 `msg` 字段)。不能简写,因为名字不同。 +- 发布是"发完即忘",没有 `await`、没有返回值——这正是发布最简单的体现(对比下一篇订阅要等消息)。 + +--- + +## 第 32-37 行:组装并返回结果 + +```typescript + const result = { success: true, topic, type }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- `const result = { success: true, topic, type };` —— 拼一个"结果摘要"对象:`success: true`(发出去了)、回显 `topic`/`type`(对象简写)。 + - 注意:发布"发完即忘",工具**无法真正确认机器人收到没**,所以 `success: true` 只表示"消息已发出",不代表"机器人执行了"。 +- `return { content: [...], details: result };` —— 返回一个 `ToolResult`(第⑭篇讲的形状): + - `content: [{ type: "text", text: JSON.stringify(result) }]` —— 给 AI 看的内容:一个文本项。 + - **`JSON.stringify(result)`**(回忆第⑥篇)—— 把结果对象转成 JSON 字符串文本。**为什么转字符串?** 因为 `content` 的文本项要的是字符串,而 AI 读到的就是这串文本——它会看到 `{"success":true,"topic":"/cmd_vel","type":"..."}`,据此知道"发成功了、发的是哪个话题"。 + - `details: result` —— 把原始结果对象也附上(给程序/UI 用,AI 不必细读)。 + - **content vs details 的分工**:`content` 是"给 AI 读的文本版",`details` 是"给机器用的对象版"。同一份结果,两种形态各取所需。这是几乎所有工具结尾的固定套路。 +- `},` 关闭 `execute`,`});` 关闭 `registerTool` 调用,`}` 关闭注册函数。 + +--- + +## 整章回顾 + +`ros2_publish` 是最简单的工具,完整展示了工具骨架被填满的样子: + +| 字段 | 本工具填的内容 | +|---|---| +| `name` | `"ros2_publish"` | +| `description` | 「发消息到话题、用来给机器人发指令」+ 举例(写给 AI) | +| `parameters` | TypeBox:`topic`(字符串) + `type`(字符串) + `message`(任意对象) | +| `execute` | 取参数(断言)→ `getTransport()` → `transport.publish(...)` → 包成 `ToolResult` 返回 | + +执行流就一句话:**把 AI 给的话题/类型/消息,转成一次 `transport.publish` 发出去,再把"已发送"的摘要包成文本返回给 AI。** + +**语法点回顾清单**(本章新增/巩固): +- TypeBox:`Type.Object({...})`(对象)、`Type.String()`(字符串)、`Type.Record(键, 值)`(键值对)、`Type.Unknown()`(任意值),每项可带 `{ description }`(写给 AI) +- `execute` 的上下文类型推断(参数/返回类型由 `AgentTool` 接口推出,不必重写) +- `params["键"] as 类型`:从 `Record` 取参数并断言(有 schema 校验兜底) +- 对象字段改名 `msg: message`(参数名与目标字段名不同,不能简写) +- `JSON.stringify` 把结果对象转文本放进 `content`、`details` 附原始对象——工具返回的固定套路 +- 发布"发完即忘":无 `await`、无返回,`success` 仅表示"已发出" + +下一份:[`ros2-subscribe.ts` 逐行详解 →](16-ros2-subscribe.ts.md)(订阅一条消息——比发布多了"等一条消息回来",会用上 Promise + 超时竞速) + + + +--- + + +# 逐行详解 ⑯:`tools/ros2-subscribe.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-subscribe.ts](../../extensions/openclaw-plugin/src/tools/ros2-subscribe.ts) +> +> 推荐阅读顺序第 16 个文件。这是"读"方向的工具——**订阅一个话题、等下一条消息回来就返回**(读电量、读传感器、查机器人状态)。它比上一篇发布多了一件麻烦事:**得等一条消息,但又不能无限等**。于是出现一个新模式——用 `Promise` 把"收到消息"和"超时"**两件事竞速**,谁先发生就按谁结束。这把第⑦篇订阅器、第⑧篇 Promise 桥接、第②篇 `setTimeout` 串到了一起。 + +--- + +## 先理解这个工具的难点 + +发布是"发完即忘",最简单。订阅却尴尬: + +- ROS2 话题是"持续不断推消息"的流,但这个工具只想要**下一条**(叫 `subscribe_once`——订阅一次)。 +- 收到一条就该:①把它返回、②立刻退订(别继续收)、③别再等了。 +- 但万一这个话题**半天没消息**呢?不能让工具永远卡着。所以要加**超时**:等太久就放弃、报错。 + +把这两条路("收到消息"和"等超时")做成竞速——这就是本篇的核心。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +和第⑮篇一模一样:TypeBox 的 `Type`、宿主类型、取传输的 `getTransport`。不再赘述。 + +--- + +## 第 5-15 行:注册函数 + 三个说明字段 + +```typescript +/** + * Register the ros2_subscribe_once tool with the AI agent. + * Subscribes to a topic and returns the next message received. + */ +export function registerSubscribeTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_subscribe_once", + label: "ROS2 Subscribe Once", + description: + "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, " + + "check robot state, or get the current value of a topic.", +``` + +- 骨架和第⑮篇同款。 +- `name: "ros2_subscribe_once"` —— 名字里的 `once`(一次)点明语义:只取下一条就走。 +- `description` 翻译:「订阅一个话题并返回下一条消息。**用它来读传感器数据、查机器人状态、或取话题当前值。**」——又是写给 AI 的用途引导 + 举例。 + +--- + +## 第 16-20 行:`parameters`——出现"可选参数" + +```typescript + parameters: Type.Object({ + topic: Type.String({ description: "The ROS2 topic name (e.g., '/battery_state')" }), + type: Type.Optional(Type.String({ description: "The ROS2 message type (e.g., 'sensor_msgs/msg/BatteryState')" })), + timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 5000)" })), + }), +``` + +- `topic: Type.String(...)` —— 话题名,必填(和第⑮篇一样)。 +- `type: Type.Optional(Type.String(...))` —— **新东西**: + - **语法小课堂:`Type.Optional(...)` —— 把一个字段标成"可选"。** 它是 TypeBox 版的 `?`(回忆第①篇可选字段 `type?: string`)。`Type.Optional(Type.String(...))` = "这个字段是字符串,但可以不填"。 + - 为什么消息类型可选?因为有些情况底层能自己推断出类型,AI 不一定要给。 +- `timeout: Type.Optional(Type.Number(...))` —— 可选的超时毫秒数。 + - **语法小课堂:`Type.Number()` —— 描述"这是个数字"。** 和 `Type.String()` 并列的基础类型。 + - 描述里写了 `default: 5000`——但**注意:TypeBox 这里只是在说明文字里告诉 AI"默认 5000",并没有真的设默认值**。真正的兜底在下面 `execute` 里用 `??` 做(马上看到)。 + +--- + +## 第 22-25 行:`execute` 取参数 + +```typescript + async execute(_toolCallId, params) { + const topic = params["topic"] as string; + const msgType = params["type"] as string | undefined; + const timeout = (params["timeout"] as number | undefined) ?? 5000; +``` + +- `topic` —— 必填,断言成 `string`(同第⑮篇)。 +- `msgType = params["type"] as string | undefined;` —— **注意断言成 `string | undefined`**(联合类型),不是单纯 `string`。因为这个参数可选,AI 可能没填,取出来可能是 `undefined`。诚实地把"可能没有"写进类型。 + - 变量名用 `msgType` 而非 `type`,因为 `type` 在 TS 里是关键字(如 `type X = ...`),避免混淆,换个名更稳。 +- `timeout = (params["timeout"] as number | undefined) ?? 5000;` —— **一行里两个语法点**: + - `(params["timeout"] as number | undefined)` —— 先取出超时值,断言成"数字或没有"。外面套圆括号是为了让 `as` 先算完,再交给 `??`。 + - `?? 5000` —— **空值合并**(第⑥篇):如果 AI 没填(`undefined`),就兜底成 `5000`(5 秒)。**这才是"默认 5000"真正生效的地方**——上面 TypeBox 的描述只是"告知",这里才是"执行"。 + +--- + +## 第 27 行:取传输 + +```typescript + const transport = getTransport(); +``` + +- 老规矩,拿到活动传输实例。 + +--- + +## 第 29-42 行:核心——把"收消息"和"超时"做成竞速 + +```typescript + const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: msgType }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); + }, + ); + const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); + }, timeout); + }); +``` + +这是本篇的灵魂,慢慢拆。 + +### 外层:`await new Promise<...>((resolve, reject) => {...})` + +- 回忆第⑥/⑧篇:`new Promise` 建一个"待兑现承诺",给它一个函数,函数收到 `resolve`(成功)和 `reject`(失败)两个开关。 +- `>` —— 这个承诺将来兑现时给出的值是"一个对象"(订阅结果)。 +- 最外面 `await` —— 等这个承诺有结果(要么收到消息成功、要么超时失败)。 +- 和第⑧篇服务调用一个思路:**用 Promise 把异步结果接住**。区别是这里要同时安排两条"结束路径"。 + +### 路径一:订阅,收到消息就成功 + +```typescript +const subscription = transport.subscribe( + { topic, type: msgType }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); + }, +); +``` + +- `transport.subscribe(选项, 回调)` —— 调传输的订阅(第④篇接口、第⑩篇适配器)。返回一个 `subscription` 句柄(带 `unsubscribe`,第③篇)。 + - 第一个参数 `{ topic, type: msgType }` —— 订阅选项:话题名 + 消息类型。`topic` 简写,`type: msgType` 因为名字不同(参数叫 `msgType`、字段叫 `type`)。 + - 第二个参数是**收到消息时的回调** `(msg) => {...}`。`msg: Record` 是收到的消息体。 +- 回调体三件事,顺序有讲究: + 1. `clearTimeout(timer);` —— **取消那个超时定时器**(回忆第②篇 `clearTimeout`)。既然消息已经来了,就不用再等超时了,把定时器撤掉,免得它待会儿又触发 reject。 + 2. `subscription.unsubscribe();` —— **退订**。只要这一条,订到手立刻退,不再继续收(这就是 `_once` 的含义)。 + 3. `resolve({ success: true, topic, message: msg });` —— **兑现承诺**,把结果交出去:成功标志 + 话题 + 收到的消息。外层 `await` 拿到它。 + +### 路径二:超时,没等到就失败 + +```typescript +const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); +}, timeout); +``` + +- `setTimeout(回调, 毫秒)`(回忆第②篇)—— 设一个定时器:过了 `timeout` 毫秒还没被取消,就执行回调。返回的定时器句柄存进 `timer`。 +- 超时回调干两件事: + 1. `subscription.unsubscribe();` —— 也要退订(都超时了,别再挂着监听白占资源)。 + 2. `reject(new Error(\`Timeout waiting for message on ${topic}\`));` —— **让承诺失败**,抛一个带话题名的清楚错误。外层 `await` 处会抛出这个错。 + +### 两条路径如何"竞速" + +- 两个回调都登记好后,就看**谁先发生**: + - 消息**先到** → 路径一跑:清掉定时器(路径二永不触发)、退订、`resolve` 成功。 + - 一直**没消息** → `timeout` 毫秒后路径二跑:退订、`reject` 失败。 +- **关键:无论走哪条,都会 `unsubscribe`**——保证不留下泄漏的订阅。而 `clearTimeout` 保证"成功路径"不会被"超时路径"误伤。两条路互相清理对方,干干净净。 + +> **一个易混点:`timer` 在回调里用,却在回调下面才声明,不报错吗?** +> 不报错。因为那个收消息的回调**不是立刻执行**的——它要等真有消息才跑。等它跑的时候,下面的 `const timer = setTimeout(...)` **早已执行完**、`timer` 已经有值了。JavaScript 里"晚定义、但用的时候已存在"是没问题的。这是异步回调里常见的写法。 + +--- + +## 第 44-49 行:返回结果 + +```typescript + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- 和第⑮篇结尾**一模一样**的固定套路:把 `result`(上面竞速拿到的成功结果)`JSON.stringify` 成文本放进 `content` 给 AI,再把原始对象放进 `details`。 +- 注意:如果上面是**超时失败**,`await` 那里就直接抛错了,根本走不到这个 `return`——错误会往外抛给宿主,宿主再告诉 AI"工具失败了"。所以这个 `return` 只在成功时执行。 +- 收尾的 `},`、`});`、`}` 依次关闭 `execute`、`registerTool`、注册函数。 + +--- + +## 发布 vs 订阅:工具复杂度对比 + +| | 发布 ⑮ | 订阅 ⑯ | +|---|---|---| +| 等回应吗 | 不等(发完即忘) | **等一条消息** | +| 用 Promise 吗 | 不用 | **用**(接住"将来到的消息") | +| 超时处理 | 无 | **有**(`setTimeout` + `reject`) | +| 清理 | 无 | **有**(两条路都 `unsubscribe` + `clearTimeout`) | +| execute 体 | 三五行 | 一个竞速 Promise | + +可以看到,订阅 = 发布的骨架 + 第⑧篇的 Promise 桥接 + 超时竞速。复杂度都在"等一条消息又不能死等"这件事上。 + +--- + +## 整章回顾 + +- `ros2_subscribe_once` 让 AI 读一个话题的下一条消息,用于读传感器/查状态。 +- 核心是一个**竞速 Promise**:同时安排"收到消息→`resolve` 成功"和"超时→`reject` 失败"两条路,谁先到按谁结束,且两条路都负责退订清理。 +- 参数出现了**可选**(`Type.Optional`)和**默认值兜底**(`?? 5000`),默认值的"说明"在 TypeBox 描述里、"执行"在 `execute` 的 `??` 里——两处别混淆。 + +**语法点回顾清单**(本章新增/巩固): +- `Type.Optional(...)`(可选参数,TypeBox 版的 `?`)、`Type.Number()`(数字) +- 取可选参数断言成 `string | undefined` / `number | undefined`(诚实写"可能没有") +- `(... as number | undefined) ?? 5000`:断言 + 空值合并兜底默认值(默认值真正生效处) +- 竞速 Promise:`subscribe` 回调 `resolve` vs `setTimeout` 回调 `reject`,谁先发生谁结束 +- 成功路径 `clearTimeout` + 失败路径都 `unsubscribe`:两条路互相清理,防泄漏 +- 异步回调里"晚声明的变量(`timer`)在回调真正执行时已存在" +- 工具返回的固定套路 `content`(文本) + `details`(对象)(巩固第⑮篇) +- 变量名避开关键字 `type` → 改用 `msgType` + +下一份:[`ros2-service.ts` 逐行详解 →](17-ros2-service.ts.md)(调用服务的工具——直接复用第⑧篇的请求-响应,比订阅还省心) + + + +--- + + +# 逐行详解 ⑰:`tools/ros2-service.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-service.ts](../../extensions/openclaw-plugin/src/tools/ros2-service.ts) +> +> 推荐阅读顺序第 17 个文件。调用服务的工具。好消息:**它几乎没有新语法**——骨架和第⑮⑯篇一模一样,干活的脏活早在第⑧篇 `callService` 里做完了。本篇的价值是让你看到"工具层有多薄":当底层(传输 + 适配器)做扎实后,上层工具就只是"取参数 → 调一个传输方法 → 包结果返回"。我们快速过一遍,重点放在它和发布/订阅的细微差别上。 + +--- + +## 工具速览 + +- **它干什么**:调用一个 ROS2 服务,等回应。用于"请求-响应"类操作:设参数、触发某个行为、查节点状态。 +- **属于哪类**:请求-响应(回忆第③篇打电话的比方、第⑧篇的 `callService`)。比订阅省心——因为"等回应 + 超时"这套逻辑早被 `callService` 封装好了,工具不用自己写竞速。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +和第⑮⑯篇完全一致:TypeBox、宿主类型、取传输。 + +--- + +## 第 5-22 行:注册 + 说明字段 + 参数 + +```typescript +export function registerServiceTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_service_call", + label: "ROS2 Service Call", + description: + "Call a ROS2 service and return the response. Use this for request/response operations " + + "like setting parameters, triggering behaviors, or querying node state.", + parameters: Type.Object({ + service: Type.String({ description: "The ROS2 service name (e.g., '/spawn_entity')" }), + type: Type.Optional(Type.String({ description: "The ROS2 service type (e.g., 'gazebo_msgs/srv/SpawnEntity')" })), + args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { + description: "The service request arguments", + })), + }), +``` + +- 骨架你已经熟了。`description` 翻译:「调用一个服务并返回响应。**用于请求-响应操作**,比如设参数、触发行为、查节点状态。」 +- 参数三个,全是见过的 TypeBox 写法(第⑮⑯篇): + - `service: Type.String(...)` —— 服务名,必填。 + - `type: Type.Optional(Type.String(...))` —— 服务类型,**可选**(`Type.Optional`,第⑯篇)。 + - `args: Type.Optional(Type.Record(...))` —— 请求参数,**可选的键值对对象**。这里是 `Type.Optional` 套着 `Type.Record`——"可以不填;要填就是个任意对象"。 +- 没有任何新语法,跳过细讲。 + +--- + +## 第 24-41 行:`execute`——取参数、调 callService、包结果 + +```typescript + async execute(_toolCallId, params) { + const service = params["service"] as string; + const type = params["type"] as string | undefined; + const args = params["args"] as Record | undefined; + + const transport = getTransport(); + const response = await transport.callService({ service, type, args }); + + const result = { + success: response.result, + service, + response: response.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +逐段: + +- **取参数**(第⑮⑯篇套路): + - `service` 必填 → 断言 `string`。 + - `type`、`args` 可选 → 断言成 `... | undefined`(诚实写"可能没有",回忆第⑯篇)。 +- `const transport = getTransport();` —— 拿传输。 +- `const response = await transport.callService({ service, type, args });` —— **本工具的核心一行**: + - 调传输的 `callService`(第④篇接口、第⑩篇适配器、底层是第⑧篇那个函数),传一个 `{ service, type, args }` 对象(三个对象简写)。 + - `await` 等响应回来。**注意:这里不用自己写 Promise、不用 setTimeout 超时**——因为这些第⑧篇的 `callService` 内部全做好了(它自带 30 秒超时、自带按 id 配对)。工具只管 `await` 拿结果。**这就是"底层做扎实、上层就轻松"的直接体现**——对比第⑯篇订阅工具得自己手写竞速,这里一行 `await` 搞定。 +- **包结果**: + - `const result = { success: response.result, service, response: response.values };` + - `success: response.result` —— 服务响应里的 `result` 字段是"成功与否"的布尔(第⑤篇 `ServiceCallResult`),这里改名装进 `success`。 + - `service` —— 对象简写,回显服务名。 + - `response: response.values` —— 把服务返回的数据 `values` 装进 `response` 字段。 + - **注意这里的字段改名**:底层的 `result`/`values` 被重命名成对 AI 更友好的 `success`/`response`。回忆第⑩篇适配器也做过"重塑结果形状"——这里工具又做了一层面向 AI 的措辞调整。 + - 最后 `return { content: [...JSON.stringify(result)...], details: result }` —— 还是第⑮篇那个固定收尾套路。 + +--- + +## 整章回顾 + +- `ros2_service_call` 是"调服务"工具,本质就是 `execute` 里一句 `await transport.callService(...)`,前面取参数、后面包结果。 +- **它最大的教学意义是反衬"分层"的好处**:请求-响应那套麻烦逻辑(配对、等待、超时)沉在第⑧篇,工具层因此薄得只剩"翻译参数、转发、整理结果"。这正是好架构的样子——复杂度被关在底层,上层一目了然。 +- 全篇无新语法,是一篇"巩固 + 体会分层"的轻松章节。 + +**语法点回顾清单**(本章无新增,全是巩固): +- 工具五件套骨架(第⑭篇)、TypeBox 参数(第⑮⑯篇)、`Type.Optional`(第⑯篇) +- 可选参数断言成 `... | undefined`(第⑯篇) +- `await transport.callService(...)`:直接复用第⑧篇封装好的请求-响应(自带超时/配对) +- 结果字段改名 `success: response.result` / `response: response.values`(面向 AI 的措辞) +- `content` + `details` 返回套路(第⑮篇) + +下一份:[`ros2-action.ts` 逐行详解 →](18-ros2-action.ts.md)(发动作目标的工具——和本篇几乎双胞胎,底层换成第⑨篇的 `sendActionGoal`) + + + +--- + + +# 逐行详解 ⑱:`tools/ros2-action.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-action.ts](../../extensions/openclaw-plugin/src/tools/ros2-action.ts) +> +> 推荐阅读顺序第 18 个文件。发动作目标的工具。它和上一篇 `ros2-service.ts` 几乎是**双胞胎**——只把底层从 `callService` 换成第⑨篇的 `sendActionGoal`。**完全没有新语法。** 我们更快地过一遍,重点只说"动作工具和服务工具差在哪",以及一个值得注意的取舍。 + +--- + +## 工具速览 + +- **它干什么**:给动作服务器发一个目标,用于**长任务**:导航到某处、机械臂运动等(回忆第③⑨篇:动作 = 长任务 + 进度反馈 + 可中途取消)。 +- **和服务工具的关系**:骨架一字不差,底层方法换成 `sendActionGoal`。 + +--- + +## 第 1-22 行:导入 + 说明字段 + 参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_action_goal tool with the AI agent. + * Sends action goals with progress feedback streaming. + */ +export function registerActionTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_action_goal", + label: "ROS2 Action Goal", + description: + "Send a goal to a ROS2 action server and stream feedback. " + + "Use this for long-running operations like navigation or arm movements.", + parameters: Type.Object({ + action: Type.String({ description: "The ROS2 action server name (e.g., '/navigate_to_pose')" }), + actionType: Type.String({ description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" }), + goal: Type.Record(Type.String(), Type.Unknown(), { + description: "The action goal parameters", + }), + }), +``` + +- 导入、骨架同前。`description` 翻译:「给动作服务器发目标并流式接收反馈。**用于长任务**,如导航或机械臂运动。」 +- 三个参数(都是熟面孔): + - `action: Type.String(...)` —— 动作服务器名,必填。 + - `actionType: Type.String(...)` —— 动作类型,**必填**(注意没套 `Type.Optional`——回忆第⑨篇:动作类型不能省)。 + - `goal: Type.Record(...)` —— 目标参数(任意对象),**必填**(注意也没 `Optional`——发动作总得给个目标)。 +- 和服务工具的细微差别:服务的 `type`/`args` 都是可选,这里 `actionType`/`goal` 都必填。这反映了"动作必须明确目标"的语义。 + +--- + +## 第 24-45 行:`execute` + +```typescript + async execute(_toolCallId, params) { + const action = params["action"] as string; + const actionType = params["actionType"] as string; + const goal = params["goal"] as Record; + + const transport = getTransport(); + const actionResult = await transport.sendActionGoal({ + action, + actionType, + args: goal, + }); + + const result = { + success: actionResult.result, + action, + result: actionResult.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- **取参数**:三个都必填,全断言成确定类型(不带 `| undefined`,因为没一个是可选的)。 +- `const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });` + - 调第⑨篇的 `sendActionGoal`(经第⑩篇适配器),`await` 等动作做完拿最终结果。 + - 参数对象:`action`、`actionType` 对象简写;`args: goal` —— **改名**(本工具参数叫 `goal`,但 `ActionGoalOptions` 的字段叫 `args`,所以 `args: goal`,回忆第⑮篇 `msg: message` 同理)。 + - **注意一个"缺失"**:第⑨/③篇的 `sendActionGoal` 支持 `onFeedback`(进度回调),但**这里没传**。也就是说,这个工具虽然 description 说"stream feedback(流式反馈)",实现上却只 `await` 等最终结果、没有真正把中途进度转给 AI。 + - 为什么?因为工具的 `execute` 是"调用一次、返回一次"的模型——它没有一个持续的通道把"导航到 30%…60%…"实时喂给 AI。所以这里务实地只等最终结果。description 里的"stream feedback"更多是底层能力的描述,工具层暂未利用。**这是个诚实值得记下的取舍**:底层支持进度,但工具这一层用"等最终结果"简化了。(将来若做实时仪表盘——第 32 篇的 canvas——才会真正用上进度流。) +- **包结果**: + - `success: actionResult.result` —— 动作结果的成功标志(第⑤篇 `ActionResult` 的 `result`)。 + - `action` —— 回显动作名。 + - `result: actionResult.values` —— 动作返回数据装进 `result` 字段。 + - **小注意**:这里外层结果对象有个字段也叫 `result`(`result: actionResult.values`),别和 `actionResult.result`(成功标志)搞混——前者是"返回数据"、后者是"成功与否",名字撞了但含义不同。服务工具那里把数据叫 `response`,这里叫 `result`,是各文件各自的措辞,不必深究。 + - 收尾还是 `content` + `details` 套路。 + +--- + +## 服务工具 vs 动作工具:双胞胎对照 + +| | 服务工具 ⑰ | 动作工具 ⑱ | +|---|---|---| +| 底层方法 | `callService` | `sendActionGoal` | +| 参数可选性 | `type`/`args` 可选 | `actionType`/`goal` 必填 | +| 等待 | 等响应(秒级) | 等最终结果(可能很久) | +| 进度反馈 | 无此概念 | 底层支持,但**工具未利用** | +| execute 复杂度 | 一行 await | 一行 await(同样薄) | + +> 两个工具骨架完全相同,差别全在"底层调哪个方法""哪些参数必填"。再次印证:**工具层是薄薄的转发层。** + +--- + +## 整章回顾 + +- `ros2_action_goal` 是"发动作目标"工具,`execute` 核心就是 `await transport.sendActionGoal(...)`,和服务工具是双胞胎。 +- 值得记住的一点:它**没有利用底层的进度回调**,只等最终结果——这是"工具调用一次返回一次"模型下的务实简化,description 里的"stream feedback"暂未在工具层兑现。 +- 全篇无新语法。 + +**语法点回顾清单**(本章无新增,全是巩固): +- 工具骨架 + TypeBox 参数(必填 vs `Type.Optional`) +- 全必填参数断言成确定类型(无 `| undefined`) +- `await transport.sendActionGoal(...)` 复用第⑨篇动作骨架 +- 对象字段改名 `args: goal` +- 底层能力(进度回调)在工具层可以选择不用——一个真实的工程取舍 + +下一份:[`ros2-param.ts` 逐行详解 →](19-ros2-param.ts.md)(参数读写——一个文件里注册**两个**工具,且看到"把参数操作翻译成调用标准服务"的巧思) + + + +--- + + +# 逐行详解 ⑲:`tools/ros2-param.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-param.ts](../../extensions/openclaw-plugin/src/tools/ros2-param.ts) +> +> 推荐阅读顺序第 19 个文件,工具层里行数最多的一个(83 行),但**不难**——因为它其实是**两个工具**装在一个文件里(读参数 `ros2_param_get` + 写参数 `ros2_param_set`),每个都是熟悉的骨架。本篇真正的新意不在语法,而在一个**设计巧思**:ROS2 没有"直接读写参数"的独立通道,参数读写其实是**调用节点自带的标准服务**完成的。这篇让你看到工具如何"把一种操作翻译成调用某个约定俗成的服务"。 + +--- + +## 先理解"参数"和它的读写方式 + +- ROS2 里每个**节点**(node,机器人上一个运行的程序单元)都可以有一堆**参数**(parameter)——比如最大速度、某个开关。 +- 怎么读写它们?ROS2 有个约定:**每个节点自动提供两个标准服务**: + - `<节点名>/get_parameters` —— 读参数。 + - `<节点名>/set_parameters` —— 写参数。 +- 所以"读参数"本质就是"调用那个节点的 `get_parameters` 服务"。本工具做的,就是把 AI 友好的"读 node 的某参数"翻译成"调 `/get_parameters` 服务、传对参数"。**它是建在第⑰篇服务调用之上的一层"语义包装"。** + +> 这呼应第⑩篇 `listTopics` 调 `/rosapi/topics`、第⑪篇没有现成接口就找替代——ROS2 里大量能力都是"调某个约定服务"实现的。认识这个模式,很多代码就通了。 + +--- + +## 第 1-8 行:导入 + 注册函数(一个函数注册俩工具) + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register ros2_param_get and ros2_param_set tools with the AI agent. + */ +export function registerParamTools(api: OpenClawPluginApi): void { +``` + +- 导入同前。 +- **注意函数名是复数 `registerParamTools`**(带 s)——回忆第⑭篇导入清单时埋的伏笔:它一口气注册**两个**工具。函数体里会有**两次** `api.registerTool(...)` 调用。其他工具文件都是"一个文件一个工具",这个是"一个文件两个工具"。把读、写两个相关工具放一起合情合理。 + +--- + +## 第一个工具:`ros2_param_get`(读参数) + +### 第 9-18 行:说明字段 + 参数 + +```typescript + api.registerTool({ + name: "ros2_param_get", + label: "ROS2 Get Parameter", + description: + "Get the value of a ROS2 parameter from a node. " + + "Use this to check robot configuration values.", + parameters: Type.Object({ + node: Type.String({ description: "The fully qualified node name (e.g., '/turtlebot3/controller')" }), + parameter: Type.String({ description: "The parameter name (e.g., 'max_velocity')" }), + }), +``` + +- `description`:「从一个节点读取某参数的值。用它来查机器人的配置值。」 +- 参数两个,都必填字符串: + - `node` —— **完整节点名**(fully qualified,即带完整路径的,如 `/turtlebot3/controller`)。 + - `parameter` —— 参数名(如 `max_velocity`)。 + +### 第 20-29 行:`execute`——翻译成调 `get_parameters` 服务 + +```typescript + async execute(_toolCallId, params) { + const node = params["node"] as string; + const parameter = params["parameter"] as string; + + const transport = getTransport(); + const response = await transport.callService({ + service: `${node}/get_parameters`, + type: "rcl_interfaces/srv/GetParameters", + args: { names: [parameter] }, + }); +``` + +- 取两个参数 `node`、`parameter`(必填、断言 `string`)。 +- 拿传输后,**核心是这次 `callService`**——把"读参数"翻译成"调标准服务": + - `service: \`${node}/get_parameters\`` —— **用模板字符串拼出服务名**(回忆第⑥篇 `` `${x}` ``)。比如 `node` 是 `/turtlebot3/controller`,拼出来就是 `/turtlebot3/controller/get_parameters`。**这就是"参数读取 = 调那个节点的 get_parameters 服务"的落地。** + - `type: "rcl_interfaces/srv/GetParameters"` —— 这是 ROS2 标准的"读参数服务"类型,写死的(所有节点的 get_parameters 都是这个类型)。 + - `args: { names: [parameter] }` —— 这个标准服务要求传一个 `names` 字段,**它是个数组**(可以一次读多个参数)。这里只读一个,所以包成单元素数组 `[parameter]`(回忆第⑥篇数组字面量)。 + - **小注意**:服务接口要的是"参数名列表",即使只读一个也得放进数组——这是迁就底层服务的固定形状。 + +### 第 31-42 行:包结果返回 + +```typescript + const result = { + success: response.result, + node, + parameter, + value: response.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +``` + +- 拼结果:`success`(成功标志)、回显 `node`/`parameter`(对象简写)、`value: response.values`(读到的值装进 `value`)。 +- 还是 `content` + `details` 套路。 +- `});` 关闭第一个工具的注册。**注意这里函数还没结束**——下面紧接着第二个工具。 + +--- + +## 第二个工具:`ros2_param_set`(写参数) + +### 第 44-54 行:说明字段 + 参数 + +```typescript + api.registerTool({ + name: "ros2_param_set", + label: "ROS2 Set Parameter", + description: + "Set the value of a ROS2 parameter on a node. " + + "Use this to change robot configuration at runtime.", + parameters: Type.Object({ + node: Type.String({ description: "The fully qualified node name" }), + parameter: Type.String({ description: "The parameter name" }), + value: Type.Unknown({ description: "The new parameter value" }), + }), +``` + +- **第二次** `api.registerTool(...)`——注册写参数工具。 +- `description`:「在节点上设置某参数的值,用来在运行时改机器人配置。」 +- 参数比读多一个: + - `node`、`parameter` 同上。 + - `value: Type.Unknown({ ... })` —— **新参数:要设的新值**。用 `Type.Unknown()`(任意类型,第⑮篇)——因为参数值可能是数字、字符串、布尔……什么都可能,所以用"任意值"。 + +### 第 56-70 行:`execute`——翻译成调 `set_parameters` 服务 + +```typescript + async execute(_toolCallId, params) { + const node = params["node"] as string; + const parameter = params["parameter"] as string; + const value = params["value"]; + + const transport = getTransport(); + const response = await transport.callService({ + service: `${node}/set_parameters`, + type: "rcl_interfaces/srv/SetParameters", + args: { + parameters: [ + { name: parameter, value }, + ], + }, + }); +``` + +- 取参数:`node`、`parameter` 断言 `string`。 +- `const value = params["value"];` —— **注意这行没有 `as` 断言**!因为 `value` 本就是"任意类型",取出来是 `unknown`,而下面要做的只是把它原样传给服务,不需要断言成某个具体类型。**用不到具体类型时,就不必断言**——这和前面"断言成 string 才能用"形成对照:断言是为了"用这个值的某个具体能力",纯转发则不需要。 +- 核心 `callService`,和读参数对称: + - `service: \`${node}/set_parameters\`` —— 拼出写参数的标准服务名。 + - `type: "rcl_interfaces/srv/SetParameters"` —— 标准的"写参数服务"类型。 + - `args` 这次结构复杂些: + ```typescript + args: { + parameters: [ + { name: parameter, value }, + ], + }, + ``` + - 这个标准服务要的是一个 `parameters` 字段,**也是数组**(可一次设多个)。 + - 数组里每项是 `{ name, value }`——参数名 + 新值。这里设一个,所以单元素数组里放一个 `{ name: parameter, value }`(`value` 对象简写)。 + - **这是个"对象套数组套对象"的嵌套结构**——逐层看就不晕:最外 `args` 是对象 → 里面 `parameters` 是数组 → 数组元素是 `{name, value}` 对象。新手遇到嵌套结构,**从外往里一层层剥**即可。 + +### 第 72-82 行:包结果返回 + +```typescript + const result = { + success: response.result, + node, + parameter, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- 结果比读参数少个 `value`(写操作不需要回显值,报告成功 + 是哪个节点的哪个参数即可)。 +- 收尾 `content` + `details`。 +- `});` 关闭第二个工具,最后 `}` 关闭整个 `registerParamTools` 函数(**这才是两个工具都注册完**)。 + +--- + +## 整章回顾 + +- `ros2-param.ts` 在**一个注册函数里注册了两个工具**(读 `ros2_param_get` + 写 `ros2_param_set`),所以函数名是复数 `registerParamTools`。 +- 两个工具的核心巧思是同一个:**参数读写没有专门通道,而是调用节点自带的标准服务** `/get_parameters` 和 `/set_parameters`(类型固定为 `rcl_interfaces/srv/Get/SetParameters`)。工具用**模板字符串拼出服务名**、按标准服务要求的形状(`names` 数组 / `parameters` 数组)组织参数。 +- 认识这个模式("某能力 = 调某约定服务")后,回头看第⑩篇 `listTopics` 调 `/rosapi/topics` 就完全是同一回事。 + +**语法点回顾清单**(本章新增/巩固): +- 一个注册函数里 **两次** `api.registerTool`(一个文件注册多个工具) +- 模板字符串拼服务名 `` `${node}/get_parameters` ``(巩固第⑥篇) +- 迁就底层服务形状:单个值也要包成数组 `names: [parameter]`、嵌套 `parameters: [{ name, value }]` +- 嵌套结构"从外往里逐层剥"的阅读法 +- `Type.Unknown()` 描述"任意类型的值"参数(第⑮篇) +- 纯转发的值**不必断言**(`const value = params["value"];` 没有 `as`)vs 要用具体能力才断言——一个对照 + +--- + +## 工具层进度小结 + +到这里,工具层已讲完 5 个(发布⑮、订阅⑯、服务⑰、动作⑱、参数⑲)。它们共享同一副骨架,差别只在"调底层哪个方法、参数怎么配、结果怎么措辞"。**你已经完全掌握了工具的写法套路。** 剩下两个工具(第 20 自省、第 21 摄像头)会各带一点小新意(自省是"列能力"、摄像头涉及图片返回),但骨架都一样,会更快。 + +下一份:[`ros2-introspect.ts` 逐行详解 →](20-ros2-introspect.ts.md)(自省工具:让 AI 问"机器人有哪些话题/服务/动作"——直接调第⑩篇那几个 list 方法) + + + +--- + + +# 逐行详解 ⑳:`tools/ros2-introspect.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-introspect.ts](../../extensions/openclaw-plugin/src/tools/ros2-introspect.ts) +> +> 推荐阅读顺序第 20 个文件,工具层里**最短、最简单的一个**(29 行)。它让 AI 能问"机器人现在有哪些话题"——属于"自省(introspection,自我检查/发现能力)"。本篇唯一的新意是一个小语法:**空参数 `Type.Object({})`**(这个工具不需要任何参数)。其余全是熟套路,几分钟就能过完。 + +--- + +## 工具速览 + +- **它干什么**:列出机器人当前所有话题及其消息类型。让 AI **先了解"机器人能干什么、发什么数据"**,再决定调别的工具。 +- **底层**:直接调第⑩篇适配器的 `listTopics`(那个用 `.map` 把 rosapi 返回缝成 `TopicInfo[]` 的方法)。 + +--- + +## 第 1-16 行:导入 + 说明字段 + 空参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_list_topics tool with the AI agent. + * Allows the agent to discover available ROS2 topics at runtime. + */ +export function registerIntrospectTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_list_topics", + label: "ROS2 List Topics", + description: + "List all available ROS2 topics and their message types. " + + "Use this to discover what data the robot publishes and what commands it accepts.", + parameters: Type.Object({}), +``` + +- 导入、骨架同前。 +- `description`:「列出所有可用话题及其消息类型。**用它来发现机器人发布什么数据、接受什么指令。**」——这段对 AI 尤其重要:它引导 AI 在"不确定机器人有什么"时**先调这个工具摸底**。 +- `parameters: Type.Object({})` —— **新点:空对象参数。** + - **语法小课堂:`Type.Object({})` 表示"这个工具不需要任何参数"。** 回忆第⑮篇 `Type.Object({ topic: ..., ... })` 里花括号装着字段。这里花括号**空着**,就是声明"无参数"。 + - 为什么无参数?因为"列出所有话题"不需要任何输入——AI 直接调用即可。 + - 注意**还是要写 `Type.Object({})`,不能省略 `parameters`**——接口要求每个工具都得有 `parameters`(哪怕是空的),保持形状统一。 + +--- + +## 第 18-28 行:`execute` + +```typescript + async execute(_toolCallId, _params) { + const transport = getTransport(); + const topics = await transport.listTopics(); + + const result = { success: true, topics }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- `async execute(_toolCallId, _params)` —— **注意两个参数都加了 `_` 前缀**(`_toolCallId`、`_params`)。回忆第⑫篇:`_` 表示"故意不用"。因为这个工具既不看调用编号、也没有参数要读,两个都用不上,于是都标 `_`。这是目前第一个**连 `params` 都不用**的工具(前面的工具至少要读参数)。 +- `const transport = getTransport();` —— 取传输。 +- `const topics = await transport.listTopics();` —— **核心一行**:调第⑩篇的 `listTopics`,`await` 拿到话题信息数组 `TopicInfo[]`(每项 `{ name, type }`)。所有复杂活(调 rosapi、`.map` 缝合)都在第⑩篇做完了,这里一行取货。 +- `const result = { success: true, topics };` —— 拼结果:成功标志 + 话题数组(`topics` 对象简写)。 +- 收尾 `content` + `details` 套路(第⑮篇)。 + +--- + +## 整章回顾 + +- `ros2_list_topics` 是最简单的工具:无参数(`Type.Object({})`)、`execute` 里一句 `await transport.listTopics()` 取货、包结果返回。 +- 它的意义在"自省":让 AI 能在运行时**主动发现机器人的能力**,是 AI 智能使用其他工具的前提(先知道有哪些话题,才知道往哪发、订哪个)。 +- 注意工具名叫 `list_topics`(只列话题),虽然第⑩篇适配器还有 `listServices`/`listActions`,但本工具只暴露了话题这一个。(这是当前实现的选择,未来可扩展。) + +**语法点回顾清单**(本章新增/巩固): +- `Type.Object({})`:声明"无参数"工具(花括号空着;仍必须写 `parameters`) +- `execute(_toolCallId, _params)`:两个参数都不用时都加 `_` 前缀(巩固第⑫篇) +- `await transport.listTopics()` 直接复用第⑩篇的自省方法 +- `content` + `details` 返回套路(巩固) + +下一份:[`ros2-camera.ts` 逐行详解 →](21-ros2-camera.ts.md)(摄像头取一帧——结构像第⑯篇订阅,但第一次处理"图片/二进制数据") + + + +--- + + +# 逐行详解 ㉑:`tools/ros2-camera.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-camera.ts](../../extensions/openclaw-plugin/src/tools/ros2-camera.ts) +> +> 推荐阅读顺序第 21 个文件,**工具层的最后一个**。它让 AI 抓取摄像头的一帧画面("机器人现在看到什么")。结构上它和第⑯篇订阅工具**几乎一样**(同一个竞速 Promise),所以骨架你已经懂了。本篇真正的新东西只有一个概念:**图像数据是怎么在文本协议里传输的**(base64 编码)。我们重点讲这个,其余快速过。 + +--- + +## 工具速览 + +- **它干什么**:从摄像头话题抓一帧图像返回。用户问"你看到什么""拍张照"时用。 +- **底层结构**:和第⑯篇 `subscribe_once` 同款——订阅一次、拿到一帧就退订、带超时竞速。区别在订阅的是**图像话题**、返回的是**图像数据**。 + +--- + +## 先理解"图像怎么通过文本传" + +这里有个新手会困惑的点:rosbridge 走的是 **JSON 文本**协议(回忆第⑤⑥篇),而图像是**二进制数据**(一堆字节)。文本里怎么塞二进制? + +答案是 **base64 编码**: + +- **base64** 是一种"把任意二进制数据编码成纯文本字符串"的标准方法。它用 64 个安全字符(A-Z、a-z、0-9、`+`、`/`)来表示二进制。 +- 一张 JPEG 图片本是二进制,经 base64 编码后变成一长串文本(像 `"/9j/4AAQSkZJRg..."`),就能塞进 JSON 传输了。接收方再 base64 解码还原成图片。 +- 所以本工具拿到的 `data` 字段,就是**图像的 base64 文本**。description 里 "base64-encoded data" 说的正是这个。 + +> 你不需要会写 base64 编解码——底层和宿主会处理。你只要理解:**图像在这套文本协议里以 base64 字符串的形态传输**。 + +--- + +## 第 1-19 行:导入 + 说明字段 + 参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_camera_snapshot tool with the AI agent. + * Grabs a single frame from a camera topic. + */ +export function registerCameraTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_camera_snapshot", + label: "ROS2 Camera Snapshot", + description: + "Capture a single image from a ROS2 camera topic. Returns the image as base64-encoded data. " + + "Use this when the user asks what the robot sees or requests a photo.", + parameters: Type.Object({ + topic: Type.Optional(Type.String({ description: "The camera image topic (default: '/camera/image_raw/compressed')" })), + timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 10000)" })), + }), +``` + +- 骨架同前。`description`:「从摄像头话题抓一帧图像,**以 base64 编码数据返回**。当用户问机器人看到什么、或要照片时用它。」 +- 参数两个,都**可选**(`Type.Optional`,第⑯篇): + - `topic` —— 摄像头话题,默认 `/camera/image_raw/compressed`(`compressed` 表示压缩图像,即 JPEG 之类)。 + - `timeout` —— 超时,默认 10000 毫秒(10 秒,比第⑯篇订阅的 5 秒长——图像帧大、可能慢一点)。 + - 默认值同第⑯篇套路:描述里写"default"只是告知,真正兜底在 `execute` 里用 `??`。 + +--- + +## 第 21-23 行:`execute` 取参数(带默认值兜底) + +```typescript + async execute(_toolCallId, params) { + const topic = (params["topic"] as string | undefined) ?? "/camera/image_raw/compressed"; + const timeout = (params["timeout"] as number | undefined) ?? 10000; +``` + +- 两个参数都用第⑯篇学的**断言 + `??` 兜底**写法: + - `topic` 没填就用默认话题字符串。 + - `timeout` 没填就用 `10000`。 +- 注意 `topic` 这里和第⑯篇的不同:第⑯篇 `topic` 是必填(无默认),这里 `topic` 可选且有默认值——因为摄像头话题名通常是约定俗成的那个,AI 不填也能work。 + +--- + +## 第 25-45 行:竞速 Promise(和第⑯篇同款) + +```typescript + const transport = getTransport(); + + const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: "sensor_msgs/msg/CompressedImage" }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ + success: true, + topic, + format: msg["format"] ?? "jpeg", + data: msg["data"] ?? "", + }); + }, + ); + const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for camera frame on ${topic}`)); + }, timeout); + }); +``` + +**这段的骨架和第⑯篇订阅工具一模一样**——竞速 Promise:订阅收到一帧就 `resolve`、超时就 `reject`,两条路都 `unsubscribe`/`clearTimeout` 清理。如果第⑯篇看懂了,这里只需看两处**不同**: + +1. **订阅类型写死为图像类型**: + ```typescript + { topic, type: "sensor_msgs/msg/CompressedImage" } + ``` + - 第⑯篇的订阅工具 `type` 是 AI 传的;这里直接写死 `sensor_msgs/msg/CompressedImage`(ROS2 标准的压缩图像消息类型)。因为这工具**专门**抓图像,类型固定,不用 AI 操心。 + +2. **收到消息后从中提取图像字段**: + ```typescript + resolve({ + success: true, + topic, + format: msg["format"] ?? "jpeg", + data: msg["data"] ?? "", + }); + ``` + - 第⑯篇是把整条 `msg` 原样返回;这里**只挑出图像相关的两个字段**: + - `format: msg["format"] ?? "jpeg"` —— 图像格式。从消息里取 `format` 字段(方括号取属性,第⑩篇),万一没有就兜底成 `"jpeg"`(`??`,第⑥篇)。 + - `data: msg["data"] ?? ""` —— **图像的 base64 数据**。取消息的 `data` 字段(就是上面讲的 base64 文本),没有就兜底成空字符串。 + - `CompressedImage` 消息里就是 `format` + `data` 两个关键字段,这里精准取出,丢掉其余无关字段。 + +> 除这两处外,竞速、超时、清理逻辑和第⑯篇完全相同,不再重复讲。这再次体现工具的"套路化":换个话题、换组要提取的字段,骨架照搬。 + +--- + +## 第 47-52 行:返回结果 + +```typescript + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- **和前面所有工具一样的 `content` + `details` 套路**——把 `result`(含 base64 图像数据)`JSON.stringify` 成文本返回。 +- **一个值得注意的点**:回忆第⑭篇 `ToolResult` 的 `content` 其实支持**图片类型** `{ type: "image", data, mimeType }`。本工具拿到了图像 base64,**理论上可以**返回成 `type: "image"` 让 AI 直接"看图"。但这里**仍用 `type: "text"`** 把整个结果(含 base64 串)当文本返回。 + - 这是当前实现的简化:图像数据作为文本字段塞在 JSON 里,由宿主/上层决定怎么进一步处理(比如渲染)。 + - 这是个可以改进的地方——未来若要让 AI 真正"看见"图像,可以把 `data` 单独拿出来组成 `{ type: "image", data, mimeType: "image/jpeg" }` 的 content 项。**注意到这种"现状 vs 可改进",正是读源码读出门道的表现。** + +--- + +## 整章回顾 + +- `ros2_camera_snapshot` 抓取一帧摄像头图像,**结构完全复用第⑯篇的竞速 Promise**,只改了两处:订阅类型写死为 `CompressedImage`、收到后提取 `format`/`data` 两个图像字段。 +- 关键概念:**图像以 base64 字符串在 JSON 文本协议里传输**(`data` 字段就是 base64 编码的图像)。 +- 当前用 `type: "text"` 返回(把 base64 塞在文本 JSON 里),`ToolResult` 其实支持 `type: "image"`,是个未来可优化点。 + +**语法点回顾清单**(本章新增/巩固): +- base64:把二进制(图像)编码成纯文本,以便在 JSON 文本协议里传输(概念) +- 竞速 Promise + 超时清理(完全复用第⑯篇) +- 订阅类型写死 `"sensor_msgs/msg/CompressedImage"`(专用工具不需 AI 给类型) +- 从消息提取特定字段 `msg["format"] ?? "jpeg"` / `msg["data"] ?? ""`(方括号取属性 + `??` 兜底) +- `ToolResult.content` 支持 `text`/`image` 两型(回扣第⑭篇),本工具用 text + +--- + +## 🎉 工具层全部讲完! + +到这里,**第五部分(工具层)7 个工具文件全部讲完**(14 总装 + 15 发布 + 16 订阅 + 17 服务 + 18 动作 + 19 参数读写 + 20 自省 + 21 摄像头)。回头看,你已经掌握了: + +> **一个工具 = 五件套骨架(name/label/description/parameters/execute)+ TypeBox 参数 + execute 里"取参数→调传输某方法→包成 ToolResult 返回"。** 七个工具的差别只在中间那几行,骨架完全一致。 + +而且工具层让你彻底吃透了 TypeBox、`content/details` 返回、竞速 Promise 等模式。**这是 AI 与机器人之间的全部"动作按钮"。** + +接下来**第六部分(钩子与命令)**换一个主题:不再是"AI 主动调的工具",而是**"在特定时机自动触发的钩子"**(动手前安全校验、开聊前注入能力)和**"用户直接打的命令"**(`/estop`、`/transport`)。会引入一些新概念(钩子机制、命令处理),但有了工具层的基础,理解起来会顺。 + +下一份:[`safety/validator.ts` 逐行详解 →](22-safety-validator.ts.md)(安全校验钩子:每次 AI 要调工具前,先拦一道,检查指令安不安全) + + + +--- + + +# 逐行详解 ㉒:`safety/validator.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/safety/validator.ts](../../extensions/openclaw-plugin/src/safety/validator.ts) +> +> 推荐阅读顺序第 22 个文件,**第六部分「钩子与命令」的开篇**。前面的工具是"AI 主动调用"的;从这篇起换一种东西——**钩子(hook)**:它**不是被 AI 调用,而是在某个时机由宿主自动触发**。本篇的钩子叫 `before_tool_call`(每次 AI 要调任何工具**之前**自动触发),用来做**安全校验**:如果 AI 想让机器人开太快,就**拦下来**。第一次见到"钩子"机制、以及一点数学运算语法。 + +--- + +## 先理解"钩子(hook)"是什么 + +- **工具**:AI 说"我要调 `ros2_publish`",宿主才执行——**被动、按需**。 +- **钩子**:你预先告诉宿主"在 XX 时机,请自动帮我跑这段代码"。到点了宿主就触发它——**主动、按时机**。 +- 本篇的 `before_tool_call`(工具调用前)就是个时机钩子:**每次 AI 即将调用任何工具,宿主先暂停,把"它要调什么、参数是什么"交给我们的钩子检查;钩子可以放行,也可以喊停(block)。** + +``` +AI 决定调用某工具 + │ + ▼ +宿主触发 before_tool_call 钩子 ──> 我们的校验代码 + │ │ + │←──── 返回 {block:true} 拦截 ──┤(不安全 → 拦下,工具不执行) + │←──── 返回 void 放行 ──────────┘(安全 → 照常执行工具) + ▼ +(放行时)工具真正执行 +``` + +这是一种"**拦截器**"模式——在动作发生前插一道关卡。安全校验放这里最合适:**在机器人真正动起来之前**就把危险指令挡掉。 + +--- + +## 第 1-9 行:导入 + 注册函数开头 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; + +/** + * Register the before_tool_call safety validation hook. + * Intercepts tool calls and validates them against safety limits. + */ +export function registerSafetyHook(api: OpenClawPluginApi, config: RosClawConfig): void { + const safety = config.safety; +``` + +- 两个 `import type`:宿主接口、配置类型。 +- `registerSafetyHook(api, config)` —— 第⑬篇 `index.ts` 调过它。它吃 `config`(不像工具只吃 `api`),因为安全限值就存在配置里。 +- `const safety = config.safety;` —— 把配置里的安全那一节(含 `maxLinearVelocity`、`maxAngularVelocity` 等,回忆第②篇 Zod schema)取出来存个短变量,下面反复用。 + +--- + +## 第 11 行:注册钩子 `api.on(...)` + +```typescript + api.on("before_tool_call", async (event, _ctx) => { +``` + +**语法小课堂:`api.on(事件名, 处理函数)` —— 登记一个钩子。** +- `api.on(...)` 是宿主提供的"订阅时机"方法(回忆第①篇 plugin-api 里见过它的类型)。 +- 第一个参数 `"before_tool_call"` —— 要挂在哪个时机(事件名)。 +- 第二个参数 `async (event, _ctx) => {...}` —— 时机到了要跑的**处理函数**: + - `event` —— 这次事件的信息。对 `before_tool_call` 来说,它是 `BeforeToolCallEvent`,含 `toolName`(要调哪个工具)和 `params`(参数)。 + - `_ctx` —— 上下文(agentId 等),本钩子用不到,加 `_` 前缀(第⑫篇)。 + - `async` —— 处理函数可以是异步的(虽然本篇没 await,但接口允许)。 +- **这个处理函数的返回值很关键**:返回 `{ block: true, blockReason: "..." }` 就**拦截**这次工具调用;什么都不返回(`void`)就**放行**。下面会看到。 + +--- + +## 第 12-13 行:只关心发布工具 + +```typescript + if (event.toolName === "ros2_publish") { + const msg = event.params["message"] as Record | undefined; +``` + +- `if (event.toolName === "ros2_publish")` —— **只检查发布工具**。因为只有发布(发指令给机器人)才可能让机器人危险地动起来;读数据、列话题之类无害,不必管。其他工具进了这个钩子会直接跳过整段、放行。 +- `const msg = event.params["message"] as ... | undefined;` —— 取出发布工具的 `message` 参数(回忆第⑮篇:发布工具的参数里有 `message`)。 + - `event.params["message"]` —— 方括号取属性(第⑩篇)。 + - `as Record | undefined` —— 断言成"对象或没有"(参数可能缺,诚实写 `| undefined`,第⑯篇)。 + +--- + +## 第 14-18 行:取出速度的两部分 + +```typescript + if (msg) { + // Check velocity limits for Twist messages + const linear = msg["linear"] as Record | undefined; + const angular = msg["angular"] as Record | undefined; +``` + +- `if (msg)` —— 真值判断(第⑦篇):有消息体才往下查。 +- 注释 `Twist messages`:速度指令的标准消息类型 `Twist` 长这样——有 `linear`(线速度,含 x/y/z)和 `angular`(角速度,含 x/y/z)两部分。 +- 取这两部分: + - `linear` —— 线速度对象,断言成 `Record | undefined`(键是字符串如 "x"/"y"/"z",值是数字;可能没有)。 + - `angular` —— 角速度对象,同理。 +- **注意这里的容错思路**:层层都断言成 `| undefined` 并用 `if` 守卫——因为消息是 AI 拼的,不保证一定有这些字段。**不能假设结构完整,每层都防一手。** + +--- + +## 第 19-29 行:校验线速度(出现数学运算) + +```typescript + if (linear) { + const speed = Math.sqrt( + (linear["x"] ?? 0) ** 2 + + (linear["y"] ?? 0) ** 2 + + (linear["z"] ?? 0) ** 2, + ); + if (speed > safety.maxLinearVelocity) { + api.logger.warn(`Blocked: linear velocity ${speed} exceeds limit ${safety.maxLinearVelocity}`); + return { block: true, blockReason: `Linear velocity ${speed.toFixed(2)} m/s exceeds safety limit of ${safety.maxLinearVelocity} m/s` }; + } + } +``` + +- `if (linear)` —— 有线速度才算。 +- **计算合速度**(把 x/y/z 三个方向合成一个总速度大小): + ```typescript + const speed = Math.sqrt( + (linear["x"] ?? 0) ** 2 + + (linear["y"] ?? 0) ** 2 + + (linear["z"] ?? 0) ** 2, + ); + ``` + - **语法小课堂:`**` 是"乘方(指数)运算符"。** `a ** 2` 就是"a 的平方"(a×a)。`** 3` 就是三次方。 + - **语法小课堂:`Math.sqrt(x)` 是"求平方根"。** (回忆第⑥篇见过 `Math.min`/`Math.pow`,`Math` 是内置数学工具箱。) + - `(linear["x"] ?? 0)` —— 取 x 分量,没有就当 0(`??` 兜底,第⑥篇)。y、z 同理。 + - 整体是数学里的**三维向量长度公式** √(x²+y²+z²)——把三个方向的速度合成一个"总速度大小"。这就是机器人实际跑多快。 +- `if (speed > safety.maxLinearVelocity)` —— 合速度超过配置的上限了吗? + - 超了就: + - `api.logger.warn(...)` —— 记一条**警告日志**(`warn` 比 `info` 级别高,回忆第①篇 logger)。 + - `return { block: true, blockReason: "..." };` —— **关键:返回拦截决定。** + - `block: true` —— 告诉宿主"拦下这次工具调用,别执行"。 + - `blockReason: "..."` —— 拦截理由(会反馈给 AI/用户,说明为啥被拦)。 + - **语法小课堂:`speed.toFixed(2)`** —— 把数字格式化成"保留 2 位小数"的字符串。比如 `1.23456` → `"1.23"`。用在给人看的消息里,免得显示一长串小数。 + - **`return` 在这里直接结束整个钩子函数**——一旦决定拦截,立刻返回,后面的角速度检查也不必做了。 + +--- + +## 第 31-37 行:校验角速度 + +```typescript + if (angular) { + const rate = Math.abs(angular["z"] ?? 0); + if (rate > safety.maxAngularVelocity) { + api.logger.warn(`Blocked: angular velocity ${rate} exceeds limit ${safety.maxAngularVelocity}`); + return { block: true, blockReason: `Angular velocity ${rate.toFixed(2)} rad/s exceeds safety limit of ${safety.maxAngularVelocity} rad/s` }; + } + } +``` + +- 和线速度对称,但简单些——角速度只看 `z`(绕竖直轴转,即左右转的快慢,地面机器人主要就这个)。 +- `const rate = Math.abs(angular["z"] ?? 0);` + - **语法小课堂:`Math.abs(x)` 是"绝对值"。** 去掉正负号。因为转向有左右(正负),但"转多快"只看大小,所以取绝对值。`Math.abs(-1.5)` = `1.5`。 +- 超限同样 `return { block: true, blockReason: ... }` 拦截。 + +--- + +## 第 41-43 行:放行(隐式)+ 收尾 + +```typescript + // TODO: Add workspace limit checks for navigation goals + }); +} +``` + +- `// TODO:` —— 标记一个**待办**:以后还要加"导航目标的活动范围限制"(回忆 CLAUDE.md:未实现处用 `// TODO` 标记)。说明安全校验目前只做了速度,是可扩展的。 +- **注意:函数走到这里没有 `return`,等于返回 `void`——也就是"放行"。** 这是关键设计: + - 不安全 → 中途 `return { block: true }` 拦截。 + - 一路没拦 → 自然走到底、返回 `void` → 放行。 + - **"默认放行、命中危险才拦"**,所以无害的工具、安全的速度都会顺畅通过。 +- `});` 关闭 `api.on` 的处理函数和调用,`}` 关闭注册函数。 + +--- + +## 整章回顾 + +- 本篇引入**钩子(hook)**:用 `api.on("before_tool_call", 处理函数)` 在"工具调用前"插一道关卡。**返回 `{block:true, blockReason}` 拦截,返回 `void` 放行。** +- 这个安全钩子只盯 `ros2_publish`,把 `Twist` 速度指令的**线速度合成大小**(`√(x²+y²+z²)`)和**角速度大小**(`|z|`)算出来,超过配置上限就拦下、并给出人话理由。 +- 设计精髓:**默认放行,命中危险才 `return` 拦截**;层层 `if`+`| undefined` 防 AI 拼的消息结构不完整。这是"机器人动起来之前"的最后一道软件保险。 + +**语法点回顾清单**(本章新增/巩固): +- 钩子机制:`api.on(事件名, async (event, ctx) => {...})`,按时机自动触发(vs 工具被动调用) +- `before_tool_call` 钩子:`event.toolName`/`event.params`,返回 `{block, blockReason}` 拦截 / `void` 放行 +- `**`(乘方)、`Math.sqrt`(平方根)、`Math.abs`(绝对值)(巩固 `Math.*`) +- `数字.toFixed(2)`:格式化为保留 2 位小数的字符串 +- 中途 `return` 提前结束钩子(命中即拦)、走到底隐式 `void`(放行) +- 层层 `as ... | undefined` + `if` 守卫,应对不可信的外部结构 + +下一份:[`context/robot-context.ts` 逐行详解 →](23-robot-context.ts.md)(另一个钩子:开聊前把"机器人有哪些能力"注入给 AI,含缓存、`Promise.all` 并行、降级兜底) + + + +--- + + +# 逐行详解 ㉓:`context/robot-context.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/context/robot-context.ts](../../extensions/openclaw-plugin/src/context/robot-context.ts) +> +> 推荐阅读顺序第 23 个文件,本批最有料的一篇(192 行)。它是另一个钩子——`before_agent_start`(**每次对话开始前**触发)——干一件聪明事:**先问机器人"你现在有哪些话题/服务/动作",再把这份能力清单塞进 AI 的系统提示里**,让 AI 一开口就知道自己能指挥什么。本篇会综合很多前面学过的东西,并第一次见到几个重要新语法:**`Promise.all` 并行**、**带 TTL 的缓存**、**降级兜底(fallback)**、**空 catch**。 + +--- + +## 先理解这篇要解决的问题 + +AI 默认对你的机器人**一无所知**——它不知道有 `/cmd_vel` 这个话题、不知道能调哪些服务。如果不告诉它,它只能瞎猜话题名。 + +解决办法:**在每次对话开始前,动态问一次机器人"你有哪些能力",把结果写成一段说明,塞进 AI 的"系统提示"(system prompt,AI 对话的背景知识)里。** 这样 AI 一上来就掌握了这台机器人的"使用手册"。 + +但天天问、每次问会慢。所以还要: +- **缓存**:问一次记住,60 秒内不重复问。 +- **降级**:万一问失败(没连上等),用一份"硬编码的默认清单"顶上,别让 AI 两眼一抹黑。 + +这三件事(注入、缓存、降级)就是本篇的全部。 + +--- + +## 第 1-4 行:导入 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import type { TopicInfo, ServiceInfo, ActionInfo } from "../transport/types.js"; +import { getTransport } from "../service.js"; +``` + +- 前三个 `import type`:宿主接口、配置、以及三种能力信息类型 `TopicInfo`/`ServiceInfo`/`ActionInfo`(第③篇定义、第⑩篇 `list*` 方法返回的)。 +- `import { getTransport }` —— 值导入,要调用它拿传输。 + +--- + +## 第 6-15 行:缓存的数据结构 + 全局缓存变量 + +```typescript +/** Cached discovery results with TTL. */ +interface DiscoveryCache { + topics: TopicInfo[]; + services: ServiceInfo[]; + actions: ActionInfo[]; + timestamp: number; +} + +const CACHE_TTL_MS = 60_000; // 60s +let cache: DiscoveryCache | null = null; +``` + +- `interface DiscoveryCache { ... }` —— 定义"缓存里存什么"(回忆第①篇 interface):三个能力数组 + 一个 `timestamp`(时间戳)。 + - **`timestamp` 是关键**:记下"这份缓存是什么时候拿的",用来判断它过期没。 +- `const CACHE_TTL_MS = 60_000;` —— **TTL** 常量。 + - **语法小课堂:TTL = Time To Live(存活时间)。** 缓存"保鲜期"。`60_000` 毫秒 = 60 秒(`_` 分位,第⑥篇)。意思是"缓存最多用 60 秒,超过就当过期、重新问"。 + - 用全大写命名 `CACHE_TTL_MS` 是常量的惯例(表示"这是个固定不变的配置值")。 +- `let cache: DiscoveryCache | null = null;` —— **模块级缓存变量**(回忆第⑫篇模块级变量):存上次问到的结果,初始 `null`(还没问过)。`let` 因为会反复改写。 + +--- + +## 第 17-20 行:清缓存的导出函数 + +```typescript +/** Clear the discovery cache so the next agent start re-discovers capabilities. */ +export function clearDiscoveryCache(): void { + cache = null; +} +``` + +- 一个对外的小函数:把 `cache` 设回 `null`,**强制下次重新发现**。 +- 谁会用它?比如换了机器人、或传输重连后——能力可能变了,得作废旧缓存。下面就会看到一处自动调用它的逻辑。 + +--- + +## 第 22-49 行:`registerRobotContext`——注册钩子(主函数) + +### 第 26-28 行:取配置 + +```typescript +export function registerRobotContext(api: OpenClawPluginApi, config: RosClawConfig): void { + const robotName = config.robot.name; + const robotNamespace = config.robot.namespace; +``` + +- 从配置取机器人名字和**命名空间(namespace)**。 + - 命名空间是 ROS2 里给一组话题/服务加的统一前缀(如 `/turtlebot3`)。多机器人时用它区分。下面会用它过滤"只保留属于本机器人的能力"。 + +### 第 30-42 行:传输重连时自动清缓存(含"空 catch") + +```typescript + // Reactive re-discovery: clear cache on transport reconnect + try { + const transport = getTransport(); + transport.onConnection((status: string) => { + if (status === "connected") { + cache = null; // Force re-discovery on next agent start + api.logger.info("Transport reconnected — capability cache cleared"); + } + }); + } catch { + // Transport not initialized yet — will be set up by the service. + // The onConnection handler will be registered when the hook fires. + } +``` + +- 意图(注释 `Reactive re-discovery`):**传输一旦重连成功,就清空缓存**——因为重连可能意味着换了机器人或机器人重启了,旧能力清单不可信了。 +- `try { ... } catch { ... }`: + - `try` 里:拿传输、登记一个 `onConnection` 回调(第④/⑩篇)。回调里判断 `if (status === "connected")` 就 `cache = null` 清缓存 + 记日志。 + - **语法小课堂:`catch { }`——不带括号的 catch。** 回忆第②⑥篇 `catch (e)` 是接住错误对象。这里写 `catch {`(**没有 `(e)`**)表示"我知道可能出错,但**我不关心错误内容**,接住了啥也不干"。这是较新的语法,用在"出错也无所谓"的场景。 + - **为什么这里允许出错?** 注释说清了:`registerRobotContext` 在插件加载时就被调(第⑬篇),那时**传输可能还没初始化**(service 的 `start` 还没跑),`getTransport()` 会抛错(第⑫篇:没初始化就抛)。这没关系——抓住、忽略即可。等钩子真正触发时(对话开始时)传输早就好了。 + - 所以这段是"**尽力而为**":能登记重连监听就登记,登记不上也不影响主流程。 + +### 第 44-48 行:注册 `before_agent_start` 钩子 + +```typescript + api.on("before_agent_start", async (_event, _ctx) => { + const capabilities = await discoverCapabilities(api, robotNamespace); + const context = buildRobotContext(robotName, robotNamespace, capabilities); + return { prependContext: context }; + }); +} +``` + +- `api.on("before_agent_start", ...)` —— 挂在"对话开始前"这个时机(回忆第22篇 `api.on` 用法;这是另一个事件名)。`_event`/`_ctx` 都不用,加 `_`。 +- 钩子体三行,逻辑极清晰: + 1. `const capabilities = await discoverCapabilities(api, robotNamespace);` —— **发现能力**(调下面的辅助函数,带缓存)。 + 2. `const context = buildRobotContext(...);` —— **把能力拼成一段说明文字**(调下面另一个辅助函数)。 + 3. `return { prependContext: context };` —— **返回注入指令**。 + - **语法小课堂:`before_agent_start` 钩子返回 `{ prependContext: 文本 }` 的含义。** `prependContext`(前置上下文)告诉宿主"把这段文本**塞到 AI 系统提示的前面**"。于是 AI 这次对话就带着这份机器人能力清单开场。 + - 这和第22篇钩子返回 `{block}` 是同一套机制(钩子用返回值影响宿主行为),只是这个钩子的返回字段是 `prependContext`(注入内容),那个是 `block`(拦截)。 +- 主函数到此结束。下面是它用到的三个辅助函数。 + +--- + +## 第 51-98 行:`discoverCapabilities`——发现能力(缓存 + 并行 + 降级) + +这是本篇最精华的函数,三个新概念都在这。 + +### 第 55-62 行:签名 + 命中缓存就直接返回 + +```typescript +async function discoverCapabilities( + api: OpenClawPluginApi, + namespace: string, +): Promise { + // Return cached results if still fresh + if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) { + return cache; + } +``` + +- 注意这个函数**没有 `export`**——它是模块内部的辅助函数,只给本文件用(回忆:不导出 = 私有)。 +- `if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS)` —— **缓存命中判断**,拆开看: + - `cache &&` —— 先得有缓存(不是 `null`)。`&&` 短路(第⑥篇):没缓存就不算后面。 + - `Date.now() - cache.timestamp < CACHE_TTL_MS` —— **检查是否还新鲜**。 + - **语法小课堂:`Date.now()` 返回"现在的时间戳"**(从 1970 年至今的毫秒数,一个大整数)。 + - `Date.now() - cache.timestamp` —— 现在减去"缓存拿到的时刻" = **缓存已经存在了多少毫秒**。 + - `< CACHE_TTL_MS` —— 这个年龄小于 60 秒吗?小于就是"还新鲜"。 + - 两者都满足 → `return cache;` **直接返回旧缓存,跳过昂贵的重新发现**。这就是缓存省时间的核心:60 秒内的重复请求秒回。 + +### 第 64-71 行:缓存失效——并行发现三类能力(`Promise.all`) + +```typescript + try { + const transport = getTransport(); + + const [topics, services, actions] = await Promise.all([ + transport.listTopics(), + transport.listServices(), + transport.listActions(), + ]); +``` + +- 缓存没命中(没有或过期),才真去问机器人。包在 `try` 里(下面有 catch 降级)。 +- `const transport = getTransport();` —— 拿传输(这时传输肯定就绪了,因为钩子是对话时才触发的)。 +- **本篇重磅新语法:`Promise.all`。** + ```typescript + const [topics, services, actions] = await Promise.all([ + transport.listTopics(), + transport.listServices(), + transport.listActions(), + ]); + ``` + - **语法小课堂:`Promise.all([承诺1, 承诺2, 承诺3])` —— "并行等待多个异步操作"。** + - 我们要问三件事:列话题、列服务、列动作。每个都是异步的(返回 Promise)。 + - **如果一个个 `await`**(`await listTopics(); await listServices(); ...`),就是**串行**——等第一个完了才发第二个,三个加起来要等三段时间。 + - `Promise.all([...])` 把三个 Promise **同时发起**,然后等**它们全部完成**。总耗时≈最慢的那一个,而不是三个之和。这就是**并行**,快得多。 + - **语法小课堂:用数组解构接住结果 `const [topics, services, actions] = ...`。** `Promise.all` 返回的结果是个数组,**顺序和传入的一一对应**:第 0 个是 `listTopics` 的结果、第 1 个是 `listServices` 的、第 2 个是 `listActions` 的。用数组解构(回忆第⑥篇 `[x, y]`)一次性按位置拆出来,分别命名 `topics`/`services`/`actions`。 + - 一句话:**三件独立的事,与其排队做,不如一起做、一起等。** 这是 `Promise.all` 的典型用武之地。 + +### 第 73-82 行:按命名空间过滤 + 写入缓存 + +```typescript + // Filter by namespace if configured + const filterByNs = (name: string) => + !namespace || name.startsWith(namespace); + + cache = { + topics: topics.filter((t: TopicInfo) => filterByNs(t.name)), + services: services.filter((s: ServiceInfo) => filterByNs(s.name)), + actions: actions.filter((a: ActionInfo) => filterByNs(a.name)), + timestamp: Date.now(), + }; +``` + +- `const filterByNs = (name: string) => !namespace || name.startsWith(namespace);` —— 定义一个**判断函数**(存进变量,回忆第①篇"函数是一等公民"): + - 读作"这个名字该保留吗":`!namespace || name.startsWith(namespace)`。 + - `!namespace` —— 没配命名空间(空字符串等假值)?那就**全保留**(`!` 取反 + 短路 `||`:没命名空间时直接 true)。 + - `name.startsWith(namespace)` —— 配了命名空间,就只保留**以它开头**的名字(`.startsWith`,第⑥篇)。比如命名空间 `/turtlebot3`,就只留 `/turtlebot3/...` 的话题,过滤掉别的机器人的。 +- **语法小课堂:`数组.filter(判断函数)` —— "筛选数组"。** + - 回忆第⑩篇的 `.map`(逐个变形)。`.filter` 是"逐个判断、只留下判断函数返回 `true` 的元素",组成新数组。 + - `topics.filter((t: TopicInfo) => filterByNs(t.name))` —— 遍历每个话题 `t`,用 `filterByNs` 判断它的 `name` 该不该留,留下合格的。services、actions 同理。 +- 把过滤后的三个数组 + 当前时间戳 `Date.now()` 组成新缓存,赋给 `cache`。**这就是"问到了就记下来"**——下次 60 秒内再来就命中上面那个缓存判断。 + +### 第 84-88 行:记日志 + 返回 + +```typescript + api.logger.info( + `Discovered ${cache.topics.length} topics, ${cache.services.length} services, ${cache.actions.length} actions`, + ); + + return cache; +``` + +- 记一行"发现了 N 个话题、M 个服务、K 个动作"(`.length` 取数组长度,模板字符串插值)。 +- `return cache;` —— 返回这份新鲜缓存。 + +### 第 89-98 行:降级兜底(catch) + +```typescript + } catch (err) { + api.logger.warn(`Capability discovery failed, using defaults: ${err}`); + return { + topics: [], + services: [], + actions: [], + timestamp: 0, + }; + } +} +``` + +- 如果上面任何一步出错(没连上、服务报错……),进 `catch`: + - `catch (err)` —— 这次接住错误对象 `err`(和第22篇 `catch {` 不同,这里要用 `err` 写进日志)。 + - 记一条警告,把错误 `${err}` 拼进去(模板字符串里放对象会自动转成文字)。 + - **返回一份"空能力"**:三个空数组 `[]` + `timestamp: 0`。 + - **语法小课堂:`timestamp: 0` 是个小心机。** 0 是最古老的时间戳(1970 年),所以"现在 - 0"远大于 60 秒——这份兜底结果**天生就是"过期的"**,不会被当成有效缓存留用。下次还会重新尝试发现,而不是把这个空结果缓存住。 + - **注意:这里 `return` 的是空能力,但 `cache` 没被赋值**——失败结果不污染缓存。设计很细。 +- **降级思想**:发现失败不崩、不卡,而是返回空,让上层用"硬编码默认清单"顶上(下面 `buildRobotContext` 会处理)。**宁可给 AI 一份不全的清单,也不让它彻底失明。** + +--- + +## 第 100-117 行:`buildRobotContext`——选用动态还是兜底 + +```typescript +function buildRobotContext( + name: string, + namespace: string, + capabilities: DiscoveryCache, +): string { + const { topics, services, actions } = capabilities; + + // If discovery returned results, use them + if (topics.length > 0 || services.length > 0 || actions.length > 0) { + return buildDynamicContext(name, topics, services, actions); + } + + // Fall back to hardcoded defaults if discovery failed + return buildFallbackContext(name, namespace); +} +``` + +- `const { topics, services, actions } = capabilities;` —— **对象解构**(第⑪篇):从能力对象里一次拆出三个数组。 +- `if (三个 length 任一 > 0)` —— **只要发现到了任何能力**,就用 `buildDynamicContext` 拼**真实清单**。 +- 否则(三个都空,说明发现失败了,回忆上面降级返回的空能力)—— 用 `buildFallbackContext` 拼**硬编码的默认清单**。 +- 这就是降级的"分流口":**有真数据用真的,没有就用兜底的。** + +--- + +## 第 119-164 行:`buildDynamicContext`——把真实能力拼成 Markdown 文本 + +```typescript +function buildDynamicContext( + name: string, + topics: TopicInfo[], + services: ServiceInfo[], + actions: ActionInfo[], +): string { + let context = `## Robot: ${name}\n\n`; + context += `You are connected to a ROS2 robot named "${name}". You can control it using the ros2_* tools.\n\n`; +``` + +- 这个函数把能力清单拼成一段 **Markdown 格式的说明文字**(要喂给 AI 读)。 +- `let context = \`## Robot: ${name}\n\n\`;` —— 用 `let` 起一个字符串变量(要反复追加,所以 `let`)。 + - **语法小课堂:`\n` 是"换行符"。** 字符串里的 `\n` 代表按一次回车。`\n\n` 就是空一行(Markdown 里用空行分段)。`## Robot:` 是 Markdown 的二级标题写法。 +- `context += \`...\`;` —— **语法小课堂:`+=` 是"追加赋值"。** `context += x` 等于 `context = context + x`,即"把 x 接到 context 后面"。这里不断 `+=` 往说明里加内容,像往纸上一行行写字。 + +### 第 128-150 行:三段循环(话题/服务/动作各一段) + +```typescript + if (topics.length > 0) { + context += "### Available Topics\n"; + for (const t of topics) { + context += `- \`${t.name}\` (${t.type})\n`; + } + context += "\n"; + } + // services、actions 两段结构完全相同,略 +``` + +- 三段长得一样,看话题这段就懂: + - `if (topics.length > 0)` —— 有话题才写这段(没有就跳过整段,不留空标题)。 + - `context += "### Available Topics\n";` —— 加个小标题。 + - `for (const t of topics) { context += ... }` —— **`for...of` 遍历**(第⑥篇),每个话题追加一行:`` - `话题名` (类型) ``。 + - `` `- \`${t.name}\` (${t.type})\n` `` —— 注意里头的 `` \` `` 是**转义的反引号**:因为整个模板字符串用反引号包着,里面想输出字面的反引号(Markdown 代码块标记)就得写 `\``。输出效果是 `` - `/cmd_vel` (geometry_msgs/msg/Twist) ``。 + - 末尾 `context += "\n";` 再空一行分段。 +- 服务、动作两段一模一样,只换标题和变量。**重复三段而不抽函数**,是因为各段标题不同、且就三段,直接写更直白。 + +### 第 152-163 行:追加固定的安全限值 + 提示 + +```typescript + context += `### Safety Limits +- Maximum linear velocity: 1.0 m/s +- Maximum angular velocity: 1.5 rad/s +- All velocity commands are validated before execution + +### Tips +- Use \`ros2_list_topics\` to discover all available topics +... +- The user can say /estop at any time to immediately stop the robot`; + + return context; +} +``` + +- 最后追加两段**固定文本**:安全限值说明 + 使用提示(告诉 AI 有哪些好用的工具、用户能 `/estop` 急停)。 + - 注意这里用了**多行模板字符串**——反引号里直接换行,所见即所得(不用写 `\n`,直接敲回车)。这是模板字符串的便利。 + - 这段是写给 AI 的"行为引导":让它知道速度有上限、知道可以用 `ros2_list_topics` 等工具、知道急停的存在。 +- `return context;` —— 返回拼好的完整说明。 + +--- + +## 第 166-192 行:`buildFallbackContext`——发现失败时的硬编码清单 + +```typescript +function buildFallbackContext(name: string, namespace: string): string { + const prefix = namespace ? `${namespace}/` : "/"; + + return ` +## Robot: ${name} +... +### Available Topics +- \`${prefix}cmd_vel\` (geometry_msgs/msg/Twist) — Velocity commands +- \`${prefix}odom\` (nav_msgs/msg/Odometry) — Odometry data +... +`.trim(); +} +``` + +- 当发现失败(没连上等),用这份**写死的常见话题清单**顶上,免得 AI 完全没信息。 +- `const prefix = namespace ? \`${namespace}/\` : "/";` —— **三元运算符**(第⑥篇):有命名空间就用 `命名空间/` 当前缀,没有就用 `/`。这样兜底清单里的话题名也能带上正确前缀(如 `/turtlebot3/cmd_vel` 或 `/cmd_vel`)。 +- 中间是一大段写死的话题(cmd_vel、odom、scan、camera、battery_state——典型移动机器人都有的),格式和动态版一致。 +- 末尾 `.trim()` —— **语法小课堂:`字符串.trim()` 去掉首尾空白**(包括开头那个因为反引号换行产生的空行)。因为模板字符串从 `` ` `` 后直接换行会在开头多一个换行,`.trim()` 把它修掉,输出干净。 +- **降级的价值**:哪怕完全没连上机器人,AI 也能拿到一份"典型机器人长这样"的清单先用着,而不是一无所知。这比"失败就什么都不给"友好得多。 + +--- + +## 整章回顾 + +`robot-context.ts` 用 `before_agent_start` 钩子,在每次对话开始前把机器人能力清单注入 AI 的系统提示。三个核心机制: + +| 机制 | 怎么做 | 好处 | +|---|---|---| +| **注入** | 钩子返回 `{ prependContext: 文本 }` | AI 开场就懂这台机器人 | +| **缓存(TTL)** | `Date.now() - timestamp < 60s` 就复用旧结果 | 60 秒内不重复问,省时间;重连时清缓存 | +| **降级(fallback)** | 发现失败返回空能力 → 用硬编码默认清单 | 没连上也不让 AI 失明 | + +并用 `Promise.all` 把"列话题/服务/动作"三件事**并行**做,进一步省时间。 + +**语法点回顾清单**(本章新增/巩固): +- **`Promise.all([...])`:并行等待多个异步操作**,结果数组按序解构 `const [a,b,c] = ...`(重点) +- 带 TTL 的缓存:`Date.now()` 取时间戳、`现在 - timestamp < TTL` 判新鲜、模块级 `cache` 变量 +- `数组.filter(判断函数)`:筛选出判断为 true 的元素(对照第⑩篇 `.map`) +- 降级兜底(fallback):失败返回空/默认,分流口 `if (有数据) 用真的 else 用兜底` +- `catch {}`(不接错误对象,出错也无所谓)vs `catch (err)`(要用错误) +- 字符串拼装:`let s = ...; s += ...`、`\n` 换行、多行模板字符串、`\\\`` 转义反引号、`.trim()` +- `timestamp: 0` 让兜底结果天生"过期"、失败不写 `cache` 不污染缓存(设计细节) +- 三元 `namespace ? ... : "/"`(巩固)、`!namespace || name.startsWith(...)` 短路(巩固) + +下一份:[`commands/estop.ts` 逐行详解 →](24-commands-estop.ts.md)(从"钩子"转到"命令":用户直接打 `/estop` 立刻急停,绕过 AI) + + + +--- + + +# 逐行详解 ㉔:`commands/estop.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/commands/estop.ts](../../extensions/openclaw-plugin/src/commands/estop.ts) +> +> 推荐阅读顺序第 24 个文件。这是**第三种交互方式**登场——**命令(command)**。前面有"AI 主动调的工具"、"按时机自动触发的钩子",现在是"**用户直接打的命令**":在聊天里输入 `/estop`,**绕过 AI**,立刻执行。`/estop` 是急停——一键让机器人停下。它只有 40 行、几乎没有新语法,正好用来认识"命令"这个机制。 + +--- + +## 先理解"命令"和它为什么要绕过 AI + +回顾三种交互: + +| 方式 | 谁触发 | 例子 | +|---|---|---| +| 工具(14–21 篇) | **AI** 主动调用 | AI 听懂"前进"后调 `ros2_publish` | +| 钩子(22–23 篇) | **宿主**按时机自动触发 | 每次调工具前自动安全校验 | +| **命令(本篇)** | **用户**直接输入 `/xxx` | 用户打 `/estop` | + +**为什么急停要做成命令、而不是让 AI 处理?** 因为安全!设想机器人正失控,你打字"快停下",AI 要先理解、再决定调哪个工具、再执行——**这中间的延迟在紧急情况下不可接受**,而且 AI 万一理解错了更糟。命令是**直达**的:`/estop` 一进来,**不经过 AI**,代码立刻发停止指令。**确定、即时、可靠**——这正是急停需要的。 + +--- + +## 第 1-11 行:导入 + 注册函数开头 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import { getTransport } from "../service.js"; + +/** + * Register the /estop command. + * This command bypasses the AI agent and immediately sends a zero-velocity + * command to stop the robot. + */ +export function registerEstopCommand(api: OpenClawPluginApi, config: RosClawConfig): void { + const namespace = config.robot.namespace; +``` + +- 导入:宿主接口、配置(都 `import type`)、`getTransport`(值导入,要调用)。 +- 注释直接点明:「此命令**绕过 AI**,立即发一个零速度指令停住机器人。」 +- `registerEstopCommand(api, config)` —— 第⑬篇 `index.ts` 调过它。 +- `const namespace = config.robot.namespace;` —— 取命名空间(第23篇见过,给话题名加前缀用)。 + +--- + +## 第 13-16 行:注册命令 `api.registerCommand` + +```typescript + api.registerCommand({ + name: "estop", + description: "Emergency stop — immediately halt the robot (bypasses AI)", +``` + +**语法小课堂:`api.registerCommand({...})` —— 登记一个命令。** +- 和 `registerTool`(工具)、`api.on`(钩子)并列,这是注册"命令"的口子。传一个 `PluginCommand` 对象(回忆第①篇定义): + - `name: "estop"` —— 命令名。**用户打 `/estop`** 就触发它(前面那个 `/` 是命令前缀,由宿主约定)。 + - `description` —— 命令的说明(显示在帮助列表里,给人看)。 + - 还有个 `handler`(处理函数),下面就是。 +- 对比工具的 `description`(写给 AI):命令的 `description` 是**写给人**的——因为命令是人直接打的,AI 不参与。 + +--- + +## 第 17-19 行:handler 开头 + 取传输 + +```typescript + async handler(_ctx) { + try { + const transport = getTransport(); + const topic = namespace ? `${namespace}/cmd_vel` : "/cmd_vel"; +``` + +- `async handler(_ctx)` —— **命令的处理函数**,用户打 `/estop` 时宿主调它。 + - `_ctx` —— 命令上下文(谁发的、哪个频道等,回忆第①篇 `CommandContext`)。急停不关心这些,加 `_` 前缀(第⑫篇)。 +- `try {` —— 整个操作包在 `try/catch` 里。**为什么急停尤其要 try/catch?** 因为急停**最不能假定一切正常**——可能正好没连上、传输坏了。必须把失败也处理好、给用户明确反馈,而不是默默崩掉(下面 catch 会讲)。 +- `const transport = getTransport();` —— 拿传输(没连上会抛错,进 catch)。 +- `const topic = namespace ? \`${namespace}/cmd_vel\` : "/cmd_vel";` —— 三元运算(第⑥篇)拼出速度话题名:有命名空间就 `/cmd_vel`,没有就 `/cmd_vel`。和第23篇 `prefix` 同款思路。 + +--- + +## 第 22-30 行:发送"零速度"指令(急停的核心) + +```typescript + // Send zero velocity + transport.publish({ + topic, + type: "geometry_msgs/msg/Twist", + msg: { + linear: { x: 0, y: 0, z: 0 }, + angular: { x: 0, y: 0, z: 0 }, + }, + }); +``` + +- **急停的本质就是:发一条"所有速度都是 0"的指令。** 机器人收到"速度归零"就停下。 +- `transport.publish({...})` —— 调发布(第④/⑩篇),和第⑮篇发布工具用的是同一个方法,只是这里参数写死: + - `topic` —— 刚拼的速度话题(对象简写)。 + - `type: "geometry_msgs/msg/Twist"` —— 速度指令的标准类型(第22篇见过 Twist)。 + - `msg: { linear: {x:0,y:0,z:0}, angular: {x:0,y:0,z:0} }` —— **全零的速度**: + - `linear`(前后左右上下)三个方向都 0、`angular`(转向)三个轴都 0。 + - 这是个**嵌套对象**(对象里套对象),逐层看就清楚:`msg` 里有 `linear` 和 `angular`,各自又是 `{x,y,z}`。 + - 对照第22篇安全钩子:那里算速度大小、超限就拦;这里直接把速度全设 0——**两者都围绕 Twist,一个管"别太快"、一个管"立刻停"**。 +- 注意:发布是"发完即忘"(第⑦篇),**不等回应**。急停要的就是这种"立刻发出、不墨迹"。 + +--- + +## 第 32-33 行:成功反馈 + +```typescript + api.logger.warn("ESTOP: Zero velocity command sent"); + return { text: "Emergency stop activated. Robot halted." }; +``` + +- `api.logger.warn(...)` —— 记一条**警告级**日志(急停是大事,用 `warn` 而非 `info`,方便日志里醒目)。 +- `return { text: "..." };` —— **命令处理函数的返回值是 `{ text: 字符串 }`**(回忆第①篇 `CommandResult`)。 + - **语法小课堂:命令返回 `{ text }` 的含义。** 这段文字会**作为回复发回给用户**(在聊天里显示)。所以这里返回"紧急停止已激活,机器人已停止"——让用户看到确认。 + - 对比:工具返回 `{ content, details }`(给 AI 读)、钩子返回 `{ block }`/`{ prependContext }`(影响宿主)。**命令返回 `{ text }`(直接回给用户看)**——因为命令是人机直接对话,回的就是人话。 + +--- + +## 第 34-39 行:失败反馈(catch) + +```typescript + } catch (error) { + api.logger.error(`ESTOP FAILED: ${String(error)}`); + return { text: "Emergency stop failed — transport may be disconnected!" }; + } + }, + }); +} +``` + +- 如果上面任何一步失败(最可能是没连上): + - `api.logger.error(...)` —— 记一条**错误级**日志(`error` 比 `warn` 还高一级,回忆第①篇 logger 的级别)。急停失败是最严重的情况。 + - **语法小课堂:`String(error)`** —— 把错误对象**显式转成字符串**。回忆第23篇模板字符串里 `${err}` 会自动转字;这里用 `String(error)` 显式转,效果类似、更明确。 + - `return { text: "..." };` —— 给用户一句**明确的失败告警**:"急停失败——传输可能断开了!" +- **这个 catch 的设计很重要**:急停失败**绝不能静默**。必须大声告诉用户"没停成、可能断连了",让用户赶紧用别的办法(物理急停按钮等)。**安全相关的失败,宁可吵闹也不能沉默。** +- 收尾 `},` 关 handler、`});` 关 registerCommand、`}` 关注册函数。 + +--- + +## 整章回顾 + +- 本篇引入第三种交互——**命令**:用 `api.registerCommand({ name, description, handler })` 登记,用户打 `/estop` 直接触发 handler,**绕过 AI**。 +- `/estop` 的实现极简:**发一条全零速度的 Twist 指令**让机器人立刻停。 +- 命令返回 `{ text }`,直接作为回复显示给用户(对比工具的 `content`、钩子的 `block`/`prependContext`)。 +- 全程 `try/catch` + 分级日志(成功 `warn`、失败 `error`):**急停这种安全操作,成功要确认、失败要响亮告警,绝不静默。** + +**三种交互方式对照(到此集齐):** + +| | 触发者 | 注册口 | 处理函数返回 | 返回给谁 | +|---|---|---|---|---| +| 工具 | AI | `registerTool` | `{ content, details }` | AI 读 | +| 钩子 | 宿主(按时机) | `api.on` | `{ block }` / `{ prependContext }` | 影响宿主 | +| 命令 | 用户(打 `/xxx`) | `registerCommand` | `{ text }` | 用户看 | + +**语法点回顾清单**(本章新增/巩固): +- `api.registerCommand({ name, description, handler })`:登记命令;`description` 写给人(vs 工具写给 AI) +- 命令 `handler(ctx)` 返回 `{ text }`:作为回复直接显示给用户 +- 急停 = 发全零速度 Twist;嵌套对象 `{ linear:{x,y,z}, angular:{x,y,z} }` +- `String(error)` 显式转字符串(vs 模板串自动转) +- 日志分级 `info`/`warn`/`error`(安全操作用高级别)(巩固第①篇) +- 安全操作的 `try/catch`:成功确认、失败响亮告警、绝不静默 + +下一份:[`commands/transport.ts` 逐行详解 →](25-commands-transport.ts.md)(`/transport` 切换传输——带参数解析、`as const`、类型守卫、配置覆盖,是命令里最丰富的一个) + + + +--- + + +# 逐行详解 ㉕:`commands/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/commands/transport.ts](../../extensions/openclaw-plugin/src/commands/transport.ts) +> +> 推荐阅读顺序第 25 个文件,**第六部分的收尾、也是命令里最丰富的一个**(171 行)。`/transport` 让用户在聊天里查看或切换传输模式(如 `/transport webrtc`、`/transport rosbridge ws://...`)。它把第⑫篇的 `switchTransport` 接到用户手里,并第一次综合演示**命令行参数解析**、**`as const` 与类型守卫**、**配置覆盖(override)+ 类型转换**等几个进阶技巧。本篇略长,但都是前面学过的东西的组合,我们分块吃。 + +--- + +## 先把这个命令的用法搞清楚 + +`/transport` 有几种用法: +- `/transport` —— 不带参数:**显示当前**传输模式和状态。 +- `/transport webrtc` —— 切换到 webrtc 模式(用配置里的默认连接参数)。 +- `/transport rosbridge ws://192.168.1.5:9090` —— 切到 rosbridge,并**覆盖**连接地址。 +- `/transport webrtc robotId=robot42` —— 切到 webrtc,并用 `key=value` 形式覆盖某个配置项。 + +所以这个命令要做三件事:**①解析用户输入的参数 ②校验模式合法 ③按参数拼出配置、调 `switchTransport` 切换**。文件里那一堆辅助函数就是分别干这些的。 + +--- + +## 第 1-5 行:导入 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import type { TransportConfig } from "../transport/types.js"; +import { getTransport, getTransportMode, switchTransport } from "../service.js"; +import { clearDiscoveryCache } from "../context/robot-context.js"; +``` + +- 三个 `import type`:宿主接口、整份配置、传输配置(第③篇判别联合)。 +- 值导入: + - `getTransport`、`getTransportMode`、`switchTransport` —— 第⑫篇 service 的三个函数(查传输、查模式、切换)。 + - `clearDiscoveryCache` —— 第23篇那个清能力缓存的函数。**为什么这里要它?** 因为切换传输后,机器人能力清单可能变了,得作废旧缓存,让下次对话重新发现。下面会用到。 + +--- + +## 第 7-12 行:合法模式列表 + 类型守卫(本篇第一个新点) + +```typescript +const VALID_MODES = ["rosbridge", "local", "webrtc"] as const; +type Mode = (typeof VALID_MODES)[number]; + +function isValidMode(value: string): value is Mode { + return (VALID_MODES as readonly string[]).includes(value); +} +``` + +这三行信息量很大,逐个拆。 + +### `as const` + +```typescript +const VALID_MODES = ["rosbridge", "local", "webrtc"] as const; +``` +- **语法小课堂:`as const` —— "把值锁成最具体的字面量类型"。** + - 普通写 `const VALID_MODES = ["rosbridge", "local", "webrtc"];`,TS 推断它的类型是 `string[]`(一个普通字符串数组)——只知道"里面是字符串",不记得具体是哪三个。 + - 加 `as const` 后,TS 把它**锁死**成"只读的、由这三个确切字面量组成的元组":`readonly ["rosbridge", "local", "webrtc"]`。即 TS **记住了这个数组就是这仨值、且不可改**。 + - 好处:下面能从它**反推出精确的类型**(而不是泛泛的 string)。 + +### 从数组反推类型 `(typeof X)[number]` + +```typescript +type Mode = (typeof VALID_MODES)[number]; +``` +- **语法小课堂:`(typeof 数组)[number]` —— 从数组值里抠出"元素类型"。** 拆成两步: + - `typeof VALID_MODES` —— 取这个值的类型(回忆第②篇 `typeof 值`:值→类型)。因为有 `as const`,得到的是 `readonly ["rosbridge","local","webrtc"]`。 + - `[number]` —— **用"数字索引"去取它的元素类型**(回忆第⑫篇索引访问类型 `T["mode"]`,这是它的数组版:用 `[number]` 表示"任意下标处的元素")。 + - 结果:`Mode` = `"rosbridge" | "local" | "webrtc"`(三个字面量的联合)。 +- **这套组合拳的价值**:模式列表只写一次(`VALID_MODES`),类型 `Mode` 自动跟着它来。以后改列表,类型自动同步——又是"别重复自己"(回忆第⑫篇同样的精神)。 + +### 类型守卫 `value is Mode` + +```typescript +function isValidMode(value: string): value is Mode { + return (VALID_MODES as readonly string[]).includes(value); +} +``` +- **语法小课堂:返回类型 `value is Mode` 是"类型守卫(type guard)"。** + - 普通函数返回 `boolean`。这里返回 `value is Mode`——它仍是返回 true/false,但**额外告诉 TS:当这函数返回 true 时,传进去的 `value` 就是 `Mode` 类型**。 + - 作用:调用处 `if (isValidMode(x))` 成立的分支里,TS 会把 `x` 从 `string` **收窄成 `Mode`**。这样后面就能安全地把 `x` 当作三选一的模式用。 + - 这是"用一个运行时检查,同时给 TS 喂类型信息"的标准手法。 +- 函数体 `(VALID_MODES as readonly string[]).includes(value)`: + - **语法小课堂:`数组.includes(x)` —— 判断数组里有没有 x**,返回 true/false。这里看 `value` 是不是那三个合法模式之一。 + - `VALID_MODES as readonly string[]` —— 一个小技术细节:`VALID_MODES` 因为 `as const` 是"只含那仨字面量的元组",直接 `.includes(任意字符串)` 会被 TS 嫌弃("这字符串不可能是那仨之一")。先 `as readonly string[]` 把它当普通只读字符串数组看,`.includes` 才能接受任意字符串来查。 + +> 这三行合起来:**定义一份"唯一可信的模式列表",从它派生出类型 `Mode`,再用类型守卫把"运行时检查"和"类型收窄"绑在一起。** 是 TS 里处理"一组合法字符串值"的经典范式。 + +--- + +## 第 14-60 行:注册命令 + handler 主流程 + +### 第 17-23 行:注册 + 取参数 + +```typescript +export function registerTransportCommand(api: OpenClawPluginApi, config: RosClawConfig): void { + api.registerCommand({ + name: "transport", + description: "Show or switch the ROS2 transport mode (rosbridge, webrtc, local)", + + async handler(ctx) { + const args = (ctx.args ?? "").trim(); +``` + +- `registerCommand` 同第24篇。命令名 `transport`、说明写给人。 +- `async handler(ctx)` —— **这次用到了 `ctx`**(不像 estop 加 `_`),因为要读用户输入的参数。 +- `const args = (ctx.args ?? "").trim();` —— 取用户在 `/transport` 后面打的参数串: + - `ctx.args` —— 命令的参数部分(回忆第①篇 `CommandContext` 有 `args?`)。可能没有(`undefined`)。 + - `?? ""` —— 没有就当空字符串(第⑥篇空值合并)。 + - `.trim()` —— 去掉首尾空格(第23篇见过)。比如用户打 `/transport webrtc ` → `args` 变成干净的 `"webrtc"`。 + +### 第 25-28 行:无参数 → 显示状态 + +```typescript + // No args → show current status + if (!args) { + return showStatus(); + } +``` + +- `if (!args)` —— 真值判断:参数是空字符串(用户只打了 `/transport`)就 `!args` 为 true。 +- `return showStatus();` —— 调辅助函数显示当前状态(下面讲),把它的返回值直接返回。 + +### 第 30-38 行:解析模式 + 校验 + +```typescript + // Parse mode from first arg + const parts = args.split(/\s+/); + const modeArg = parts[0]; + + if (!isValidMode(modeArg)) { + return { + text: `Unknown transport mode: "${modeArg}". Valid modes: ${VALID_MODES.join(", ")}`, + }; + } +``` + +- `const parts = args.split(/\s+/);` —— **把参数串按空白切成数组**。 + - **语法小课堂:`字符串.split(分隔符)` —— 按分隔符拆成数组。** 比如 `"webrtc robotId=x".split(...)` → `["webrtc", "robotId=x"]`。 + - **语法小课堂:`/\s+/` 是"正则表达式"。** 两个 `/` 之间是一个**正则**(匹配文本的模式)。`\s` 表示"空白字符"(空格、Tab 等),`+` 表示"一个或多个"。所以 `/\s+/` 匹配"一段连续空白"——用它当分隔符,多个空格也能正确切开。(正则是个大主题,这里只需知道 `/\s+/` = 按空白分割。) +- `const modeArg = parts[0];` —— 第一段就是模式名(数组下标 0,第⑥篇)。 +- `if (!isValidMode(modeArg))` —— **用上面那个类型守卫校验**。不合法就: + - 返回 `{ text: ... }` 报错,列出合法模式。 + - `VALID_MODES.join(", ")` —— **语法小课堂:`数组.join(分隔符)` —— 把数组拼成字符串**(`.split` 的逆操作)。`["rosbridge","local","webrtc"].join(", ")` → `"rosbridge, local, webrtc"`。用来友好地列出选项。 +- **关键**:通过这个 `if` 之后,因为 `isValidMode` 是类型守卫,**TS 已经把 `modeArg` 收窄成 `Mode` 类型**了——后面就能放心把它当三选一用。 + +### 第 40-46 行:拼配置(可能抛错) + +```typescript + // Build TransportConfig from base config + overrides + let transportConfig: TransportConfig; + try { + transportConfig = buildTransportConfig(modeArg, parts.slice(1), config); + } catch (err) { + return { text: `Invalid arguments: ${String(err)}` }; + } +``` + +- `let transportConfig: TransportConfig;` —— 先声明后赋值(第⑫篇)。 +- `buildTransportConfig(modeArg, parts.slice(1), config)` —— 调辅助函数拼出完整传输配置: + - `modeArg` —— 模式(已是 `Mode` 类型)。 + - `parts.slice(1)` —— **`.slice(1)` 取数组从下标 1 到末尾**(回忆第⑩篇 `.slice`,这是数组版)。即"模式名之后的所有参数",也就是那些覆盖项。 + - `config` —— 基础配置。 +- 包在 `try/catch` 里:拼配置时若参数非法(如未知配置键),`buildTransportConfig` 会 `throw`,这里接住、返回友好错误。 + +### 第 48-57 行:执行切换 + +```typescript + // Perform the switch + try { + await switchTransport(transportConfig, api.logger); + clearDiscoveryCache(); + return { text: formatSwitchSuccess(transportConfig) }; + } catch (err) { + return { + text: `Failed to switch transport: ${String(err)}\nYou can retry with /transport ${modeArg}`, + }; + } + }, + }); +} +``` + +- `await switchTransport(transportConfig, api.logger);` —— **调第⑫篇那个热切换函数**(断旧连新、带并发门闩)。命令到这里才真正动手切换。 +- `clearDiscoveryCache();` —— 切换成功后清空能力缓存(第23篇),因为换了传输、能力可能变,强制下次重新发现。 +- `return { text: formatSwitchSuccess(transportConfig) };` —— 返回成功消息(调辅助函数格式化,下面讲)。 +- `catch (err)` —— 切换失败(连不上等):返回友好错误,**还贴心地提示"可以用 `/transport <模式>` 重试"**(回忆第⑫篇 `switchTransport` 注释说"失败不回滚、用户重试"——这里就是那句"重试"的落地)。 +- 注意 `\n` 让错误信息换行(第23篇)。 + +--- + +## 第 62-76 行:`showStatus`——显示当前状态 + +```typescript +function showStatus(): { text: string } { + const mode = getTransportMode(); + + if (!mode) { + return { text: "Transport: not active" }; + } + + try { + const transport = getTransport(); + const status = transport.getStatus(); + return { text: `Transport: ${mode} (${status})` }; + } catch { + return { text: `Transport: ${mode} (unknown status)` }; + } +} +``` + +- 返回类型写成 `{ text: string }`(和命令结果同形)。 +- `const mode = getTransportMode();` —— 查当前模式(第⑫篇,可能 `null`)。 +- `if (!mode)` —— 没有活动传输,回"not active"。 +- 否则 `try` 里查具体连接状态:`getStatus()`(第④/⑩篇),拼成 `Transport: webrtc (connected)` 这样。 +- `catch {}` —— **空 catch**(第23篇):万一查状态出错也无所谓,退回显示 `(unknown status)`。**查状态是只读小事,失败就给个模糊答案,不值得报错。** + +--- + +## 第 78-136 行:`buildTransportConfig`——按模式拼配置 + 应用覆盖 + +这是最长的辅助函数,但骨架是第⑫篇见过的 `switch (mode)`。 + +### 签名 + +```typescript +function buildTransportConfig( + mode: Mode, + overrides: string[], + config: RosClawConfig, +): TransportConfig { + switch (mode) { +``` +- 吃模式、覆盖参数数组、基础配置,返回拼好的 `TransportConfig`。 +- `switch (mode)` —— 按三种模式分别处理。因为 `mode` 是 `Mode`(三选一),三个 case 穷尽,**不用 default**(第⑫篇讲过的"穷尽即可省 default")。 + +### rosbridge 分支(第 87-104 行,最完整) + +```typescript + case "rosbridge": { + const base = { ...config.rosbridge }; + + for (const arg of overrides) { + if (arg.startsWith("ws://") || arg.startsWith("wss://")) { + // Positional URL override + base.url = arg; + } else if (arg.includes("=")) { + const [key, ...rest] = arg.split("="); + const value = rest.join("="); + applyOverride(base, key, value); + } else { + throw new Error(`Unexpected argument: "${arg}"`); + } + } + + return { mode: "rosbridge", rosbridge: base }; + } +``` + +- `const base = { ...config.rosbridge };` —— **语法小课堂:`{ ...对象 }` 是"展开(spread)"——浅拷贝一个对象。** + - `...config.rosbridge` 把原配置的所有字段"摊开"复制进一个新对象 `base`。 + - **为什么要拷贝、不直接改 `config.rosbridge`?** 因为我们要往 `base` 上应用用户的覆盖项,但**不想污染原始配置**(原配置后面可能还要用)。拷一份改副本,是安全的做法。(回忆第⑫篇"先存局部变量再提交"的同款谨慎。) +- `for (const arg of overrides)` —— 遍历每个覆盖参数(第⑥篇 `for...of`)。对每个 `arg` 三种情况: + 1. `if (arg.startsWith("ws://") || arg.startsWith("wss://"))` —— 是个 WebSocket 地址(以 `ws://`/`wss://` 开头,第⑥篇 `.startsWith`)→ **位置式覆盖**:直接当作 url,`base.url = arg`。这让用户能简写 `/transport rosbridge ws://...` 而不必写 `url=ws://...`。 + 2. `else if (arg.includes("="))` —— 含等号 → **key=value 式覆盖**: + - **语法小课堂:`字符串.includes(x)` 判断是否包含子串**(和数组的 `.includes` 同名、用在字符串上)。 + - `const [key, ...rest] = arg.split("=");` —— 按 `=` 切开,**用数组解构 + 剩余元素 `...rest`**: + - **语法小课堂:解构里的 `...rest`(剩余元素)。** `[key, ...rest]` 把第一段给 `key`,**其余所有段收进数组 `rest`**。 + - **为什么要 `...rest` 而不是 `[key, value]`?** 因为值里可能**也含 `=`**(如 `token=ab=cd`)。`split("=")` 会切成 `["token","ab","cd"]`;`key="token"`、`rest=["ab","cd"]`。 + - `const value = rest.join("=");` —— 再用 `=` 把 `rest` 拼回去(`"ab=cd"`)。**先按 = 切、第一段当键、剩下的用 = 拼回当值**——这样值里的 `=` 不会丢。是处理"键=值,值里可能有等号"的标准技巧。 + - `applyOverride(base, key, value);` —— 调下个函数应用这条覆盖(带类型转换)。 + 3. `else { throw new Error(...) }` —— 既不是地址也不是 key=value → 非法参数,抛错(被上面 handler 的 try 接住)。 +- 最后 `return { mode: "rosbridge", rosbridge: base };` —— 拼成判别联合的 rosbridge 成员(第③篇)返回。 + +### webrtc / local 分支(第 106-134 行) + +- 这两个分支结构相同、比 rosbridge 简单:**只接受 key=value 形式**(没有"位置式 url"那种特例),不是 key=value 就抛错。 +- 各自 `{ ...config.webrtc }` / `{ ...config.local }` 拷贝、遍历应用覆盖、返回对应判别联合成员。 +- 重复三段而非强行合并——因为每个模式的特例不同(rosbridge 有 url 简写),分开写更清楚。 + +--- + +## 第 138-158 行:`applyOverride`——把字符串值转成正确类型(很妙) + +```typescript +function applyOverride(obj: Record, key: string, value: string): void { + if (!(key in obj)) { + throw new Error(`Unknown config key: "${key}"`); + } + + const existing = obj[key]; + + // Coerce value to match existing type + if (typeof existing === "number") { + const num = Number(value); + if (Number.isNaN(num)) { + throw new Error(`"${key}" expects a number, got "${value}"`); + } + obj[key] = num; + } else if (typeof existing === "boolean") { + obj[key] = value === "true"; + } else { + obj[key] = value; + } +} +``` + +这个函数解决一个实际问题:**用户输入的永远是字符串**(命令行就是文本),但配置项可能要数字或布尔。得"按原值的类型,把字符串转过去"。 + +- `if (!(key in obj))` —— **语法小课堂:`键 in 对象` 判断对象有没有这个键。** 没有这个配置项就抛错"未知配置键"——防止用户瞎写。 + - `!(key in obj)` 整体加括号取反:"不存在这个键"。 +- `const existing = obj[key];` —— 取出该键**现有的值**,用它的类型当"目标类型"。 +- 按 `existing` 的类型分三种转换(`typeof`,回忆第②⑥篇): + - **是数字**:`const num = Number(value);` —— **`Number("3.5")` 把字符串转成数字**。 + - `if (Number.isNaN(num))` —— **语法小课堂:`Number.isNaN(x)` 判断是不是 NaN(Not a Number,非数字)。** 如果用户填了 `"abc"`,`Number("abc")` 得到 `NaN`,这里就抛错"期望数字"。`NaN` 是"转换失败"的标志。 + - 转换成功就 `obj[key] = num`。 + - **是布尔**:`obj[key] = value === "true";` —— 把字符串 `"true"` 转成布尔 `true`、其余转成 `false`。简单直接。 + - **否则(字符串等)**:`obj[key] = value;` —— 原样存字符串。 +- **这个函数的智慧**:以现有配置值的类型为准,把用户输入的字符串"强制对齐"成对的类型,并在转不动时报错。这样 `/transport rosbridge timeout=5000` 里的 `"5000"` 会正确变成数字 5000,而不是字符串。 + +--- + +## 第 160-171 行:`formatSwitchSuccess`——拼成功消息 + +```typescript +function formatSwitchSuccess(config: TransportConfig): string { + switch (config.mode) { + case "rosbridge": + return `Switched to rosbridge transport (${config.rosbridge.url})`; + case "webrtc": { + const robotId = config.webrtc.robotId; + return `Switched to webrtc transport (robotId: ${robotId})`; + } + case "local": + return `Switched to local transport (domainId: ${config.local?.domainId ?? 0})`; + } +} +``` + +- 按模式拼一句不同的成功提示(`switch` + 判别联合类型收窄,第③⑪篇——每个 case 里能安全访问对应字段)。 +- `config.local?.domainId ?? 0` —— 可选链 + 空值合并(第⑥篇):安全取 `domainId`,没有就显示 0。 +- webrtc 那个 case 套了 `{ }` 块(因为里面用 `const` 声明了 `robotId`,回忆第⑪篇"case 里用 const 要套花括号");另两个 case 直接 return,不需要。 + +--- + +## 整章回顾 + +`/transport` 是命令里最丰富的一个,把"解析→校验→拼配置→切换"串成完整流程: + +| 步骤 | 谁做 | 关键技巧 | +|---|---|---| +| 取参数 | handler | `ctx.args ?? ""`、`.trim()`、`.split(/\s+/)` | +| 校验模式 | `isValidMode` | `as const` + 派生类型 `Mode` + 类型守卫收窄 | +| 拼配置 | `buildTransportConfig` | `{...spread}` 拷贝、`for...of` 应用覆盖、`[key,...rest]` 解析 key=value | +| 类型转换 | `applyOverride` | 按现有值的 `typeof` 把字符串转 number/boolean,`Number.isNaN` 校验 | +| 切换 | handler | `await switchTransport`(第⑫篇)+ `clearDiscoveryCache`(第23篇)+ 失败提示重试 | + +无参数时则走 `showStatus` 显示当前模式与状态。 + +**语法点回顾清单**(本章新增/巩固): +- **`as const`**:把数组/值锁成只读字面量类型(让类型可从值派生) +- **从数组派生类型 `(typeof 数组)[number]`**(元素联合类型) +- **类型守卫 `function f(x): x is T`**:运行时检查 + 让 TS 收窄类型 +- `数组.includes` / `字符串.includes`(是否包含)、`数组.join` / `字符串.split`(拼/拆)、`.slice(1)`(数组切片) +- 正则 `/\s+/`(匹配连续空白,用作分隔符)—— 仅需知其义 +- 解构剩余元素 `const [key, ...rest] = ...`(处理"值里也含 = ") +- `{ ...对象 }` 展开浅拷贝(改副本不污染原配置) +- `键 in 对象`(判断有无该键)、`typeof` 分支转换、`Number(value)` 转数字、`Number.isNaN` 校验、`value === "true"` 转布尔 +- `switch` + 判别联合收窄、case 用 `const` 套 `{}`(巩固第③⑪篇) + +--- + +## 🎉 第六部分(钩子与命令)讲完! + +到这里,**插件的三种交互方式全部讲透**: + +> **工具**(AI 调,14–21)+ **钩子**(按时机自动触发,22–23)+ **命令**(用户直接打,24–25)。 + +加上前面的骨架(1–13),**整个 `@rosclaw/openclaw-plugin` 插件——以 rosbridge 为传输——你已经从头到尾、逐行读完了!** 这是项目的主体和精华。 + +接下来**第七部分**转向**另外两种传输模式**(模式 A 本地 DDS、模式 C WebRTC)——它们和 rosbridge 实现的是同一套 `RosTransport` 接口(第④篇),所以你已经懂它们"该长什么样",读起来会有"换汤不换药"的熟悉感,同时会接触一些新东西(如 `rclnodejs`、WebRTC 信令)。 + +下一份:[`transport/local/transport.ts` 逐行详解 →](26-local-transport.ts.md)(模式 A:在同一台机器上直接用 DDS,不经 rosbridge——本系列最后会补的 `createRequire`/CommonJS 互操作在此登场) + + + +--- + + +# 逐行详解 ㉖:`transport/local/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/transport.ts](../../extensions/openclaw-plugin/src/transport/local/transport.ts) +> +> 推荐阅读顺序第 26 个文件,**第七部分开篇、也是目前最长的源文件(375 行)**。它是**模式 A** 的传输:当 OpenClaw **直接跑在机器人上**时,不必绕 rosbridge 网络,直接用本地 DDS 总线和 ROS2 对话。它和第⑩篇 `RosbridgeTransport` 实现的是**同一个 `RosTransport` 接口**(第④篇),所以你已经懂它"该有哪些方法"——这篇会有"换汤不换药"的熟悉感。新东西是底层换成了 Node 库 **`rclnodejs`**,由此引出本系列最后一个预告的大语法:**`createRequire`(CommonJS 互操作)**。本篇较长,我们按"接口的几大块"分段,节奏会比前面快——因为方法的"意图"你已在第④⑩篇熟知,重点只看"本地 DDS 怎么实现它"。 + +--- + +## 先理解模式 A 和 rclnodejs + +- **rosbridge 模式(B)**:OpenClaw 在一台电脑、机器人在另一处,中间靠 WebSocket + rosbridge_server 转发(第⑤–⑩篇那一大套)。 +- **本地模式(A)**:OpenClaw **就装在机器人身上**,和 ROS2 节点在同一台机器、同一条 DDS 总线上。这时根本不需要网络中转——直接用 ROS2 的原生通信即可。 +- **`rclnodejs`** 是"ROS2 的 Node.js 客户端库"——它让 JavaScript 能直接创建 ROS2 节点、发布/订阅、调服务。模式 A 就靠它。 +- **代价**:`rclnodejs` 是个**可选的、重型依赖**(需要 ROS2 环境,回忆第⑪篇工厂里那段"local 模式可能没装 rclnodejs"的错误处理)。所以本篇大量地方把它当 `any` 处理、动态加载——下面会看到。 + +--- + +## 第 1-21 行:导入 + `createRequire`(最后的大语法) + +```typescript +import { createRequire } from "node:module"; +import type { RosTransport } from "../transport.js"; +import type { + ConnectionStatus, ConnectionHandler, Subscription, PublishOptions, + SubscribeOptions, ServiceCallOptions, ServiceCallResult, ActionGoalOptions, + ActionResult, TopicInfo, ServiceInfo, ActionInfo, MessageHandler, +} from "../types.js"; +import { EntityCache } from "./entities.js"; +import { toRosMessage, fromRosMessage, loadMessageClass, clearTypeCache } from "./conversion.js"; + +const require = createRequire(import.meta.url); +``` + +- 第 2-17 行:导入 `RosTransport` 接口和一堆类型(和第⑩篇适配器几乎一样的清单),以及两个**同目录帮手文件**: + - `EntityCache`(来自 `./entities.js`,第28篇讲)—— 缓存发布器/订阅器/服务客户端。 + - `toRosMessage`/`fromRosMessage`/`loadMessageClass`/`clearTypeCache`(来自 `./conversion.js`,第27篇讲)—— 在"普通 JS 对象"和"rclnodejs 类型化消息"之间转换。 + - **这两个帮手是本篇的左膀右臂**,这里先记住它们的用途,调用到时点一下,细节留给 27、28 篇。 +- **第 1 行 + 第 21 行是本系列最后一个预告的大语法:** + +```typescript +import { createRequire } from "node:module"; +// ... +const require = createRequire(import.meta.url); +``` +- **语法小课堂:`createRequire` 与 ESM / CommonJS 互操作。** + - 回忆 CLAUDE.md:本项目是**纯 ESM**(用 `import`/`export`)。而 `rclnodejs` 是个老式的 **CommonJS(CJS)** 库——CJS 用的是另一套加载机制:`require("xxx")`(一个函数)。 + - 问题:ESM 文件里**默认没有 `require` 这个函数**(它是 CJS 的东西)。但我们偏偏要加载一个 CJS 库。 + - `createRequire(import.meta.url)` 就是 Node 提供的"桥":它**造出一个能用的 `require` 函数**,让你在 ESM 文件里也能 `require("rclnodejs")` 加载 CJS 模块。 + - `import.meta.url` —— ESM 里表示"当前文件的地址",`createRequire` 需要它来确定"从哪个位置去找模块"。 + - 造好后赋给 `const require = ...`,下面就能像 CJS 那样 `require("rclnodejs")` 了。 + - 一句话:**`createRequire` 是"在新式 ESM 代码里,借用旧式 CJS 的 `require` 加载机制"的官方手段。** 专门用于加载那些还没 ESM 化的老库。 + +> 至此,README 里"后面会补的大语法"全部讲完——`Promise.all`(第23篇)、`createRequire`(本篇)都已登场。 + +--- + +## 第 23-36 行:选项类型 + 内部过滤名单 + +```typescript +export interface LocalTransportOptions { + domainId?: number; +} + +/** Internal ROS2 topics/services to filter from introspection results. */ +const INTERNAL_TOPIC_PREFIXES = ["/rosout", "/parameter_events", "/rosclaw/"]; +const INTERNAL_SERVICE_SUFFIXES = [ + "/describe_parameters", "/get_parameter_types", "/get_parameters", + "/list_parameters", "/set_parameters", "/set_parameters_atomically", +]; +``` + +- `LocalTransportOptions` —— 本地传输的选项,就一个可选 `domainId`。 + - **DDS 的 domainId(域 ID)**:ROS2 用它把不同的机器人/系统隔开——同一个 domainId 的节点才能互相通信。默认 0。 +- 两个常量数组:**自省时要过滤掉的"内部话题/服务"**。 + - ROS2 自己有一堆系统话题(`/rosout` 日志、`/parameter_events` 参数事件)和每个节点自带的参数服务(`/get_parameters` 等)。列话题/服务时这些是"噪音",AI 不关心,所以建两份名单把它们滤掉。下面 `listTopics`/`listServices` 会用到。 + +--- + +## 第 44-59 行:类实现接口 + 一堆 `any` 字段 + 静态门闩 + +```typescript +export class LocalTransport implements RosTransport { + private domainId: number; + private status: ConnectionStatus = "disconnected"; + private connectionHandlers = new Set(); + /** rclnodejs module — loaded dynamically at runtime (optional dep, no types). */ + private rclnodejs: any = null; + private node: any = null; + private entityCache: EntityCache | null = null; + private activeGoals = new Map(); + + /** Singleton guard — rclnodejs.init() must only be called once per process. */ + private static rclInitialized = false; + + constructor(options?: LocalTransportOptions) { + this.domainId = options?.domainId ?? 0; + } +``` + +- `implements RosTransport` —— 和第⑩篇一样,**承诺实现那 13 个方法**(第④/⑩篇 `implements`)。下面方法的"意图"与第⑩篇一一对应。 +- 字段里多处 `any`(回忆第⑪篇 `any` 是"放弃类型检查"):`rclnodejs`、`node`、`activeGoals` 的值都是 `any`。 + - **为什么这么多 `any`?** 注释说了:`rclnodejs` 是可选依赖、**可能没有类型定义**。没类型可标,就用 `any` 让 TS 别管。这是"和无类型的老库打交道"的现实妥协——代价是这部分失去类型保护,得自己小心。 +- `private status: ConnectionStatus = "disconnected";` —— 当前连接状态,初始"断开"。 +- `connectionHandlers = new Set();` —— 存连接状态回调的集合(`Set`,第⑥篇)。 + - **注意:和第⑩篇不同,这里自己管理回调集合**。第⑩篇是把 `onConnection` 转发给底层 client;本地模式没有那样的 client,得自己存回调、状态变了自己挨个通知(见下面 `setStatus`)。 +- `entityCache: EntityCache | null` —— 那个实体缓存(第28篇),连接后才建。 +- `activeGoals = new Map()` —— 记录"正在进行的动作目标",键是动作名、值是目标句柄(用于取消)。 +- `private static rclInitialized = false;` —— **语法小课堂:`static`(静态成员)。** + - 普通字段(如 `this.status`)是**每个实例各一份**。加 `static` 的字段是**整个类共享一份**(挂在类上,不在实例上),所有实例看到同一个。 + - 这里为什么要 static?注释:`rclnodejs.init()` **每个进程只能调一次**(它是全局初始化)。如果建了两个 `LocalTransport` 实例,不能各初始化一次。用 `static rclInitialized` 这个**类级别的门闩**,保证"全进程只初始化一次"(回忆第⑫篇并发门闩,这是它的 static 版)。 + - 访问时写 `LocalTransport.rclInitialized`(用类名,不是 `this`),下面会看到。 +- 构造函数:取 `domainId`,没给就 0(`??`)。 + +--- + +## 第 63-90 行:`connect`——加载 rclnodejs、初始化、建节点 + +```typescript + async connect(): Promise { + if (this.status === "connected") return; + this.setStatus("connecting"); + + try { + this.rclnodejs = require("rclnodejs"); + + if (this.domainId !== 0) { + process.env.ROS_DOMAIN_ID = String(this.domainId); + } + + if (!LocalTransport.rclInitialized) { + await this.rclnodejs!.init(); + LocalTransport.rclInitialized = true; + } + + this.node = this.rclnodejs!.createNode("rosclaw_local"); + this.rclnodejs!.spin(this.node); + this.entityCache = new EntityCache(); + + this.setStatus("connected"); + } catch (err) { + this.setStatus("disconnected"); + throw err; + } + } +``` + +- `if (this.status === "connected") return;` —— 已连就直接返回(幂等:重复调用无害)。 +- `this.setStatus("connecting");` —— 标记"连接中"(`setStatus` 见下面,会通知回调)。 +- `try { ... } catch` —— 整个连接过程包起来,失败就退回"断开"并把错误抛出去。 +- `this.rclnodejs = require("rclnodejs");` —— **用上面造的 `require` 加载 rclnodejs**。如果没装,这里抛错(回忆第⑪篇工厂会把它翻译成友好提示)。 +- `if (this.domainId !== 0) { process.env.ROS_DOMAIN_ID = String(this.domainId); }` —— 非默认域就设环境变量 `ROS_DOMAIN_ID`。 + - **语法小课堂:`process.env.XXX` 是"读写环境变量"。** `process` 是 Node 的全局对象,`process.env` 是环境变量集合。rclnodejs 通过这个环境变量决定用哪个 DDS 域,所以**必须在 `init()` 之前设好**。`String(this.domainId)` 把数字转成字符串(环境变量都是字符串)。 +- `if (!LocalTransport.rclInitialized) { await this.rclnodejs!.init(); ... }` —— **用静态门闩保证只初始化一次**:没初始化过才 `init()`,然后把门闩置 true。 + - `this.rclnodejs!` 的 `!` 是非空断言(第⑨篇):上面刚赋了值,断言它非 null。 +- `this.node = this.rclnodejs!.createNode("rosclaw_local");` —— 创建一个名为 `rosclaw_local` 的 ROS2 节点(我们在 DDS 总线上的"身份")。 +- `this.rclnodejs!.spin(this.node);` —— **`spin`(自转)让节点开始处理消息**。ROS2 里节点必须"spin"起来才能收发消息(相当于"开始监听/泵消息")。 +- `this.entityCache = new EntityCache();` —— 建实体缓存(第28篇)。 +- `this.setStatus("connected");` —— 大功告成,标记已连。 + +--- + +## 第 92-127 行:`disconnect`——层层清理(逆序拆解) + +```typescript + async disconnect(): Promise { + if (this.status === "disconnected") return; + + // Cancel any active action goals + for (const [action] of this.activeGoals) { + try { + await this.cancelActionGoal(action); + } catch { + // Best-effort + } + } + this.activeGoals.clear(); + + if (this.entityCache && this.node) { + this.entityCache.destroyAll(this.node); + this.entityCache = null; + } + + if (this.node) { + this.node.destroy(); + this.node = null; + } + + if (this.rclnodejs && LocalTransport.rclInitialized) { + try { + this.rclnodejs.shutdown(); + } catch { + // May already be shut down + } + LocalTransport.rclInitialized = false; + } + + clearTypeCache(); + this.rclnodejs = null; + this.setStatus("disconnected"); + } +``` + +- 断开要"按建立的逆序"把资源一层层拆掉。逐段: + - **先取消所有进行中的动作**:`for (const [action] of this.activeGoals)` —— 遍历 Map。 + - **语法小课堂:`for (const [action] of 某Map)` 遍历 Map + 解构。** Map 遍历时每项是 `[键, 值]` 对,这里用数组解构 `[action]` **只取键**(动作名),值不要。逐个调 `cancelActionGoal` 取消。 + - 每个取消包 `try { } catch {}`(空 catch,第23篇)——`// Best-effort`(尽力而为):取消失败也不影响继续断开。 + - `this.activeGoals.clear();` —— 清空记录。 + - **销毁实体缓存**:有缓存和节点就 `destroyAll`(第28篇)销毁所有发布器/订阅器/客户端,再置 null。 + - **销毁节点**:`this.node.destroy()`。 + - **关闭 rclnodejs**:`this.rclnodejs.shutdown()`,包 try/catch(可能已关);并把静态门闩复位 `false`(允许将来重新 init)。 + - `clearTypeCache();` —— 清第27篇那个消息类型缓存。 + - `this.rclnodejs = null;` + `setStatus("disconnected")` —— 收尾。 +- **整段是"对称清理"的范例**:connect 里建了什么,disconnect 就逆序拆什么;每步都防御性判断(`if (this.node)` 等)+ 尽力而为(空 catch),保证清理过程本身不会因小错中断。 + +--- + +## 第 129-138 行:`getStatus` / `onConnection` + +```typescript + getStatus(): ConnectionStatus { + return this.status; + } + + onConnection(handler: ConnectionHandler): () => void { + this.connectionHandlers.add(handler); + return () => { + this.connectionHandlers.delete(handler); + }; + } +``` + +- `getStatus` —— 直接返回当前状态字段。 +- `onConnection` —— **自己管理回调集合**(不像第⑩篇转发给 client):把回调加进 `Set`,返回一个退订函数(调用时从 `Set` 删除)。这是"登记回调 + 返回退订函数"的标准款(回忆第④/⑦篇)。 + +--- + +## 第 142-158 行:`publish` / `subscribe`(借助两个帮手) + +```typescript + publish(options: PublishOptions): void { + this.ensureConnected(); + const publisher = this.entityCache!.getPublisher(this.node, options.topic, options.type); + const rosMsg = toRosMessage(options.type, options.msg); + publisher.publish(rosMsg); + } + + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription { + this.ensureConnected(); + const type = options.type ?? this.resolveTopicType(options.topic); + if (!type) { + throw new Error(`Cannot subscribe to ${options.topic}: type is required when it cannot be inferred`); + } + return this.entityCache!.addSubscription(this.node, options.topic, type, handler); + } +``` + +- `this.ensureConnected();` —— 每个干活方法开头先确认已连接(见下面私有助手),没连就抛错。 +- **`publish`**: + - `this.entityCache!.getPublisher(...)` —— 从缓存拿(或建)一个发布器(第28篇)。 + - `const rosMsg = toRosMessage(options.type, options.msg);` —— **关键转换**:把上层给的"普通 JS 对象消息"转成 rclnodejs 要的"类型化消息实例"(第27篇)。这是本地模式特有的——rosbridge 走 JSON 不用转,本地走 rclnodejs 必须转成它认的类型。 + - `publisher.publish(rosMsg);` —— 发出去。 +- **`subscribe`**: + - `const type = options.type ?? this.resolveTopicType(options.topic);` —— 类型可省时,**尝试从节点的图信息里推断**(`resolveTopicType` 见下面)。 + - 推断不出(`if (!type)`)就抛错——本地模式订阅必须知道类型(不像 rosbridge 服务端有时能帮忙)。 + - `return this.entityCache!.addSubscription(...)` —— 委托给缓存去订阅,返回 `Subscription` 句柄(第28篇)。 + +--- + +## 第 162-196 行:`callService`(手工拼请求 + Promise 包装) + +```typescript + async callService(options: ServiceCallOptions): Promise { + this.ensureConnected(); + + const type = options.type ?? this.resolveServiceType(options.service); + if (!type) { + throw new Error(`Cannot call service ${options.service}: type is required when it cannot be inferred`); + } + + const client = this.entityCache!.getServiceClient(this.node, options.service, type); + + const available = await client.waitForService(5000); + if (!available) { + throw new Error(`Service ${options.service} not available after 5 seconds`); + } + + const ServiceClass = loadMessageClass(type); + const request = new ServiceClass.Request(); + if (options.args) { + for (const [key, value] of Object.entries(options.args)) { + request[key] = value; + } + } + + const response = await this.sendServiceRequest(client, request, 30_000); + + return { + result: true, + values: fromRosMessage(response), + }; + } +``` + +- 推断类型(同 subscribe 思路)、从缓存拿服务客户端。 +- `await client.waitForService(5000)` —— **等服务端就绪(最多 5 秒)**。本地 DDS 里服务端可能还没起来,先等等;等不到就抛错。 +- **手工拼请求对象**: + - `const ServiceClass = loadMessageClass(type);` —— 加载服务类型类(第27篇)。 + - `const request = new ServiceClass.Request();` —— new 一个空请求实例。 + - `if (options.args) { for (const [key, value] of Object.entries(options.args)) { request[key] = value; } }` —— **把参数逐个拷进请求对象**。 + - **语法小课堂:`Object.entries(对象)` —— 把对象拆成 `[键, 值]` 对的数组**,配合 `for...of` + 解构 `[key, value]` 就能遍历对象的每个字段。这里把 `args` 里每个字段赋到 `request` 上。(这是遍历对象所有键值的标准写法。) +- `const response = await this.sendServiceRequest(client, request, 30_000);` —— 调私有助手把"回调式的发请求"包装成 Promise + 30 秒超时(见下面)。 +- 返回 `{ result: true, values: fromRosMessage(response) }` —— **把 rclnodejs 的响应转回普通对象**(`fromRosMessage`,第27篇,与 publish 的 `toRosMessage` 相反方向)。 + - 注意 `result: true` 写死——本地模式里能拿到响应就算成功(不像 rosbridge 响应自带 result 字段)。 + +--- + +## 第 200-257 行:`sendActionGoal`(回调式动作 → Promise + finally 清理) + +```typescript + async sendActionGoal(options: ActionGoalOptions): Promise { + this.ensureConnected(); + + const ActionClass = loadMessageClass(options.actionType); + const actionClient = new (this.rclnodejs!.ActionClient as any)( + this.node, ActionClass, options.action, + ); + + const available = await actionClient.waitForServer(5000); + if (!available) { + actionClient.destroy(); + throw new Error(`Action server ${options.action} not available after 5 seconds`); + } + + const goal = new ActionClass.Goal(); + if (options.args) { + for (const [key, value] of Object.entries(options.args)) { + goal[key] = value; + } + } + + try { + const result = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.activeGoals.delete(options.action); + reject(new Error(`Action ${options.action} timed out after 120 seconds`)); + }, 120_000); + + actionClient.sendGoal( + goal, + (goalHandle: any) => { + this.activeGoals.set(options.action, goalHandle); + }, + (feedback: any) => { + if (options.onFeedback) { + options.onFeedback(fromRosMessage(feedback)); + } + }, + (resultResponse: any) => { + clearTimeout(timer); + this.activeGoals.delete(options.action); + resolve(fromRosMessage(resultResponse)); + }, + ); + }); + + return { result: true, values: result }; + } finally { + actionClient.destroy(); + } + } +``` + +这是本篇最复杂的方法,但**骨架就是第⑨篇 + 第⑯篇的组合**(Promise 包装回调 + 超时 + `try/finally` 清理)。逐看: + +- 加载动作类、`new ActionClient(...)`: + - `new (this.rclnodejs!.ActionClient as any)(...)` —— **语法小课堂:`new (表达式 as any)(...)`。** `ActionClient` 类挂在 rclnodejs 上、是 `any`(无类型)。要 `new` 一个 `any` 的构造器,TS 会犹豫,所以把它 `as any` 后用圆括号包起来再 `new`。是和无类型库打交道时的常见写法。 +- `await actionClient.waitForServer(5000)` —— 等动作服务器就绪,等不到先 `destroy()` 清理再抛错。 +- 拼 `goal` 对象(同 callService 拼 request 的写法:`Object.entries` 逐个赋)。 +- **核心 Promise**(包住 rclnodejs 那套回调): + - `setTimeout(...120_000)` —— 120 秒超时(动作是长任务,第⑨篇同款时长),超时就从 `activeGoals` 删掉并 reject。 + - `actionClient.sendGoal(goal, 回调1, 回调2, 回调3)` —— rclnodejs 的发目标要**三个回调**: + 1. **目标响应回调** `(goalHandle) => this.activeGoals.set(options.action, goalHandle)` —— 服务器接受目标后给个"目标句柄",**存进 `activeGoals` 备用**(取消时要用它)。 + 2. **进度反馈回调** `(feedback) => { if (options.onFeedback) options.onFeedback(fromRosMessage(feedback)); }` —— 每来一条进度,转成普通对象后转交上层的 `onFeedback`(回忆第⑩篇也做过类似的"进度转交")。 + 3. **结果回调** `(resultResponse) => { clearTimeout(timer); this.activeGoals.delete(...); resolve(fromRosMessage(resultResponse)); }` —— 动作完成:清超时、移除记录、`resolve` 最终结果(转成普通对象)。 + - 这就是"把回调式 API 包装成 Promise"(第⑧篇思想)的又一次实践,只是这里有三个回调要安置。 +- `return { result: true, values: result };` —— 成功返回。 +- **`finally { actionClient.destroy(); }`** —— **无论成功/超时/出错,都销毁这个动作客户端**(第⑨篇 `try/finally` 的清理精髓)。动作客户端是一次性的,用完必须销毁,放 finally 万无一失。 + +--- + +## 第 259-265 行:`cancelActionGoal` + +```typescript + async cancelActionGoal(action: string): Promise { + const goalHandle = this.activeGoals.get(action); + if (goalHandle && typeof goalHandle.cancelGoal === "function") { + await goalHandle.cancelGoal(); + this.activeGoals.delete(action); + } + } +``` + +- 从 `activeGoals` 取出那个动作的"目标句柄"(上面回调1存的)。 +- `if (goalHandle && typeof goalHandle.cancelGoal === "function")` —— **双重保险**:句柄存在、且它确实有个 `cancelGoal` 方法(`typeof ... === "function"` 检查,第⑥篇)。因为句柄是 `any`,不确定有没有这方法,先查一下更稳。 +- 有就 `await goalHandle.cancelGoal()` 取消、并从记录里删除。 + +--- + +## 第 269-310 行:三个自省方法(filter + some 过滤内部项) + +```typescript + async listTopics(): Promise { + this.ensureConnected(); + const namesAndTypes: Array<{ name: string; types: string[] }> = + this.node.getTopicNamesAndTypes(); + return namesAndTypes + .filter((t) => !INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix))) + .map((t) => ({ name: t.name, type: t.types[0] ?? "" })); + } +``` + +- `this.node.getTopicNamesAndTypes()` —— rclnodejs **直接从 DDS 图里拿到所有话题名和类型**(比 rosbridge 还方便——本地能直接查图,不用调 rosapi 服务)。 + - 返回类型显式标成 `Array<{ name: string; types: string[] }>`(每项有名字和类型数组)。 +- **链式 `.filter(...).map(...)`**: + - `.filter((t) => !INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix)))` —— 滤掉内部话题。 + - **语法小课堂:`数组.some(判断函数)` —— "有没有任意一个元素满足"**,返回 true/false。`INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix))` 读作"这话题名是否以**任意一个**内部前缀开头"。前面加 `!` 取反 → "不以任何内部前缀开头的才保留"。 + - `.some` 配 `.filter` 是"按一组条件批量过滤"的经典组合。 + - `.map((t) => ({ name: t.name, type: t.types[0] ?? "" }))` —— 转成 `TopicInfo`(第⑩篇见过的 `.map` + 返回对象套圆括号 + `?? ""` 兜底)。取 `types[0]`(第一个类型)。 +- `listServices`(第280行)—— 同款,但**两道 filter**:先滤"内部服务后缀"(`.endsWith` + `.some`),再滤"内部前缀"。链式 filter 可以叠加多个条件。 +- `listActions`(第292行)—— **和第⑩篇 `listActions` 几乎一字不差**:没有现成的列动作接口,用"找 `/_action/feedback` 后缀话题反推动作名"的启发式(第⑩篇详讲过,这里直接复用同一套 `.slice`/`.endsWith` 逻辑)。注释也明说 `Same ... heuristic as rosbridge/adapter.ts`。 + +--- + +## 第 314-374 行:私有助手们 + +### `setStatus`(改状态 + 通知所有回调) + +```typescript + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const handler of this.connectionHandlers) { + handler(status); + } + } +``` +- 改状态字段,然后**遍历回调集合挨个通知**(`for...of` 遍历 Set)。这就是为什么前面 `connect`/`disconnect` 里改状态都走 `setStatus`——好统一触发通知。 + +### `ensureConnected`(前置守卫) + +```typescript + private ensureConnected(): void { + if (this.status !== "connected" || !this.node || !this.entityCache) { + throw new Error("LocalTransport is not connected"); + } + } +``` +- 三个条件任一不满足就抛错。每个干活方法开头都调它,**把"没连接"的情况挡在最前面**,省得后面各方法各自判空。 + +### `resolveTopicType` / `resolveServiceType`(从图里推断类型) + +```typescript + private resolveTopicType(topic: string): string | undefined { + if (!this.node) return undefined; + const namesAndTypes: Array<{ name: string; types: string[] }> = + this.node.getTopicNamesAndTypes(); + const entry = namesAndTypes.find((t) => t.name === topic); + return entry?.types[0]; + } +``` +- 用来在 `subscribe`/`callService` 省略类型时自动推断。 +- **语法小课堂:`数组.find(判断函数)` —— "找出第一个满足条件的元素"**(找不到返回 `undefined`)。和 `.filter`(找全部)不同,`.find` 只要第一个。这里找名字匹配的那一项。 +- `return entry?.types[0];` —— 可选链(第⑥篇):找到就返回它第一个类型,没找到(`entry` 是 undefined)整体返回 undefined。 +- `resolveServiceType` 同款,换成服务。 + +### `sendServiceRequest`(回调式 → Promise + 超时) + +```typescript + private sendServiceRequest(client: any, request: any, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Service call timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + try { + client.sendRequest(request, (response: any) => { + clearTimeout(timer); + if (response) { + resolve(response); + } else { + reject(new Error("Service returned no response")); + } + }); + } catch (err) { + clearTimeout(timer); + reject(err); + } + }); + } +``` +- 又一次"把回调式 API 包成 Promise + 超时"(第⑧/⑯篇思想): + - 设超时定时器 → 超时 reject。 + - `client.sendRequest(request, 回调)` —— rclnodejs 发请求,响应到了走回调:清超时 → 有响应 resolve、没响应 reject。 + - 外层 try/catch:发请求这一步本身就抛错的话,也清超时 + reject。 +- 这是 `callService` 用到的底层助手。 + +--- + +## 整章回顾 + +`LocalTransport`(模式 A)和第⑩篇 `RosbridgeTransport` 实现**同一个 `RosTransport` 接口**,但底层从"WebSocket+JSON"换成"rclnodejs+DDS"。关键差异: + +| 方面 | rosbridge(⑩) | local(本篇) | +|---|---|---| +| 加载方式 | 普通 import | **`createRequire`** 加载 CJS 的 rclnodejs | +| 消息形态 | JSON 对象直接走 | 需 `toRosMessage`/`fromRosMessage` **转换类型化消息**(第27篇) | +| 实体管理 | client 内部管 | 自己用 **`EntityCache`** 缓存发布器/订阅器/客户端(第28篇) | +| 初始化 | 连 WebSocket | `rclnodejs.init()`(**static 门闩保证全进程一次**)+ 建节点 + spin | +| 自省 | 调 rosapi 服务 | 直接 `node.getTopicNamesAndTypes()` 查 DDS 图 + filter/some 过滤内部项 | +| 类型 | 有类型 | 大量 `any`(rclnodejs 可选依赖、无类型) | + +但"每个方法该返回什么、回调式如何包成 Promise、动作如何 try/finally 清理"这些**模式你全在前面学过了**——这正是"实现同一接口"带来的熟悉感。 + +**语法点回顾清单**(本章新增/巩固): +- **`createRequire(import.meta.url)`**:在 ESM 里借用 CJS 的 `require` 加载老库(最后一个预告大语法) +- `process.env.XXX`:读写环境变量;`import.meta.url` 当前文件地址 +- `static` 静态成员(类共享一份)+ 静态门闩(全进程只初始化一次) +- `Object.entries(对象)` + `for...of [key,value]`:遍历对象所有键值 +- `for (const [key] of 某Map)`:遍历 Map 只取键 +- 数组 `.some(判断)`(有无任一满足)、`.find(判断)`(找第一个)、链式 `.filter().map()`、叠加多个 `.filter` +- `new (x as any)(...)`、`typeof x.fn === "function"` 防御性检查(与无类型库打交道) +- 对称清理:connect 建什么、disconnect 逆序拆什么 + 防御判断 + 尽力而为空 catch +- 回调式 API → Promise + 超时(巩固第⑧⑯篇)、动作三回调 + `try/finally` 销毁(巩固第⑨篇) + +下一份:[`transport/local/conversion.ts` 逐行详解 →](27-local-conversion.ts.md)(本篇反复用到的"消息转换"帮手:普通对象 ↔ rclnodejs 类型化消息,含递归赋值) + + + +--- + + +# 逐行详解 ㉗:`transport/local/conversion.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/conversion.ts](../../extensions/openclaw-plugin/src/transport/local/conversion.ts) +> +> 推荐阅读顺序第 27 个文件。它是上一篇 `LocalTransport` 反复用到的"翻译官":把**普通 JS 对象**(上层和 `RosTransport` 接口用的 `Record`)和 **rclnodejs 的类型化消息实例**互相转换。难点不在语法,而在第一次正经接触**递归(recursion)**——因为消息会嵌套(如 `Twist.linear` 是个 `Vector3` 子消息),转换得"层层钻进去"。本篇把递归讲透。 + +--- + +## 先理解为什么需要"转换" + +- 上层(工具、接口)说的是"普通话":`{ linear: { x: 0.5 } }` 这种朴素 JS 对象。 +- rclnodejs 说的是"方言":它要的是 `new Twist()` 那种**带类型的消息实例**(有固定字段、有 setter)。 +- 两边语言不通,需要翻译官: + - **发消息时**:普通对象 → rclnodejs 实例(`toRosMessage`)。 + - **收消息时**:rclnodejs 实例 → 普通对象(`fromRosMessage`)。 +- 注释里说这"类似 rosbridge_library 的 `dict_to_msg` / `msg_to_dict`"——同样的翻译活,ROS2 各语言库都得做一遍。 + +--- + +## 第 1-22 行:模块注释 + createRequire + 加载/缓存机制 + +```typescript +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +/** Cached message classes keyed by normalized type string. */ +const typeCache = new Map(); + +function getRclnodejs(): any { + return require("rclnodejs"); +} +``` + +- `createRequire`(第26篇刚学)—— 这个文件也要加载 CJS 的 rclnodejs,所以自己也造一个 `require`。 +- `const typeCache = new Map();` —— **模块级缓存**(第⑫篇模块级变量 + 第⑥篇 Map):键是类型字符串、值是加载来的消息类。 + - **为什么缓存?** 加载一个 ROS2 消息类有开销,同一类型反复用(每次发 Twist 都要它)。缓存后第二次直接取,省事。这和第23篇能力缓存、第28篇实体缓存是同一种"算一次记住"的思路。 +- `getRclnodejs()` —— 一个小包装,返回加载来的 rclnodejs。包一层是为了"想拿 rclnodejs 都走这个口",统一。 + +--- + +## 第 24-35 行:`normalizeType`——把类型字符串规整成统一格式 + +```typescript +function normalizeType(typeStr: string): string { + const parts = typeStr.split("/"); + // Already fully qualified: "pkg/msg/Type" or "pkg/srv/Type" or "pkg/action/Type" + if (parts.length === 3) return typeStr; + // Short form: "pkg/Type" → assume msg + if (parts.length === 2) return `${parts[0]}/msg/${parts[1]}`; + return typeStr; +} +``` + +- 解决一个现实问题:ROS2 类型字符串有几种写法,rclnodejs 要的是**完整三段式** `包/msg/类型`。但上层可能传简写。这个函数把它们**规整成统一格式**。 +- `const parts = typeStr.split("/");` —— 按 `/` 切开(第25篇 `.split`)。如 `"geometry_msgs/msg/Twist"` → `["geometry_msgs", "msg", "Twist"]`。 +- `if (parts.length === 3) return typeStr;` —— **已经是三段**(完整的 `pkg/msg/Type`),原样返回。 +- `if (parts.length === 2) return \`${parts[0]}/msg/${parts[1]}\`;` —— **只有两段**(简写 `pkg/Type`),中间补个 `msg` 凑成三段(默认当消息类型)。模板字符串拼接。 +- 其余情况原样返回(兜底)。 +- **小结**:这是一个"输入容错"——不管上层写完整还是简写,都规整成 rclnodejs 认的样子。 + +--- + +## 第 37-49 行:`loadMessageClass`——加载消息类(带缓存) + +```typescript +export function loadMessageClass(typeStr: string): any { + const normalized = normalizeType(typeStr); + const cached = typeCache.get(normalized); + if (cached) return cached; + + const rclnodejs = getRclnodejs(); + const cls = rclnodejs.require(normalized); + typeCache.set(normalized, cls); + return cls; +} +``` + +- 这是第26篇反复调用的那个 `loadMessageClass`。逻辑是标准的"**缓存读取模式**": + 1. `normalizeType` 规整类型字符串。 + 2. `typeCache.get(normalized)` 查缓存,命中(`if (cached)`)就直接返回。 + 3. 没命中:`rclnodejs.require(normalized)` 真正加载这个消息类,存进缓存,再返回。 +- **这个"先查缓存、没有才算、算完存起来"的三步,和第23篇 `discoverCapabilities` 开头的缓存判断、第28篇 `getPublisher` 一模一样**——是个反复出现的通用套路,认得它即可。 + +--- + +## 第 51-62 行:`toRosMessage`——普通对象 → rclnodejs 实例 + +```typescript +export function toRosMessage(typeStr: string, obj: Record): any { + const MessageClass = loadMessageClass(typeStr); + const msg = new MessageClass(); + assignFields(msg, obj); + return msg; +} +``` + +- 三步:加载类 → `new` 一个空实例 → 把普通对象 `obj` 的字段**赋到实例上**(靠 `assignFields`,下面是重点)→ 返回。 +- 为什么不直接 `Object.assign` 一把梭?因为消息**有嵌套子消息**,得递归处理。这就引出 `assignFields`。 + +--- + +## 第 64-89 行:`assignFields`——递归赋值(本篇核心,递归首秀) + +```typescript +function assignFields(target: any, source: Record): void { + for (const [key, value] of Object.entries(source)) { + if (value === undefined || value === null) continue; + + if (typeof value === "object" && !Array.isArray(value)) { + // Nested sub-message ... + if (target[key] !== undefined && target[key] !== null && typeof target[key] === "object") { + assignFields(target[key], value as Record); + } else { + target[key] = value; + } + } else if (Array.isArray(value)) { + target[key] = value; + } else { + target[key] = value; + } + } +} +``` + +**先讲"递归"这个概念**: + +> **语法小课堂:递归(recursion)—— 函数自己调用自己。** +> 当一个问题"可以拆成同类的小问题"时,就用递归。这里:给一个消息赋值 = 给它的每个字段赋值;而某个字段如果**本身又是个子消息**,那"给子消息赋值"又是同一个问题——于是 `assignFields` **调用 `assignFields` 处理子消息**。像俄罗斯套娃,一层层钻进去,直到字段是简单值(不能再拆)为止。 + +逐行拆: + +- `for (const [key, value] of Object.entries(source))` —— 遍历普通对象的每个字段(`Object.entries`,第26篇)。 +- `if (value === undefined || value === null) continue;` —— 值是空的就**跳过**(`continue`,第⑥篇:跳过本轮、继续下一个字段)。不赋空值。 +- 然后按 `value` 的种类分三路: + 1. **是对象、且不是数组**(`typeof value === "object" && !Array.isArray(value)`,回忆第⑥篇 `typeof`;`Array.isArray` 判断是不是数组)——这说明它是个**嵌套子消息**(如 Twist 的 `linear`): + - `if (target[key] 已存在且也是对象)` —— rclnodejs new 出来的实例,子消息字段通常**已经被初始化成默认子消息实例**了。这种情况就 **`assignFields(target[key], value)` 递归**——钻进这个子消息,把 `value` 的字段赋进去。**这就是递归调用本身。** + - `else { target[key] = value; }` —— 否则(目标没有这个子字段实例)直接赋值。 + 2. **是数组**(`Array.isArray(value)`)—— 直接赋值。注释解释:数组元素可能是基本类型或子消息,但 rclnodejs 的 setter 能自己处理基本类型数组和类型化数组的强转,所以这里**先简单直接赋**(注释也坦承这对"嵌套消息数组"是简化处理——又是一个诚实标注的取舍)。 + 3. **其余**(数字、字符串、布尔等简单值)—— 直接 `target[key] = value;`。**这是递归的"底"**:简单值不能再拆,赋上就结束这一支。 +- **理解递归的关键**:每次遇到"子消息"就往里钻一层(情况1的递归),遇到"简单值/数组"就赋值收尾(情况2、3)。一个嵌套对象就这样被层层赋值填满。 + +> **举个具体例子**:`toRosMessage("Twist", { linear: { x: 0.5 }, angular: { z: 1.0 } })` +> - 外层 `assignFields(twistMsg, {linear:..., angular:...})`: +> - 字段 `linear` 是对象 → 递归 `assignFields(twistMsg.linear, {x:0.5})`: +> - 字段 `x` 是数字 → `twistMsg.linear.x = 0.5`(到底,收尾) +> - 字段 `angular` 是对象 → 递归 `assignFields(twistMsg.angular, {z:1.0})`: +> - 字段 `z` 是数字 → `twistMsg.angular.z = 1.0`(到底) +> - 钻了两层,把嵌套对象完整搬进了类型化消息。 + +--- + +## 第 91-107 行:`fromRosMessage`——rclnodejs 实例 → 普通对象(反方向) + +```typescript +export function fromRosMessage(msg: any): Record { + if (msg === null || msg === undefined) return {}; + + // Preferred path: rclnodejs provides toPlainObject() + if (typeof msg.toPlainObject === "function") { + return msg.toPlainObject() as Record; + } + + // Fallback: manual extraction + return extractFields(msg); +} +``` + +- 反方向:把 rclnodejs 消息转回普通对象。 +- `if (msg === null || msg === undefined) return {};` —— 空的就返回空对象,省得后面崩。 +- **优先用库自带的快捷方式**:`if (typeof msg.toPlainObject === "function")` —— 新版 rclnodejs 自带 `toPlainObject()` 方法,能一键转普通对象。**有就用它**(`typeof ... === "function"` 检查方法存在,第⑥篇/第26篇)。 +- **没有就降级**到手工提取 `extractFields`(下面)。这又是"优先用现成的、不行再兜底"的降级思想(第23篇)。 + +--- + +## 第 109-146 行:`extractFields`——手工递归提取(反方向的递归) + +```typescript +function extractFields(msg: any): Record { + if (msg === null || msg === undefined) return {}; + if (typeof msg !== "object") return {}; + + const result: Record = {}; + + const keys = Object.keys(msg); + for (const key of keys) { + if (key.startsWith("_")) continue; + + const value = msg[key]; + if (typeof value === "function") continue; + + if (value === null || value === undefined) { + result[key] = value; + } else if (Array.isArray(value)) { + result[key] = value.map((item: any) => + typeof item === "object" && item !== null ? extractFields(item) : item, + ); + } else if (typeof value === "object") { + if (value.constructor && value.constructor.name !== "Object") { + result[key] = extractFields(value); + } else { + result[key] = value; + } + } else { + result[key] = value; + } + } + + return result; +} +``` + +和 `assignFields` 镜像——也是递归,但方向相反(从类型化实例**抽**字段到普通对象)。 + +- 开头两个守卫:空的或非对象,返回空对象。 +- `const result = {};` —— 准备一个空普通对象装结果。 +- `const keys = Object.keys(msg);` —— **语法小课堂:`Object.keys(对象)` 取对象所有键名的数组**(`Object.entries` 的"只要键"版)。 +- 遍历每个键,先两道**跳过**: + - `if (key.startsWith("_")) continue;` —— 跳过下划线开头的(内部/私有属性,rclnodejs 实例上有些内部字段,不要)。 + - `if (typeof value === "function") continue;` —— 跳过方法(只要数据,不要函数)。 +- 然后按值的种类分路(和 `assignFields` 对称): + - **空值** → 原样记下。 + - **数组** → `value.map(...)`:对每个元素,**是对象就递归 `extractFields(item)`、否则原样**(`.map` + 三元,第⑩篇)。这处理"子消息数组"——每个子消息也要转成普通对象。 + - **对象** → 判断 `value.constructor.name !== "Object"`: + - **语法小课堂:`value.constructor.name` 看"这个对象是哪个类造出来的"。** 普通 JS 对象 `{}` 的 `constructor.name` 是 `"Object"`;而 rclnodejs 的子消息实例是别的类(如 `"Vector3"`)。 + - 如果**不是普通 Object**(是个类型化子消息)→ 递归 `extractFields(value)` 钻进去提取。 + - 是普通对象 → 直接记下。 + - **其余简单值** → 直接记下(递归的底)。 +- 返回填好的 `result`。 +- **和 `assignFields` 对照看**:一个"往类型化实例里塞"(递归点是"目标字段已是子消息实例"),一个"从类型化实例里抽"(递归点是"值是非普通对象")。两个递归方向相反、判断子消息的依据不同,但"遇嵌套就钻、遇简单值就收"的递归骨架完全一样。 + +--- + +## 第 148-153 行:`clearTypeCache`——清缓存 + +```typescript +export function clearTypeCache(): void { + typeCache.clear(); +} +``` + +- 把类型缓存清空(`Map.clear()`,第⑥篇)。第26篇 `disconnect` 时调它——断开连接就把缓存的消息类释放掉。 + +--- + +## 整章回顾 + +`conversion.ts` 是本地模式的"翻译官",三对核心能力: + +| 函数 | 方向 | 关键 | +|---|---|---| +| `loadMessageClass` | 取消息类 | 规整类型字符串 + `typeCache` 缓存(算一次记住) | +| `toRosMessage` → `assignFields` | 普通对象 → 类型化实例 | **递归**把嵌套字段层层赋进去 | +| `fromRosMessage` → `extractFields` | 类型化实例 → 普通对象 | 优先 `toPlainObject()`,否则**递归**层层抽出来 | + +最大的收获是**递归**:当数据是"可嵌套同类结构"时(消息里套子消息),用"函数调用自己处理子结构"来层层穿透——遇嵌套就钻进去、遇简单值就赋值收尾。 + +**语法点回顾清单**(本章新增/巩固): +- **递归:函数调用自己处理"同类的子结构"**(`assignFields`/`extractFields` 处理嵌套子消息) +- `Array.isArray(x)`(是否数组)、`Object.keys(对象)`(所有键名)、`value.constructor.name`(对象由哪个类造) +- 缓存读取套路:查缓存→命中即返→未命中则算+存(`loadMessageClass`,呼应第23、28篇) +- 降级:优先用库的 `toPlainObject()`,没有才手工 `extractFields`(呼应第23篇) +- `continue` 跳过不需要的字段(空值/方法/下划线私有) +- `.split("/")` + `parts.length` 判断格式、模板串补全(巩固第25篇) +- `typeof x === "function"` 判断方法是否存在(巩固第26篇) + +下一份:[`transport/local/entities.ts` 逐行详解 →](28-local-entities.ts.md)(实体缓存:把 rclnodejs 的发布器/订阅器/服务客户端缓存复用,本地模式三大件中最后一块) + + + +--- + + +# 逐行详解 ㉘:`transport/local/entities.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/entities.ts](../../extensions/openclaw-plugin/src/transport/local/entities.ts) +> +> 推荐阅读顺序第 28 个文件,**本地模式三大件的最后一块**(前两块是第26篇传输主体、第27篇消息转换)。它叫 `EntityCache`(实体缓存):把 rclnodejs 的**发布器(publisher)、订阅器(subscription)、服务客户端(service client)** 缓存起来复用——同一个话题反复发布时,共用一个发布器,而不是每次新建。本篇没有全新语法,是前面学过的 **Map 缓存 + Set 多订阅者 + 退订清理**的综合应用,读起来会很顺。 + +--- + +## 先理解为什么要"缓存实体" + +- 在 rclnodejs 里,"发布器""订阅器""服务客户端"都是**有成本的 DDS 实体**——创建它们要占资源、要在 DDS 网络上注册。 +- 如果每次 `publish` 都新建一个发布器,发 100 次就建 100 个,浪费且容易出问题。 +- **正确做法**:同一个 `(话题, 类型)` 只建一个发布器,缓存起来,之后复用。订阅器、服务客户端同理。 +- 注释说这"和参考实现里的 `_pubs`/`_subs`/`_srv_clients` 映射是同一套路"——**用 Map 做"键→实体"的缓存**,是各 ROS2 客户端的通用模式。 + +--- + +## 第 1-13 行:模块注释 + 导入 + 两个类型别名 + +```typescript +import type { Subscription } from "../types.js"; +import { loadMessageClass, fromRosMessage } from "./conversion.js"; + +type Node = any; +type MessageHandler = (msg: Record) => void; +``` + +- 导入:`Subscription` 句柄类型(第③篇)、以及第27篇的 `loadMessageClass`(加载消息类)和 `fromRosMessage`(实例转普通对象)。 +- `type Node = any;` —— **语法小课堂:`type X = any` 给 `any` 起个别名。** + - rclnodejs 的节点对象没类型,本该到处写 `any`。这里起个别名 `Node`,**让代码读起来有意义**(看到 `node: Node` 比 `node: any` 更能表达"这是个 ROS2 节点"),虽然实际还是 `any`。这是"给无类型的东西起个有意义的名字"的小技巧——**自我文档化**。 +- `type MessageHandler = (msg: Record) => void;` —— 消息回调的函数类型(收一个普通对象、返回 void)。和第③篇接口里的同名类型一致,这里本地模块自己定义一份。 + +--- + +## 第 15-19 行:类 + 三个缓存 Map + +```typescript +export class EntityCache { + private publishers = new Map(); + private subscriptions = new Map }>(); + private serviceClients = new Map(); +``` + +- 三个私有 Map,对应三类实体: + - `publishers` —— 键是字符串、值是发布器(`any`)。 + - `serviceClients` —— 键是字符串、值是服务客户端。 + - `subscriptions` —— **值的结构更复杂**:`{ handle: any; handlers: Set }`。 + - `handle` —— 那个 rclnodejs 订阅器实体。 + - `handlers` —— 一个 **`Set`**(第⑥篇)装多个回调。 + - **为什么订阅要存一组 handlers?** 因为可能有**多处都想订同一个话题**。我们只建**一个** rclnodejs 订阅器(省资源),但把它收到的消息**分发给所有登记的回调**。所以一个订阅实体配一个回调集合。下面 `addSubscription` 会看到这个设计的妙处。 + +--- + +## 第 20-32 行:`getPublisher`——取或建发布器(缓存套路) + +```typescript + getPublisher(node: Node, topic: string, typeStr: string): any { + const key = `${topic}::${typeStr}`; + const cached = this.publishers.get(key); + if (cached) return cached; + + const MessageClass = loadMessageClass(typeStr); + const publisher = node.createPublisher(MessageClass, topic); + this.publishers.set(key, publisher); + return publisher; + } +``` + +- `const key = \`${topic}::${typeStr}\`;` —— **用"话题::类型"拼一个缓存键**。 + - 为什么把两者拼起来当键?因为唯一标识一个发布器需要话题 + 类型两者。用 `::` 连接(一个不会出现在话题名里的分隔符),拼成单个字符串当 Map 的键。这是"多字段组合键"的常见做法。 +- 接下来是**标准缓存套路**(第27篇 `loadMessageClass`、第23篇能力缓存同款): + - `const cached = this.publishers.get(key); if (cached) return cached;` —— 查缓存,有就返回。 + - 没有:`loadMessageClass(typeStr)` 加载消息类 → `node.createPublisher(...)` 真正建发布器 → `this.publishers.set(key, publisher)` 存进缓存 → 返回。 +- 第26篇 `publish` 里 `this.entityCache!.getPublisher(...)` 调的就是它。 + +--- + +## 第 34-82 行:`addSubscription`——多订阅者共享一个订阅器(本篇精华) + +```typescript + addSubscription(node: Node, topic: string, typeStr: string, handler: MessageHandler): Subscription { + const key = `${topic}::${typeStr}`; + let entry = this.subscriptions.get(key); + + if (!entry) { + const MessageClass = loadMessageClass(typeStr); + const handlers = new Set(); + + const handle = node.createSubscription( + MessageClass, + topic, + (msg: any) => { + const plain = fromRosMessage(msg); + for (const h of handlers) { + h(plain); + } + }, + ); + + entry = { handle, handlers }; + this.subscriptions.set(key, entry); + } + + entry.handlers.add(handler); + + return { + unsubscribe: () => { + entry!.handlers.delete(handler); + if (entry!.handlers.size === 0) { + try { + node.destroySubscription(entry!.handle); + } catch { + // Already destroyed + } + this.subscriptions.delete(key); + } + }, + }; + } +``` + +这是本篇最精巧的方法。它实现"**多个回调共享一个底层订阅器**"。逐段: + +### 第一步:没有就建一个订阅器(含分发逻辑) + +- `let entry = this.subscriptions.get(key);` —— 查这个话题有没有已建的订阅条目。用 `let` 因为可能要赋新值。 +- `if (!entry) { ... }` —— **第一次订这个话题**,才建底层订阅器: + - `const handlers = new Set();` —— 建一个空回调集合。 + - `node.createSubscription(类, 话题, 收到消息的回调)` —— 建**唯一**的 rclnodejs 订阅器。它的回调是关键: + ```typescript + (msg: any) => { + const plain = fromRosMessage(msg); // 类型化消息转普通对象(第27篇) + for (const h of handlers) { // 遍历所有登记的回调 + h(plain); // 挨个分发 + } + } + ``` + - **每来一条消息,转成普通对象后,分发给 `handlers` 里的每一个回调**。这就是"一个订阅器,多个订阅者"的实现核心——底层只收一份,上层每个订阅者都收到。 + - `entry = { handle, handlers };` —— 把订阅器实体和回调集合打包成条目,存进 Map。 + +### 第二步:把本次的回调加入集合 + +- `entry.handlers.add(handler);` —— 不管是新建的还是已有的条目,**都把这次的 `handler` 加进回调集合**。 + - 于是:第一次订阅 → 建订阅器 + 加回调;第二次订同话题 → 复用订阅器,只加回调。**省了重复建订阅器。** + +### 第三步:返回带"智能退订"的句柄 + +- 返回一个 `Subscription`(第③篇:带 `unsubscribe` 的对象,回忆第⑩篇也现造过这种小对象): + ```typescript + unsubscribe: () => { + entry!.handlers.delete(handler); // 先把自己这个回调移除 + if (entry!.handlers.size === 0) { // 如果没有回调了 + try { node.destroySubscription(entry!.handle); } catch { } // 才真正销毁订阅器 + this.subscriptions.delete(key); // 并从缓存移除 + } + } + ``` + - **退订逻辑很讲究**:调用者退订时,**只移除它自己那个回调**(`handlers.delete(handler)`)。 + - `if (entry!.handlers.size === 0)` —— **只有当最后一个回调也退订了**(集合空了,`.size` 是 Set 元素个数),才真正销毁底层订阅器、从缓存删掉。 + - **为什么这样?** 因为订阅器是共享的——只要还有别的订阅者在用,就不能销毁。**等所有人都退订了,才拆掉**。这叫"引用计数"式的清理:共享资源用的人都走光了才释放。 + - `entry!` 的 `!` 非空断言(第⑨篇):闭包里 `entry` 此时必然有值。 + - 销毁包 `try { } catch {}`(空 catch,第23篇):可能已被销毁,无所谓。 + +> **这个方法把"缓存复用"做到了订阅这种"一对多"场景**:底层一个订阅器、上层多个回调,建时复用、退时引用计数清理。比发布器(一对一)精巧不少,值得细品。 + +--- + +## 第 84-96 行:`getServiceClient`——取或建服务客户端 + +```typescript + getServiceClient(node: Node, service: string, typeStr: string): any { + const key = `${service}::${typeStr}`; + const cached = this.serviceClients.get(key); + if (cached) return cached; + + const ServiceClass = loadMessageClass(typeStr); + const client = node.createClient(ServiceClass, service); + this.serviceClients.set(key, client); + return client; + } +``` + +- 和 `getPublisher` **一模一样的缓存套路**,只是换成服务客户端(`node.createClient`)。理解了 `getPublisher` 这个直接跳过。 +- 第26篇 `callService` 里调的就是它。 + +--- + +## 第 98-128 行:`destroyAll`——断开时销毁所有实体 + +```typescript + destroyAll(node: Node): void { + for (const pub of this.publishers.values()) { + try { + node.destroyPublisher(pub); + } catch { + // Best-effort cleanup + } + } + this.publishers.clear(); + + for (const { handle } of this.subscriptions.values()) { + try { + node.destroySubscription(handle); + } catch { + // Best-effort cleanup + } + } + this.subscriptions.clear(); + + for (const client of this.serviceClients.values()) { + try { + node.destroyClient(client); + } catch { + // Best-effort cleanup + } + } + this.serviceClients.clear(); + } +``` + +- 第26篇 `disconnect` 调它,**一次性销毁所有缓存的实体**。三段结构相同: + - **语法小课堂:`Map.values()` 遍历 Map 的所有值。** (对照第26篇 `for (const [key] of map)` 遍历键值对、这里 `for (const x of map.values())` 只遍历值。) + - 遍历每个实体,调对应的销毁方法(`destroyPublisher`/`destroySubscription`/`destroyClient`),各包 `try { } catch {}`(**尽力而为**,第26篇同款——销毁失败不影响销毁其他的)。 + - 销毁完 `.clear()` 清空 Map。 +- 订阅那段用了解构:`for (const { handle } of this.subscriptions.values())` —— **对象解构 `{ handle }`**(第⑪篇):每个值是 `{ handle, handlers }`,这里只取出 `handle`(要销毁的是订阅器实体,回调集合不用管)。 +- **整段是"批量清理"的范例**:遍历每类实体逐个销毁、各自容错、最后清空。和第26篇 `disconnect` 的对称清理精神一致。 + +--- + +## 整章回顾 + +`EntityCache` 用三个 Map 缓存 rclnodejs 的三类实体,避免重复创建: + +| 实体 | 方法 | 特点 | +|---|---|---| +| 发布器 | `getPublisher` | 一对一缓存(标准"查→建→存"套路) | +| 服务客户端 | `getServiceClient` | 同上 | +| 订阅器 | `addSubscription` | **一对多**:一个底层订阅器 + 一组回调,分发给所有人;引用计数式退订 | +| 全部 | `destroyAll` | 断开时批量销毁、尽力而为 | + +最值得记住的是 `addSubscription` 的"**多订阅者共享一个订阅器 + 引用计数清理**"——只在第一个订阅者来时建实体、最后一个走时拆实体,中间复用。 + +**语法点回顾清单**(本章新增/巩固): +- `type X = any` 给 any 起有意义的别名(自我文档化) +- 组合键 `` `${a}::${b}` ``:多字段拼成单个 Map 键 +- 缓存套路"查→命中即返→未命中则建+存"(巩固第23、27篇) +- 一对多分发:`Set<回调>` + 收到消息 `for...of` 挨个调用 +- 引用计数式退订:`handlers.delete(自己)` → `if (size===0)` 才销毁底层实体 +- `Map.values()` 遍历值、`for (const { handle } of ...)` 遍历 + 对象解构 +- 批量清理:逐个 `try{销毁}catch{}` 尽力而为 + `.clear()`(巩固第26篇对称清理) + +--- + +## 🎉 第七部分之模式 A(本地 DDS)讲完! + +本地模式三大件齐活: + +> **传输主体 `LocalTransport`(26)** + **消息转换 `conversion`(27)** + **实体缓存 `entities`(28)**。 + +它们合起来,让 OpenClaw 直接跑在机器人上、经 rclnodejs 用本地 DDS 通信——和 rosbridge 模式实现同一个 `RosTransport` 接口,但底层完全不同。你现在已经看过**两种**传输实现了,对"同一接口、不同实现"有了切身体会。 + +下一批进入**模式 C(WebRTC)**——它目前是**存根(stub)**(回忆 CLAUDE.md:未实现、带 `// TODO` 和正确类型签名)。所以那几篇会侧重"**接口/信令协议怎么定义、存根怎么写**",而非真实逻辑。先从信令协议的类型定义(第29篇)开始。 + +下一份:[`transport/webrtc/signaling-types.ts` 逐行详解 →](29-webrtc-signaling-types.ts.md)(WebRTC 信令消息的类型定义——回到第③⑤篇那种"纯类型契约"的味道) + + + +--- + + +# 逐行详解 ㉙:`transport/webrtc/signaling-types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/signaling-types.ts](../../extensions/openclaw-plugin/src/transport/webrtc/signaling-types.ts) +> +> 推荐阅读顺序第 29 个文件,**第七部分模式 C(WebRTC)的开篇**。它和第③⑤篇一样,是一个**纯类型文件**——只定义"信令服务器协议"的各种消息形状,没有任何执行逻辑。所谓"信令(signaling)",是 WebRTC 建立连接前、两端通过一台中间服务器**交换"如何直连"的信息**的过程。本篇把这些消息的类型一一列出。由于全是熟悉的 `interface`/`extends`,读起来很轻松,正好作为模式 C 的热身。 + +--- + +## 先理解 WebRTC 和"信令" + +- **WebRTC** 是浏览器/设备之间**点对点(peer-to-peer)直连**传输音视频和数据的技术。模式 C 用它的"数据通道(data channel)"来传 ROS2 消息——适合"OpenClaw 在云端、机器人在远处"的部署。 +- 但两端要直连,得先**互相知道对方的网络地址、加密参数**等。这个"交换连接信息"的过程叫**信令**,需要一台双方都能访问的**信令服务器**居中转达。 +- 信令交换的内容主要是: + - **SDP**(会话描述):描述"我支持什么编解码、我的媒体参数"等,分 **offer(提议)** 和 **answer(应答)** 两种。 + - **ICE candidate(候选地址)**:双方可能的网络路径,互相试探哪条能通。 +- 一旦信令完成、直连建好,之后的数据就**不再经过信令服务器**,而是点对点直传。 +- **本篇就是给这些信令消息定义类型**。你不需要精通 WebRTC,只要知道"这些消息是连接握手时交换的"即可。 + +--- + +## 第 1-6 行:模块注释 + +```typescript +/** + * Type definitions for the WebRTC signaling server protocol. + * + * These match the message format used by the webrtc-py signaling server + * (see mock_client.py and mock_robot.py for reference implementations). + */ +``` + +- 说明:这些类型**对齐一个 Python 写的信令服务器(webrtc-py)的消息格式**。 +- **这点很重要**:这些 `interface` 不是凭空设计的,而是**照着一个已存在的服务器协议"描摹"出来的**——所以字段名都是 Python 风格的下划线(`user_id`、`robot_id`),而非 TS 习惯的驼峰。**当 TS 代码要和外部系统(这里是 Python 服务器)通信时,类型就得照对方的格式来**(回忆第⑤篇 rosbridge 协议也是下划线,同理)。 + +--- + +## 第 8-31 行:REST API 的请求/响应类型 + +```typescript +// --- REST API --- + +export interface ConnectRequest { + user_id: string; + robot_id: string; + robot_key: string; +} + +export interface ConnectResponse { + session: { session_id: string }; + room_id: string; +} + +export interface DisconnectResponse { + status: string; +} + +export interface RobotInfo { + robot_id: string; + status: string; + capabilities?: string[]; +} + +export type DiscoverResponse = RobotInfo[]; +``` + +- 连接 WebRTC 前,先通过普通 **REST API**(HTTP 请求)做几件事:发现机器人、请求连接、断开。这几个类型就是那些请求/响应的形状。 +- `ConnectRequest` —— 请求连接时要发的:用户 id、机器人 id、机器人密钥(`robot_key`,身份凭证)。 +- `ConnectResponse` —— 服务器的回复: + - `session: { session_id: string }` —— **内联的嵌套对象类型**(回忆第①篇:字段的类型可以直接写成 `{ ... }`)。表示 `session` 字段是个含 `session_id` 的对象。 + - `room_id` —— 分配的"房间" id(双方在同一房间里交换信令)。 +- `DisconnectResponse` —— 断开的回复,就一个状态字符串。 +- `RobotInfo` —— 一个机器人的信息:id、状态、可选的能力列表(`capabilities?: string[]`,第①篇可选 + 数组)。 +- `export type DiscoverResponse = RobotInfo[];` —— **用 `type` 给"机器人信息数组"起个别名**(回忆第①篇 `type`)。"发现机器人"接口返回的就是一串 `RobotInfo`。 + +--- + +## 第 33-39 行:所有信令消息的"基底"形状 + +```typescript +// --- WebSocket signaling messages --- + +/** Base shape for all signaling messages. */ +export interface SignalingMessage { + type: string; + [key: string]: unknown; +} +``` + +- 接下来是走 **WebSocket** 的信令消息(连接握手期间双向交换)。 +- `SignalingMessage` 是所有信令消息的**共同基底**: + - `type: string` —— 每条消息都有个 `type` 字段标明它是什么(和第⑤篇 rosbridge 的 `op` 同理——判别字段)。 + - `[key: string]: unknown;` —— **语法小课堂:索引签名(index signature)。** + - `[key: string]: unknown` 读作"**除了上面明确写的字段,还允许任意多个字符串键、值为 unknown 的字段**"。 + - 为什么要它?因为不同信令消息字段各不相同,这个基底类型只能确定"一定有 `type`",其余字段开放。具体子类型(下面)再补上各自的字段。 + - 这是"我知道有 `type`,其余字段先放开"的写法。 + +--- + +## 第 41-92 行:客户端→服务器 的消息(`extends` + 字面量收窄) + +挑几个代表讲,其余同理: + +```typescript +// Client → Server + +export interface JoinRoomMessage extends SignalingMessage { + type: "join_room"; + room_id: string; + peer_id: string; + peer_type: "frontend" | "robot"; + session_id: string; +} +``` + +- `interface JoinRoomMessage extends SignalingMessage` —— **继承基底**(回忆第⑤篇 `interface extends`):在"有 `type` + 任意字段"的基础上,补上这条消息特有的字段。 +- `type: "join_room";` —— **关键:把 `type` 从基底的 `string` 收窄成字面量 `"join_room"`**(回忆第⑤篇子接口收紧字段)。这样 TS 一看到 `type === "join_room"` 就知道这条消息是 `JoinRoomMessage`,能安全访问它的 `room_id` 等字段(判别联合的威力,第③篇)。 +- `peer_type: "frontend" | "robot"` —— 字面量联合:对等端只能是"前端"或"机器人"二选一。 +- 其余字段是这条消息要带的数据。 + +后面一连串都是同款套路(`extends SignalingMessage` + `type` 收窄成各自字面量),按用途列举: + +- `RobotConnectMessage`(`"robot_connect"`)—— 机器人上线。 +- `SessionAcceptedMessage`(`"session_accepted"`)—— 会话被接受。 +- `OfferMessage`(`"offer"`)—— **SDP 提议**,`data: { type: string; sdp: string }`(嵌套对象装 SDP 内容)。 +- `AnswerMessage`(`"answer"`)—— **SDP 应答**,结构类似 offer。 +- `IceCandidateMessage`(`"ice_candidate"`)—— **ICE 候选地址**: + ```typescript + data: { + candidate: string; + sdpMid: string | null; + sdpMLineIndex: number | null; + }; + ``` + - 注意这里字段是**驼峰**(`sdpMid`、`sdpMLineIndex`)——因为这部分对齐的是 WebRTC 标准(标准本身用驼峰),而非那个 Python 服务器的下划线习惯。**混用说明"字段名永远跟着它对接的那个协议走"**,不强求统一。 + - `string | null` / `number | null` —— 这些字段**可能是值、也可能明确为 null**(WebRTC 标准里它们可空)。注意是 `| null`(明确的空)而非 `?`(可省略),细微区别:`?` 是"可以没有这个键",`| null` 是"键在、但值是 null"。 +- `HeartbeatMessage`(`"heartbeat"`)—— 心跳,带 `timestamp: number`(保活用,第31篇会看到怎么定时发)。 + +--- + +## 第 94-130 行:服务器→客户端 的消息 + +```typescript +// Server → Client + +export interface PeerJoinedMessage extends SignalingMessage { + type: "peer_joined"; + peer_id: string; + peer_type: "frontend" | "robot"; +} +// ... +``` + +- 同样是 `extends SignalingMessage` + `type` 收窄的一组,方向相反(服务器发给客户端): + - `PeerJoinedMessage`(`"peer_joined"`)/ `PeerLeftMessage`(`"peer_left"`)—— 有对等端加入/离开房间。 + - `SessionInvitationMessage`(`"session_invitation"`)—— 会话邀请。 + - `SessionEndedMessage`(`"session_ended"`)—— 会话结束,`reason?: string`(可选原因)。 + - `HeartbeatRequestMessage`(`"heartbeat_request"`)—— 服务器要求客户端回个心跳。 + - `ErrorMessage`(`"error"`)—— 错误消息,带 `message` + 可选 `code`。 +- 这些都是声明式的类型,无逻辑,按需查阅即可。 + +--- + +## 整章回顾 + +- `signaling-types.ts` 是模式 C 的"协议字典":定义 WebRTC 信令服务器收发的所有消息形状,照着一个 Python 服务器(webrtc-py)的格式描摹。 +- 结构上分两类:**REST API**(连接前的 HTTP 请求/响应)和 **WebSocket 信令消息**(握手期间双向交换,含 SDP offer/answer、ICE candidate、心跳等)。 +- 所有信令消息都 `extends SignalingMessage`(基底有 `type` + 索引签名),各自把 `type` 收窄成字面量——又是第③⑤篇"判别联合"那套,换了个场景。 +- 字段命名**跟着对接的协议走**:对 Python 服务器用下划线、对 WebRTC 标准用驼峰,不强求统一。 + +**语法点回顾清单**(本章新增/巩固): +- 索引签名 `[key: string]: unknown`("除已知字段外,允许任意字符串键") +- `interface X extends 基底` + 把 `type` 从 `string` 收窄成字面量(判别消息身份)(巩固第⑤篇) +- 内联嵌套对象类型 `session: { session_id: string }`(巩固第①篇) +- `type 别名 = T[]`(给数组类型起名,如 `DiscoverResponse`) +- `string | null`(值可为 null)vs `?`(键可省略)的区别 +- 字段命名跟随对接协议(下划线 vs 驼峰混用)(巩固第⑤篇协议命名) + +下一份:[`transport/webrtc/transport.ts` 逐行详解 →](30-webrtc-transport.ts.md)(模式 C 的传输主体——全项目最长的文件,但**它其实是完整实现、不是存根**,我们会澄清这点并抓主干讲) + + + +--- + + +# 逐行详解 ㉚:`transport/webrtc/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/transport.ts](../../extensions/openclaw-plugin/src/transport/webrtc/transport.ts) +> +> 推荐阅读顺序第 30 个文件,**全项目最长的源文件(516 行)**。它是**模式 C** 的传输主体:通过 WebRTC 数据通道把 ROS2 消息传给远程机器人。 +> +> **先澄清一件事**:本系列总览(README)和 CLAUDE.md 早先把模式 C 标为"存根(stub)",但**真读这份代码会发现它其实是相当完整的实现**——它真的用了 `node-datachannel`、`ws` 这些库,有完整的"REST 请求 → 信令握手 → SDP/ICE 交换 → 数据通道开通 → 收发消息"流程。**这是一个文档与代码不一致的地方**,本篇按"它是完整实现"来讲,并在结尾说明这个出入。(注意到这种"文档说存根、代码却完整"的对照,正是我们一路在练的读码眼光。) +> +> 好消息:尽管最长,但它**实现的还是第④篇那个 `RosTransport` 接口**,而且**数据通道上跑的正是第⑤篇那套 rosbridge JSON 协议**——所以接口方法你已在第⑩篇见过、协议你已在第⑤⑥篇见过。真正新的只有"WebRTC 连接怎么建"那一段。我们抓这条主干,熟悉的部分快速带过。 + +--- + +## 先把整体流程记在心里 + +这个类扮演"应答方(answering peer)",连接分几步(类注释也列了): + +``` +1. REST: POST /connect → 拿到 session_id、room_id +2. 开信令 WebSocket → 加入房间 (join_room) +3. 等机器人发来 SDP offer(提议) +4. 建 PeerConnection、设置远端描述、生成 answer(应答) +5. 交换 ICE candidate(候选网络路径) +6. 数据通道打开 → 之后就在上面跑 rosbridge JSON +``` + +**关键洞察**:第 6 步之后,这个类就和第⑩篇 `RosbridgeTransport` 几乎一样了——都是"组一条 rosbridge JSON、发出去、按 id 接响应"。区别只在"消息走 WebRTC 数据通道"而非"走 WebSocket"。所以**前 5 步(建连接)是新内容,第 6 步之后(收发消息)全是旧知识**。 + +--- + +## 第 1-25 行:导入 + +```typescript +import { PeerConnection, DescriptionType, type DataChannel, type RtcConfig } from "node-datachannel"; +import type { RosTransport } from "../transport.js"; +import type { /* 一大批接口类型 */ } from "../types.js"; +import { SignalingClient } from "./signaling-client.js"; +import type { SignalingMessage, OfferMessage, IceCandidateMessage, PeerJoinedMessage } from "./signaling-types.js"; +``` + +- `import { PeerConnection, DescriptionType, type DataChannel, type RtcConfig } from "node-datachannel";` + - **`node-datachannel`** 是个真实的 WebRTC 库(提供 `PeerConnection` 等)。**这是"完整实现"的铁证**——存根不会真导入一个 WebRTC 库。 + - **语法小课堂:一条 import 里混用值导入和 `type` 导入。** `PeerConnection`、`DescriptionType` 是**值**(要 `new`/使用),`type DataChannel`、`type RtcConfig` 前面加了 `type` 表示**只当类型**。可以在同一对花括号里混写——`import { 值, type 类型 } from ...`。比分两行写紧凑。 +- `SignalingClient`(值)—— 第31篇那个信令客户端,本类用它做 REST + WebSocket。 +- 一批信令消息类型(第29篇)。 + +--- + +## 第 27-45 行:选项类型 + 待处理请求类型 + +```typescript +export interface WebRTCTransportOptions { + signalingUrl: string; + apiUrl: string; + robotId: string; + robotKey: string; + iceServers?: RTCIceServerConfig[]; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} +``` + +- `WebRTCTransportOptions` —— 连接所需:信令 WebSocket 地址、REST API 地址、目标机器人 id、机器人密钥、可选的 ICE 服务器(STUN/TURN,帮助穿透网络)。 +- `PendingRequest` —— **一个"待兑现请求"的形状**:存 `resolve`、`reject`(Promise 的两个开关,第⑧篇)和 `timer`(超时定时器句柄)。 + - **语法小课堂:`ReturnType`。** 回忆第⑥篇 `ReturnType`(取函数返回值的类型)+ `typeof 值`。`setTimeout` 的返回值是个定时器句柄,类型在不同环境(浏览器/Node)下不同,与其写死,不如用 `ReturnType` **让 TS 自己推**——"无论它返回啥类型,我这字段就是那个类型"。是处理"跨环境类型"的稳妥写法。 + - 这个 `PendingRequest` 就是第⑥篇 `client.ts` 里 `registerPending` 存的东西,本类自己实现了一份等价物。 + +--- + +## 第 58-76 行:类 + 一堆状态字段 + 构造 + +```typescript +export class WebRTCTransport implements RosTransport { + private options: WebRTCTransportOptions; + private signaling: SignalingClient; + private pc: PeerConnection | null = null; + private dataChannel: DataChannel | null = null; + private status: ConnectionStatus = "disconnected"; + private connectionHandlers = new Set(); + private topicHandlers = new Map>(); + private pendingRequests = new Map(); + private idCounter = 0; + private sessionId: string | null = null; + private roomId: string | null = null; + private peerId: string | null = null; + private robotPeerId: string | null = null; + + constructor(options: WebRTCTransportOptions) { + this.options = options; + this.signaling = new SignalingClient(options.apiUrl, options.signalingUrl); + } +``` + +- `implements RosTransport` —— 老规矩,实现那 13 个方法(第④/⑩篇)。 +- 字段分几类,对照第⑥篇 client + 第26篇 local 就眼熟: + - `pc`(PeerConnection)、`dataChannel` —— WebRTC 的连接和数据通道,连上才有,先 null。 + - `status` / `connectionHandlers` —— 状态 + 连接回调集合(和第26篇 local 一样自己管理)。 + - `topicHandlers = Map>` —— **话题 → 一组回调**。和第28篇 entities 的订阅设计同理:一个话题可能多个订阅者,用 `Set` 装回调。 + - `pendingRequests = Map` —— **id → 待处理请求**。这就是第⑥篇 `registerPending` 那套"按 id 配对响应"的本类版。 + - `idCounter` —— 自增计数器,生成唯一 id(第⑥篇 `nextId` 同款)。 + - `sessionId`/`roomId`/`peerId`/`robotPeerId` —— 信令握手过程中拿到的各种 id。 +- 构造函数:存选项、建一个 `SignalingClient`(第31篇)。 + +--- + +## 第 78-119 行:`connect`——六步建连接(本篇新内容的核心) + +```typescript + async connect(): Promise { + if (this.status === "connected") return; + this.setStatus("connecting"); + + try { + // Step 1: Request connection via REST API + const userId = `frontend_${Date.now()}`; + const connectRes = await this.signaling.requestConnection(this.options.robotId, { + user_id: userId, + robot_id: this.options.robotId, + robot_key: this.options.robotKey, + }); + this.sessionId = connectRes.session.session_id; + this.roomId = connectRes.room_id; + this.peerId = userId; + + // Step 2: Connect signaling WebSocket + await this.signaling.connectWs(); + + // Step 3: Set up message handler before joining room + const connected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("WebRTC connection timed out (30s)")); + }, 30_000); + + this.signaling.onMessage((msg: SignalingMessage) => { + this.handleSignalingMessage(msg, resolve, reject, timeout); + }); + }); + + // Step 4: Join room + this.signaling.joinRoom(this.roomId, userId, "frontend", this.sessionId); + + // Step 5: Wait for data channel to be established + await connected; + this.setStatus("connected"); + } catch (err) { + this.setStatus("disconnected"); + this.cleanup(); + throw err; + } + } +``` + +注释清楚标了步骤,逐步看: + +- **Step 1(REST 请求连接)**: + - `const userId = \`frontend_${Date.now()}\`;` —— 用时间戳拼一个唯一用户 id(`Date.now()`,第23篇)。 + - `await this.signaling.requestConnection(...)` —— 调信令客户端的 REST 接口(第31篇),传机器人 id、密钥。拿回 `session_id`、`room_id`,存进字段。 +- **Step 2(开信令 WebSocket)**:`await this.signaling.connectWs();`(第31篇)。 +- **Step 3(建一个"连接成功"的 Promise + 装消息处理器)**——**这段最巧妙**: + - `const connected = new Promise((resolve, reject) => {...})` —— 建一个 Promise 代表"整个 WebRTC 连接是否建成"。 + - 里面设 30 秒超时(超时 reject)。 + - `this.signaling.onMessage((msg) => this.handleSignalingMessage(msg, resolve, reject, timeout));` —— **登记信令消息处理器,并把 `resolve`/`reject`/`timeout` 一路传进去**。 + - 为什么传进去?因为"连接成功"这个事件,要等到后面收到 offer、建好数据通道、通道 `onOpen` 触发时才算数(在 `handleOffer` 里)。届时调 `resolve` 让这个 Promise 兑现。**这是把"分散在多个回调里才完成的事"汇聚成一个 Promise 的手法**(第⑧篇思想的进阶版:resolve 被传到很深的回调里才调用)。 +- **Step 4(加入房间)**:`this.signaling.joinRoom(...)` 发 join_room 消息(第29、31篇)。这会触发服务器和机器人那边的后续动作(机器人发来 offer)。 +- **Step 5(等数据通道建好)**:`await connected;` —— **卡在这里等那个 Promise**。直到数据通道 `onOpen`(在后面回调链里)调了 `resolve`,才往下 `setStatus("connected")`。 +- `catch` —— 任何一步失败:退回断开、`cleanup()` 清理、抛错。 + +> **这段的精髓**:`connect` 启动握手、然后 `await` 一个 Promise;而让这个 Promise 兑现的 `resolve`,被深埋在"信令消息处理 → 处理 offer → 数据通道 onOpen"这条回调链的末端。**一条 await 等着一串异步回调最终汇合。** + +--- + +## 第 121-147 行:`disconnect` / `getStatus` / `onConnection` + +```typescript + async disconnect(): Promise { + this.cleanup(); + if (this.sessionId) { + try { + await this.signaling.requestDisconnect(this.options.robotId); + } catch { + // Best-effort disconnect notification + } + this.sessionId = null; + } + this.signaling.close(); + this.setStatus("disconnected"); + } +``` + +- `disconnect` —— `cleanup()`(关数据通道和 pc,见下)→ 尽力通知服务器断开(REST,失败无所谓,空 catch)→ 关信令 WebSocket → 标记断开。 +- `getStatus` / `onConnection` —— 和第26篇 local **完全一样**(返回状态字段 / 登记回调返回退订函数)。跳过。 + +--- + +## 第 149-189 行:`publish` / `subscribe`(开始变回熟悉的 rosbridge 味道) + +```typescript + publish(options: PublishOptions): void { + this.sendOverDataChannel({ + op: "publish", + topic: options.topic, + type: options.type, + msg: options.msg, + }); + } +``` + +- **`publish`**:注意它组的是 `{ op: "publish", topic, type, msg }`——**这就是第⑤篇的 rosbridge `PublishMessage` 格式!** 只是不再用 `client.send`(WebSocket),而是 `sendOverDataChannel`(WebRTC 数据通道,见下面私有助手)。 +- **`subscribe`**(第158行): + - 先把回调存进 `topicHandlers`(话题→回调集合,和第28篇 entities 一样的"一对多"管理)。 + - `this.sendOverDataChannel({ op: "subscribe", id: this.nextId("sub"), topic, type, throttle_rate, queue_length });` —— 发 rosbridge 的 `subscribe` 命令(第⑤篇格式)。 + - 返回 `Subscription` 句柄,`unsubscribe` 里:从集合移除本回调,**若集合空了**才真正发 `unsubscribe` 命令并从 Map 删除(**引用计数式退订,和第28篇一模一样的逻辑**)。 +- **看出来了吗**:从这里开始,本类就是"**第⑩篇适配器的逻辑 + 第28篇的订阅管理**"——组 rosbridge JSON、管多订阅者。WebRTC 只是换了条"管子"。 + +--- + +## 第 191-257 行:`callService` / `sendActionGoal` / `cancelActionGoal` + +```typescript + async callService(options: ServiceCallOptions): Promise { + const id = this.nextId("service"); + const response = await new Promise>((resolve, reject) => { + this.registerPending(id, (v) => resolve(v as Record), reject, 30_000); + this.sendOverDataChannel({ + op: "call_service", + id, + service: options.service, + args: options.args, + type: options.type, + }); + }); + return { + result: response.result as boolean, + values: response.values as Record | undefined, + }; + } +``` + +- **`callService`**:这和第⑧篇 `services.ts` 的 `callService` **几乎逐行对应**! + - `nextId` 拿号 → `new Promise` + `registerPending`(先张接球网)→ `sendOverDataChannel` 发请求 → 返回承诺。 + - **完全是第⑧篇那套"拿号→登记回调→发请求→按 id 兑现"五步骨架**,只是发送通道是数据通道、`registerPending` 是本类自己实现的版本。第⑧篇吃透了这里秒懂。 +- **`sendActionGoal`**(第212行):和第⑨篇 `actions.ts` 对应——`registerPending` 等最终结果(120 秒超时)、若有 `onFeedback` 就挂一个 `__action_feedback__` 频道接进度、用 `try/finally` 清理进度频道。**第⑨篇的翻版**。 +- **`cancelActionGoal`**(第251行):发个 `cancel_action_goal` 消息,最简单(第⑨篇同款)。 + +> 三个方法全是第⑧⑨篇知识的"换管子"重演。**这就是"实现同一接口、复用同一协议"的回报:核心逻辑你早就会了。** + +--- + +## 第 259-299 行:三个自省方法 + +- `listTopics` / `listServices` —— 调 `callService("/rosapi/topics"...)`,再用 `.map` 把并列数组缝成信息数组。**和第⑩篇适配器的 `listTopics` 一字不差**(连 `result.values?.["topics"] as string[] ?? []` 都一样)。 +- `listActions` —— 还是那个"找 `/_action/feedback` 后缀话题反推"的启发式(第⑩、26篇见过两遍了)。注释也说 `Same heuristic as RosbridgeTransport`。 +- 完全复用,跳过。 + +--- + +## 第 301-341 行:基础私有助手 + +```typescript + private nextId(prefix = "rosclaw"): string { + return `${prefix}_${++this.idCounter}`; + } + + private sendOverDataChannel(msg: Record): void { + if (!this.dataChannel) { + throw new Error("Data channel is not open"); + } + this.dataChannel.sendMessage(JSON.stringify(msg)); + } + + private registerPending(id, resolve, reject, timeoutMs): void { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pendingRequests.set(id, { resolve, reject, timer }); + } + + private resolvePending(id: string, result: unknown): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.resolve(result); + } + } +``` + +- `nextId` —— `++this.idCounter` 前置自增(第⑥篇)拼唯一 id。第⑥篇 client 同款。 +- `sendOverDataChannel` —— **本类发消息的唯一出口**:没数据通道就抛错,否则 `JSON.stringify` 后用 `dataChannel.sendMessage` 发出去。**这就是替代了第⑥篇 `client.send` 的那根"管子"**——其余代码都通过它发,所以前面 publish/callService 等才能那么像 rosbridge。 +- `registerPending` / `resolvePending` —— **本类自己实现的第⑥篇那套"按 id 配对"**:登记时设超时 + 存进 `pendingRequests`;响应到了 `resolvePending` 清超时、移除、调 resolve。逻辑和第⑥篇 client 一致。 + +--- + +## 第 343-393 行:`handleDataChannelMessage`——收到数据通道消息的路由 + +```typescript + private handleDataChannelMessage(data: string): void { + let msg: Record; + try { + msg = JSON.parse(data) as Record; + } catch { + return; + } + const op = msg.op as string | undefined; + if (!op) return; + + switch (op) { + case "publish": { /* 找 topicHandlers,分发给所有回调 */ } + case "service_response": { /* 按 id resolvePending */ } + case "action_result": { /* 按 id resolvePending */ } + case "action_feedback": { /* 找 __action_feedback__ 频道分发 */ } + } + } +``` + +- 这是数据通道收到消息时的**总路由**,**和第⑥篇 `client.ts` 的 `handleMessage` 是同一个角色、同一套 `switch(op)` 逻辑**: + - `JSON.parse` 解析(解析失败就忽略,空 catch)。 + - 取 `op` 判别消息种类。 + - `publish` → 找话题的回调集合,挨个分发(订阅消息到了)。 + - `service_response` / `action_result` → 按 `id` 调 `resolvePending`(兑现等待的请求)。 + - `action_feedback` → 找 `__action_feedback__` 频道分发进度。 +- **第⑥篇那个"最难"的 `handleMessage` 你吃透了,这里就是它的 WebRTC 版**——结构完全平行。 + +--- + +## 第 395-493 行:信令与 WebRTC 握手(真正的新内容) + +这两个方法是本篇唯一"前面没见过"的部分——WebRTC 怎么建连接。**它细节多、涉及 WebRTC 专业概念,对初学者只需把握"在干什么",不必逐字抠 API。** 抓大意: + +### `handleSignalingMessage`(第396行)——处理握手期的信令 + +```typescript +switch (msg.type) { + case "peer_joined": { /* 记下机器人的 peer_id */ } + case "offer": { /* 机器人发来 SDP 提议 → 调 handleOffer */ } + case "ice_candidate": { /* 对方的网络候选 → pc.addRemoteCandidate */ } + case "error": { /* 出错 → reject */ } +} +``` +- 按信令消息的 `type`(第29篇那些类型)分别处理:对方加入、收到 offer(转给 `handleOffer`)、收到 ICE 候选(喂给 pc)、出错。 +- `msg as OfferMessage` 等——把基底 `SignalingMessage` 断言成具体子类型再用其字段(判别后收窄的手动版)。 + +### `handleOffer`(第434行)——收到提议、建连接、回应答 + +这是 WebRTC 握手的核心一步,逐段大意: +- **准备 ICE 服务器**:`this.options.iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }]` —— 没配就用 Google 的公共 STUN 服务器兜底(`??`)。`.map` 把配置转成库要的字符串格式。 +- `this.pc = new PeerConnection("rosclaw-frontend", rtcConfig);` —— **建 WebRTC 连接对象**。 +- 然后挂几个回调(这是 WebRTC 编程的典型形态——事件驱动): + - `pc.onLocalCandidate(...)` —— 我方产生网络候选时,通过信令发给机器人(`sendIceCandidate`,第31篇)。 + - `pc.onStateChange(...)` —— 连接状态变化;失败/关闭就标记断开、拒绝所有待处理请求。 + - `pc.onDataChannel((dc) => {...})` —— **机器人开数据通道时触发**,这是关键: + - 存下 `dataChannel = dc`。 + - `dc.onOpen(() => { clearTimeout(timeout); onConnected(); })` —— **通道开通!清超时、调 `onConnected`(就是 connect 里那个 resolve)**——至此 `connect` 的 `await connected` 才放行。**这就是前面说的"resolve 深埋在回调链末端"的落点。** + - `dc.onMessage(...)` —— 收到消息 → 转字符串 → `handleDataChannelMessage`(上面那个路由)。 + - `dc.onClosed(...)` —— 通道关闭 → 清理、拒绝待处理请求。 +- **设远端描述 + 生成应答**:`pc.setRemoteDescription(offer.data.sdp, ...)` 设入机器人的 offer,`pc.localDescription()` 拿到我方 answer,`sendAnswer(...)`(第31篇)发回去。 +- **大意小结**:收到机器人的"提议"→ 建连接对象、挂好各种事件回调 → 把提议设进去、生成"应答"发回 → 之后双方交换 ICE 候选、直到数据通道 `onOpen`,连接告成。**WebRTC 就是这样一套"事件回调驱动的握手",细节交给库,我们只需理解流程。** + +--- + +## 第 495-515 行:清理助手 + +```typescript + private cleanup(): void { + this.rejectAllPending(new Error("Transport disconnected")); + if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; } + if (this.pc) { this.pc.close(); this.pc = null; } + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } +``` + +- `cleanup` —— 拒绝所有还在等的请求、关数据通道、关 pc。和第26篇 local 的对称清理一个精神。 +- `rejectAllPending` —— **断开时把所有"还在等响应"的请求统统 reject**,免得它们永远悬着(调用方的 await 会收到错误而非永久卡住)。 + - `for (const [, pending] of this.pendingRequests)` —— **语法小课堂:解构里跳过某项 `[, pending]`。** Map 遍历每项是 `[键, 值]`,这里**第一个位置空着**(逗号前什么都不写)表示"键我不要",只取值 `pending`。(对照第26篇 `for (const [action] of ...)` 只取键,这里相反,只取值。) + - 挨个清超时 + reject,最后清空 Map。 +- 这是"连接断了,别让等待者吊死"的负责任收尾。 + +--- + +## 整章回顾 + +- `WebRTCTransport`(模式 C)实现第④篇 `RosTransport` 接口,**在 WebRTC 数据通道上跑第⑤篇的 rosbridge JSON 协议**。 +- **它的代码可清晰分成两半**: + - **新内容(前 5 步建连接)**:REST 请求 → 信令 WebSocket → 收 offer → 建 PeerConnection、回 answer → 交换 ICE → 数据通道开通。这是 WebRTC 特有的"事件回调驱动握手",抓流程即可。 + - **旧知识(连上之后收发)**:`publish`/`subscribe`/`callService`/`sendActionGoal`、`registerPending`/`resolvePending`、`handleDataChannelMessage` 的 `switch(op)`——**全是第⑤⑥⑧⑨⑩篇的翻版**,只把"WebSocket 这根管子"换成"数据通道这根管子"(`sendOverDataChannel`)。 +- **关于"存根"**:README/CLAUDE.md 标它为存根,但代码是完整实现(真用 `node-datachannel`/`ws`、握手逻辑齐全)。**这是文档滞后于代码的一处不一致**——读码时以代码为准。 + +**语法点回顾清单**(本章新增/巩固): +- 一条 import 混用值与 `type`:`import { 值, type 类型 } from ...` +- `ReturnType`:让 TS 推断"定时器句柄"的跨环境类型(巩固第⑥篇) +- 把 `resolve`/`reject` 深传进多层回调,让一个 `await` 等一串异步回调汇合(进阶第⑧篇) +- 解构跳过某项 `for (const [, pending] of map)`(只取值,对照第26篇只取键) +- 断开时 `rejectAllPending`:把所有悬而未决的请求 reject,避免调用方吊死 +- 复用:rosbridge JSON 协议(第⑤篇)、按 id 配对(第⑥篇)、请求-响应/动作骨架(第⑧⑨篇)、订阅一对多(第28篇)——全部重演 +- 读码核对文档:发现"标称存根、实为完整实现"的不一致 + +下一份:[`transport/webrtc/signaling-client.ts` 逐行详解 →](31-webrtc-signaling-client.ts.md)(信令客户端:封装 REST 调用 + 信令 WebSocket 生命周期 + 心跳——本篇大量用到的 `this.signaling.xxx` 在那里实现) + + + +--- + + +# 逐行详解 ㉛:`transport/webrtc/signaling-client.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/signaling-client.ts](../../extensions/openclaw-plugin/src/transport/webrtc/signaling-client.ts) +> +> 推荐阅读顺序第 31 个文件,**模式 C 的最后一块、也是整个传输层的收尾**。它是 `SignalingClient`——上一篇 `WebRTCTransport` 反复调用的 `this.signaling.xxx` 都在这里实现。职责很清晰:**封装和信令服务器打交道的两件事——REST API 调用(发现/连接/断开)和信令 WebSocket 的生命周期(连接、收发、心跳)。** 新东西有两个:浏览器/Node 通用的 **`fetch`(发 HTTP 请求)** 和 **`satisfies` 运算符**。同样,这也是真实实现(用了 `ws` 库),不是存根。 + +--- + +## 先理解它和 transport 的分工 + +- `WebRTCTransport`(第30篇)管"WebRTC 连接本身"(PeerConnection、数据通道、SDP/ICE)。 +- `SignalingClient`(本篇)管"**和信令服务器通信**"——即握手期间,借服务器中转消息那部分: + - **REST 部分**:连接前的 HTTP 请求(发现有哪些机器人、请求建立会话、通知断开)。 + - **WebSocket 部分**:握手期间双向交换信令消息(join_room、offer、answer、ICE、心跳)。 +- 第30篇那些 `this.signaling.requestConnection(...)`、`connectWs()`、`joinRoom(...)`、`sendAnswer(...)` 的实现,全在本篇。 + +--- + +## 第 1-14 行:导入 + 回调类型别名 + +```typescript +import WebSocket from "ws"; +import type { /* 一批信令类型 */ } from "./signaling-types.js"; + +export type SignalingMessageHandler = (msg: SignalingMessage) => void; +``` + +- `import WebSocket from "ws";` —— **默认导入** `ws` 库(Node 的 WebSocket 实现;浏览器自带 WebSocket,Node 要装这个库)。注意是默认导入(不带花括号,回忆第13篇 `export default`)。**真导入 `ws` 库 = 真实现,非存根。** +- 一批信令消息类型(第29篇)。 +- `export type SignalingMessageHandler = (msg: SignalingMessage) => void;` —— 给"信令消息回调"的函数类型起个别名(收一条信令消息、返回 void)。 + +--- + +## 第 16-33 行:类 + 字段 + 构造(含 URL 规整) + +```typescript +export class SignalingClient { + private ws: WebSocket | null = null; + private apiUrl: string; + private signalingUrl: string; + private messageHandler: SignalingMessageHandler | null = null; + private heartbeatInterval: ReturnType | null = null; + + constructor(apiUrl: string, signalingUrl: string) { + // Normalize: strip trailing slashes + this.apiUrl = apiUrl.replace(/\/+$/, ""); + this.signalingUrl = signalingUrl.replace(/\/+$/, ""); + } +``` + +- 字段:`ws`(WebSocket 实例,连上才有)、两个 URL、消息回调、心跳定时器句柄。 + - `heartbeatInterval: ReturnType | null` —— 心跳定时器句柄类型(`ReturnType`,和第30篇 `setTimeout` 同理;注意这里是 `setInterval` 反复触发的那种)。 +- 构造函数做了件细致事——**规整 URL**: + - `apiUrl.replace(/\/+$/, "")` —— **语法小课堂:`字符串.replace(正则, 替换串)`。** 把匹配正则的部分替换掉。 + - `/\/+$/` 是个正则:`\/` 是转义的斜杠 `/`(因为 `/` 在正则里有特殊含义,要转义),`+` 是"一个或多个",`$` 是"字符串结尾"。合起来 `/\/+$/` = **"结尾处的一个或多个斜杠"**。 + - 替换成 `""`(空)= **去掉 URL 末尾的斜杠**。 + - 为什么?因为用户配的 URL 可能写成 `https://host/` 或 `https://host`,末尾有没有斜杠不统一。规整掉,后面拼路径(`${apiUrl}/api/...`)才不会出现 `host//api` 这种双斜杠。**这是"输入规整"的好习惯**(呼应第27篇 `normalizeType` 规整类型字符串)。 + +--- + +## 第 35-66 行:REST API 三方法(`fetch` 登场) + +```typescript + async discoverRobots(): Promise { + const res = await fetch(`${this.apiUrl}/api/robots/`); + if (!res.ok) { + throw new Error(`Discovery failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; + } +``` + +- **语法小课堂:`fetch(url, 选项?)` —— 发 HTTP 请求的标准函数。** + - `fetch` 是现代 JS(浏览器和新版 Node 都内置)发网络请求的方法。`await fetch(...)` 拿到一个**响应对象 `res`**。 + - `res.ok` —— 布尔,请求是否成功(HTTP 状态码 200–299)。失败就抛错,带上 `res.status`(状态码如 404)和 `res.statusText`(状态文字)。 + - `res.json()` —— **把响应体解析成 JSON**(也是异步的,返回 Promise)。`as Promise` 断言成我们期望的类型(第29篇)。 +- 这是最简单的一个:GET 请求"发现机器人"列表。 + +```typescript + async requestConnection(robotId: string, request: ConnectRequest): Promise { + const res = await fetch(`${this.apiUrl}/api/robots/${robotId}/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Connection request failed: ${res.status} — ${body}`); + } + return res.json() as Promise; + } +``` + +- `requestConnection` —— **带 body 的 POST 请求**(第30篇 connect 第 1 步调的就是它)。`fetch` 第二参数是选项对象: + - `method: "POST"` —— 请求方法。 + - `headers: { "Content-Type": "application/json" }` —— 告诉服务器"我发的是 JSON"。 + - `body: JSON.stringify(request)` —— 请求体:把 `request` 对象转成 JSON 字符串(第⑥篇 `JSON.stringify`)。 + - 失败时 `await res.text()` 取出错误响应文本,拼进报错(比只给状态码信息更全)。 +- `requestDisconnect`(第58行)—— POST 通知断开,最简单。 +- **三个方法是 `fetch` 的标准用法范例**:GET、带 body 的 POST、检查 `res.ok`、`res.json()`/`res.text()` 取响应。Web 开发里天天用。 + +--- + +## 第 68-117 行:`connectWs`——连信令 WebSocket(Promise 包装事件) + +```typescript + async connectWs(): Promise { + return new Promise((resolve, reject) => { + const wsUrl = `${this.signalingUrl}/ws`; + const timeout = setTimeout(() => { + reject(new Error(`Signaling WebSocket connection to ${wsUrl} timed out`)); + }, 10_000); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + clearTimeout(timeout); + this.startHeartbeat(); + resolve(); + }; + + this.ws.onmessage = (event) => { + const data = typeof event.data === "string" ? event.data : event.data.toString(); + let msg: SignalingMessage; + try { + msg = JSON.parse(data) as SignalingMessage; + } catch { + return; + } + if (msg.type === "heartbeat_request") { + this.send({ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage); + return; + } + if (this.messageHandler) { + this.messageHandler(msg); + } + }; + + this.ws.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Signaling WebSocket error connecting to ${wsUrl}`)); + }; + + this.ws.onclose = () => { + clearTimeout(timeout); + this.stopHeartbeat(); + this.ws = null; + }; + }); + } +``` + +- **整体是"把 WebSocket 的事件回调包装成一个 Promise"**——和第⑥篇 client 的 `connect`、第⑧篇思想一脉相承: + - `return new Promise((resolve, reject) => {...})` —— 返回一个承诺,"连上了就 resolve、出错/超时就 reject"。 + - 10 秒超时(`setTimeout` → reject)。 + - `this.ws = new WebSocket(wsUrl);` —— 建 WebSocket,连 `信令URL/ws`。 +- 然后挂四个事件回调(WebSocket 的标准事件): + - **`onopen`(连上了)**:清超时 → `startHeartbeat()` 开始心跳(见下)→ `resolve()` 兑现承诺。 + - **`onmessage`(收到消息)**: + - `const data = typeof event.data === "string" ? event.data : event.data.toString();` —— 消息数据可能是字符串或二进制,统一转成字符串(三元 + `typeof`,第⑥篇)。 + - `JSON.parse` 解析(失败就忽略,空 catch)。 + - **自动回心跳**:`if (msg.type === "heartbeat_request")` —— 服务器要心跳,就**立刻自动回一条 heartbeat**(`this.send(...)`),然后 `return` 不再往下。这是"保活"机制的一半(另一半是下面主动定时发)。 + - **语法小课堂:`satisfies` 运算符。** `{ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage` —— `satisfies` 检查"这个对象**符合** `HeartbeatMessage` 类型吗",符合就放行、不符合编译报错。 + - **它和 `as` 的区别**:`as` 是"别管了,我说它是这类型"(断言,可能骗过 TS);`satisfies` 是"请帮我**验证**它确实是这类型,但保留它本来的精确类型"。`satisfies` 更安全——它不放松检查,只确认你写对了。这里用它确保心跳消息字段写对(`type`/`timestamp` 齐全且类型对)。 + - 否则把消息转交给登记的 `messageHandler`(第30篇 `connect` 里登记的那个 `handleSignalingMessage`)。 + - **`onerror`(出错)**:清超时 → reject。 + - **`onclose`(关闭)**:清超时 → 停心跳 → 置 null。 +- 这就是第30篇 connect 第 2 步 `await this.signaling.connectWs()` 等的东西。 + +--- + +## 第 119-179 行:消息收发 + join/answer/ice 几个发送方法 + +```typescript + onMessage(handler: SignalingMessageHandler): void { + this.messageHandler = handler; + } + + send(message: SignalingMessage): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("Signaling WebSocket is not connected"); + } + this.ws.send(JSON.stringify(message)); + } +``` + +- `onMessage` —— 存下消息处理器(第30篇调它登记 `handleSignalingMessage`)。 +- `send` —— **发送出口**:先确认 WebSocket 还开着(`this.ws.readyState !== WebSocket.OPEN` 检查连接状态),否则抛错;然后 `JSON.stringify` 后发出。所有发送都走它。 + +```typescript + joinRoom(roomId, peerId, peerType, sessionId): void { + this.send({ + type: "join_room", + room_id: roomId, + peer_id: peerId, + peer_type: peerType, + session_id: sessionId, + } satisfies JoinRoomMessage); + } +``` + +- `joinRoom` / `sendAnswer` / `sendIceCandidate` —— 三个**便捷发送方法**:各自组一条对应的信令消息(第29篇的 `JoinRoomMessage`/`AnswerMessage`/`IceCandidateMessage`),用 `satisfies` 确认格式正确,再 `send` 出去。 + - 这就是第30篇 `joinRoom(...)`、`sendAnswer(...)`、`sendIceCandidate(...)` 的实现。 + - 每个都用 `satisfies 对应类型` 把关——保证发出去的消息符合协议。**这是 `satisfies` 的典型用途:构造要发给外部的结构化消息时,让 TS 帮你核对字段。** + +--- + +## 第 167-179 行:`close` + `isConnected`(getter) + +```typescript + close(): void { + this.stopHeartbeat(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + get isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } +``` + +- `close` —— 停心跳 + 关 WebSocket + 置 null。 +- `get isConnected()` —— **语法小课堂:`get`(取值器 / getter)。** + - 前面加 `get` 的方法是个"**伪装成属性的方法**"。用的时候写 `client.isConnected`(**不加括号**,像访问属性),它背后会执行这个函数返回值。 + - 这里 `isConnected` 计算"ws 存在且处于 OPEN 状态"返回布尔。调用方写 `signaling.isConnected` 就像读一个属性,但其实是实时算的。 + - 用 getter 是为了"对外看像个只读属性、内部其实有计算逻辑"。比写成 `isConnected()` 方法更自然(语义上它是个状态值,不是动作)。 + +--- + +## 第 181-196 行:心跳的开/停 + +```typescript + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.send({ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage); + } + }, 15_000); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } +``` + +- **语法小课堂:`setInterval(回调, 毫秒)` / `clearInterval(句柄)`。** + - 回忆第②篇 `setTimeout`(**只触发一次**)。`setInterval` 是它的"反复版"——**每隔指定毫秒就触发一次回调,直到 `clearInterval` 停掉**。 + - 这里每 15 秒发一条心跳(`heartbeat`),告诉服务器"我还在线"。这是**主动心跳**(和上面 `onmessage` 里"被请求才回"的被动心跳互补,双保险保活)。 +- `startHeartbeat` —— 先 `stopHeartbeat()`(防止重复开多个定时器)再开新的。每次触发前检查连接还开着才发。 +- `stopHeartbeat` —— 有定时器就 `clearInterval` 停掉并置 null。 +- **保活机制小结**:长连接容易被中间设备掐断,靠定期心跳维持。本类两路心跳——主动每 15 秒发、被收到 `heartbeat_request` 时回——确保连接不被误判为死连接。 + +--- + +## 整章回顾 + +- `SignalingClient` 封装了"和信令服务器通信"的全部细节,给 `WebRTCTransport` 当底层: + - **REST**(`fetch`):`discoverRobots`/`requestConnection`/`requestDisconnect`——连接前的 HTTP 交互。 + - **WebSocket**:`connectWs`(Promise 包装事件 + 超时)、`send`/`onMessage`、`joinRoom`/`sendAnswer`/`sendIceCandidate`(便捷发送,`satisfies` 把关)。 + - **心跳**:`setInterval` 主动发 + 收到请求被动回,双路保活。 +- 它和第⑥篇 `RosbridgeClient` 角色相似(都是"管连接、收发底层消息"的客户端),只是一个走 rosbridge WebSocket、一个走信令服务器。 + +**语法点回顾清单**(本章新增/巩固): +- `fetch(url, {method, headers, body})`:发 HTTP 请求;`res.ok`/`res.status`/`res.json()`/`res.text()` +- **`satisfies 类型`**:验证对象符合某类型但保留精确类型(比 `as` 安全——核对而非强断言) +- `get 属性()`:取值器(getter),用时不加括号、像读属性但实时计算 +- `setInterval`/`clearInterval`:反复定时触发(vs `setTimeout` 一次);心跳保活 +- `字符串.replace(/\/+$/, "")`:正则去掉结尾斜杠(URL 规整,呼应第27篇) +- Promise 包装 WebSocket 事件(`onopen`→resolve / `onerror`→reject + 超时)(巩固第⑥⑧篇) +- `ws.readyState === WebSocket.OPEN` 判断连接是否就绪 + +--- + +## 🎉🎉 传输层全部讲完!三种模式集齐! + +到这里,**第七部分(模式 A / C)讲完,整个传输层的三种实现全部读完**: + +> **模式 B rosbridge(⑤–⑩)** + **模式 A 本地 DDS(26–28)** + **模式 C WebRTC(29–31)**,外加它们共同的接口(③④)和工厂(⑪)。 + +三种实现各走不同的"管子"(WebSocket / 本地 DDS / WebRTC 数据通道),但**都实现同一个 `RosTransport` 接口**——这就是第④篇那个"接口抽象"的全部价值:上层(工具、命令、服务)完全不用管底层用哪种,换模式只换工厂里 `new` 哪个类。你现在对"面向接口编程、多种实现可替换"有了**三个活生生的例子**支撑,这是非常扎实的收获。 + +**整个 `@rosclaw/openclaw-plugin` 插件——31 个源文件——已全部逐行读完!** 只剩最后一篇:第二个扩展 `openclaw-canvas`(实时仪表盘)。 + +下一份:[`openclaw-canvas` 逐行详解 →](32-openclaw-canvas.md)(第八部分、收官篇:另一个独立扩展,实时可视化机器人状态) + + + +--- + + +# 逐行详解 ㉜:`openclaw-canvas`(收官篇) + +> 对应源文件:[extensions/openclaw-canvas/index.ts](../../extensions/openclaw-canvas/index.ts)(仅 14 行)及该扩展的配置文件 +> +> 推荐阅读顺序第 32 个、**最后一个文件**。它是项目的**第二个扩展** `@rosclaw/openclaw-canvas`——一个"实时机器人仪表盘"。和前面 31 个文件不同,**它的代码现在是真正的"存根(placeholder)"**:`index.ts` 只有 14 行、什么实质功能都没做。所以本篇换个讲法:**代码部分几分钟讲完,重点带你读懂它的"设计意图"**——一个还没动工、但已经把蓝图画清楚的扩展长什么样,以及"存根/占位"在工程里的正当价值。读完它,整个 32 篇逐行系列就圆满了。 + +--- + +## 先理解它和主插件的关系 + +- 前面 31 篇讲的都是 `@rosclaw/openclaw-plugin`(主插件,负责"控制机器人")。 +- 本篇的 `@rosclaw/openclaw-canvas` 是**另一个独立扩展**(monorepo 里 `extensions/` 下并列的第二个包,回忆 CLAUDE.md 的结构),目标是"**可视化机器人状态**"——实时仪表盘:电量、摄像头画面、地图、遥控摇杆等。 +- 关键分工(README 讲得很清楚): + - **聊天界面**(WhatsApp/Telegram 等,主插件管)适合"下命令、看文字回复"。 + - **仪表盘**(本扩展管)适合"看连续更新的画面和数据、做高频遥控"——这些塞不进聊天消息。 + - 用消息 App 的普通用户看不到仪表盘;只有用 **OpenClaw 原生 App**(macOS/iOS/Android)的操作员能看到。 + +--- + +## 代码部分:14 行的存根(很快) + +### `index.ts` 全文 + +```typescript +/** + * OpenClaw Canvas extension for real-time robot dashboard. + * Phase 3 — placeholder only. + */ + +interface OpenClawPluginAPI { + log: { + info(msg: string, ...args: unknown[]): void; + }; +} + +export function register(api: OpenClawPluginAPI): void { + api.log.info("RosClaw Canvas extension loaded (Phase 3 — not yet implemented)"); +} +``` + +逐段(语法全是老朋友,快速过): + +- 注释直说:`Phase 3 — placeholder only`(第三阶段,仅占位)。**诚实标注"还没做"**。 +- `interface OpenClawPluginAPI { log: { info(...): void } }` —— **一个极简的局部接口**。 + - 注意它**只声明了用得到的那一点点**:一个 `log.info` 方法。对比主插件第①篇那个完整的 `OpenClawPluginApi`(有 logger、registerTool、registerService……几十项),这里**只要 `log.info` 就只声明 `log.info`**。 + - **语法小课堂:`...args: unknown[]`(剩余参数 rest parameters)。** 回忆第25篇解构里的 `...rest`,这是它的**函数参数版**:`info(msg: string, ...args: unknown[])` 表示"第一个参数是 `msg`,**后面任意多个参数**都收进数组 `args`"。这样 `info("x")`、`info("x", 1, 2)` 都能调。常见于日志类函数(消息 + 若干附加值)。 + - **为什么自己声明而不 import 主插件的接口?** 因为这是**独立扩展**,刻意不依赖主插件——只按自己需要描述"宿主至少得给我一个能打日志的 api"。存根阶段,够用就行。 +- `export function register(api): void { api.log.info("...not yet implemented"); }` —— **唯一的函数**:被宿主加载时,只打一行"已加载,但第三阶段尚未实现"的日志。**仅此而已。** + +### 配置文件(一眼带过) + +- `openclaw.plugin.json` —— 扩展清单:`id`、`name`、`version`、`description`、`entry`(入口指向 `./dist/index.js`)。宿主靠它认识这个扩展。 +- `package.json` —— npm 包定义:注意 `"dependencies": {}`(**空依赖**——存根什么库都不需要)、有 `build`/`typecheck` 脚本。`"type": "module"`(纯 ESM,和全项目一致,回忆 CLAUDE.md)。 + +**代码部分到此讲完**——确实就这么点。下面是本篇真正有价值的部分。 + +--- + +## 重点:读懂一份"还没写、但想清楚了"的设计 + +`index.ts` 是空的,但它的 [README](../../extensions/openclaw-canvas/README.md) 写得非常充实——**这本身就是一种值得学习的工程实践**:动手写代码前,先把"要做什么、怎么做、依赖什么"想透并写下来。我们顺着它的蓝图,认识几个新概念(你不必掌握细节,理解"是什么、为什么"即可)。 + +### 1. Canvas 是什么——原生 App 里的一块 WebView 面板 + +- "Canvas(画布)"不是网页、不是浏览器标签,而是**嵌在 OpenClaw 原生 App 里的一块 WebView 面板**(WebView = App 内嵌的网页渲染区)。 +- 三个平台各自的承载方式不同(macOS 用 WKWebView、iOS 用 SwiftUI+WebView、Android 用 WebView),但对扩展来说都是"一块能显示 UI 的面板"。 +- AI 通过一个内置的 `canvas` 工具控制它(`canvas.present` 显示、`canvas.navigate` 跳转、`canvas.a2ui.push` 推送界面……)——**回忆第14篇"工具"概念**:这又是一组工具,只不过是 OpenClaw 内置的、操作画布的。 + +### 2. A2UI 是什么——"用 JSON 描述界面"的协议 + +- 仪表盘的界面不是手写 HTML,而是用 **A2UI**(Agent-to-UI,一个开放标准)来描述。 +- **核心思想:agent 用 JSON 描述"界面长什么样、数据是什么",客户端负责把它渲染成原生组件。** 三类关键消息: + - `surfaceUpdate` —— **描述组件树**(界面由哪些卡片、文字、按钮组成)。注意它用"扁平邻接表"——每个组件一个 id,靠 id 互相引用拼成树。 + - `dataModelUpdate` —— **推送数据**(如电量 "87%")。绑定了数据的组件会**自动重新渲染**(响应式)。 + - `userAction` —— **用户操作回传**(点了急停按钮、拖了滑块)。agent 收到后处理、再推新界面。 +- **这和我们学过的东西呼应**:第15篇工具参数用 TypeBox 描述"形状"、第23篇用文本注入给 AI——都是"**用数据/声明来表达东西,而非写死的代码**"的思路。A2UI 是这个思路在"界面"上的体现:**声明式 UI**。 + +### 3. 数据怎么流——它不直接连 ROS2 + +README 的架构图点出一个重要设计:**这个扩展不自己连机器人**。它没有自己的传输层(不像主插件有第④篇那套),而是: +- **路径一(agent 中介)**:让 AI 调主插件的工具(`ros2_subscribe`、`ros2_camera_snapshot`……第15–21篇那些)拿数据,格式化成 A2UI 推给画布。简单,但每次都过一遍 AI,有延迟。 +- **路径二(网关方法)**:主插件注册一些"网关方法",画布前端直接调它们拿实时数据,**绕过 AI**(适合高频更新,如遥控)。但这要求主插件先实现网关方法(README 说"Issue #10,目前搁置")。 +- **设计上的复用意识**:仪表盘不重复造"连机器人"的轮子,而是**站在主插件已有能力之上**。这正是前面反复见到的分层/复用精神(第④篇接口、第17篇"工具薄薄一层")。 + +--- + +## 为什么留一个"存根"是合理的? + +新手可能疑惑:"没实现,写它干嘛?"——存根/占位在工程里有正当价值: + +1. **占好位置、定好边界**:`register` 的签名、扩展的 id/入口、在 monorepo 的位置都先确定了。将来填实现时,骨架是现成的,不用动结构。 +2. **写下设计、对齐共识**:那份详尽的 README 把"要做什么、怎么渲染、数据怎么流、依赖什么前置条件"都讲清了。**团队能据此讨论、评审,甚至发现依赖未就绪(如网关方法 Issue #10)——在写代码之前就暴露问题**,成本最低。 +3. **诚实标注状态**:注释、README、日志都明说"Phase 3 — not yet implemented"。**不假装、不误导**——回忆 CLAUDE.md 的规范:"未实现的代码用 `// TODO` 标记,并附正确的类型签名"。这个扩展就是这条规范的整体体现:**类型签名对(`register(api)` 形状正确)、功能诚实标注未做。** +4. **可独立演进**:作为独立扩展,它不拖累主插件——主插件 31 个文件已是完整可用的产品,仪表盘可以将来从容补上。 + +> **一句话**:好的"存根"不是偷懒,而是"**先把蓝图和骨架立好,把功能诚实地标为待办**"。它和"假装实现了、其实是空壳骗人"有本质区别——前者透明、后者欺骗。 + +--- + +## 整章回顾 + +- `@rosclaw/openclaw-canvas` 是项目第二个扩展,目标是原生 App 里的**实时机器人仪表盘**,目前是**诚实的存根**(`index.ts` 仅 14 行,只打一行日志)。 +- 代码层面只有两点新语法:**极简局部接口**(只声明用得到的 `log.info`)和**剩余参数 `...args: unknown[]`**。 +- 真正的价值在它的**设计蓝图**:Canvas(App 内 WebView 面板)+ A2UI(用 JSON 声明式描述界面)+ 不自己连 ROS2(复用主插件能力,agent 中介或网关方法两条数据路径)。 +- 它示范了"存根"的正当用途:**占位定界、写下设计、诚实标注、独立演进**——是工程实践的一部分,不是糊弄。 + +**语法点回顾清单**(本章新增/巩固): +- 剩余参数 `function f(a, ...args: unknown[])`(收集任意多个尾部参数,函数版的 `...rest`,呼应第25篇) +- 极简局部接口:只声明真正用到的成员(vs 第①篇完整 `OpenClawPluginApi`) +- 存根/占位的工程价值:正确类型签名 + 诚实标注未实现(呼应 CLAUDE.md `// TODO` 规范) + +--- + +# 🎉🎉🎉 全系列完成!32 篇逐行详解到此收官 + +你从第 01 篇 `plugin-api.ts` 一路读到这第 32 篇,**把整个 RosClaw 项目——主插件 31 个源文件 + 第二个扩展——逐行、逐符号读完了。** 回头看走过的路: + +| 部分 | 篇目 | 你掌握了 | +|---|---|---| +| 一·契约与配置 | 01–02 | 接口/类型基础、Zod 配置校验 | +| 二·传输抽象 | 03–04 | `RosTransport` 统一接口、判别联合 | +| 三·rosbridge | 05–10 | WebSocket 协议、按 id 配对、请求-响应/动作骨架、适配器 | +| 四·装配启动 | 11–13 | 工厂、服务生命周期、插件入口 | +| 五·工具层 | 14–21 | AI 的 7 个"动作按钮"、TypeBox、竞速 Promise | +| 六·钩子与命令 | 22–25 | 三种交互方式、安全校验、能力注入、`/estop`·`/transport` | +| 七·另两种传输 | 26–31 | 本地 DDS(rclnodejs/递归/缓存)、WebRTC(信令/fetch/satisfies) | +| 八·第二扩展 | 32 | 存根的设计意图、A2UI 声明式 UI | + +**更重要的是,你不再是"新手中的新手"了。** 一路上「语法小课堂」累计讲了上百个 TypeScript 语法点(都收录在 [README 的语法学习地图](README.md)里可随时反查),从 `interface`、`async/await`,到 `Promise.all`、递归、`createRequire`、`satisfies`、类型守卫……这些是读**任何** TypeScript 项目都用得上的通用功底,而不只限于这一个项目。 + +而且你练出了几样比语法更值钱的东西: +- **看懂"分层"**:接口在中间、实现可替换(三种传输模式是最好的例子)。 +- **识别"模式"**:缓存(查→建→存)、Promise 包装回调、按 id 配对、引用计数清理……同一套路在不同文件反复出现,你已能一眼认出。 +- **对照"现状 vs 文档"**:第21、30、31 篇我们都发现并诚实指出了代码与文档的不一致——这种不盲信、以代码为准的眼光,是真正读懂代码的标志。 + +接下来建议你:挑一个最感兴趣的方法,**自己改一行、跑一下**,看会发生什么——"读"过之后,"动手改"是把知识焊牢的最后一步。 + +← 返回 [逐行详解系列 · 总览](README.md) | ← 返回 [核心代码导读(鸟瞰版)](../04-核心代码导读.md) + +**恭喜你读完整个系列。** 🚀 + + + +--- + diff --git a/learn/.build/img/00-1.png b/learn/.build/img/00-1.png new file mode 100644 index 0000000000000000000000000000000000000000..27cb0015aeea3bc833869f256d8f5ba184d3d563 GIT binary patch literal 163349 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 F`amB$u|U~PE`DCvhw_2 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 literal 0 HcmV?d00001 diff --git a/learn/.build/img/00-2.png b/learn/.build/img/00-2.png new file mode 100644 index 0000000000000000000000000000000000000000..3ada3a27d4941923505cf5f5911ab723e221b936 GIT binary patch literal 76222 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

GK5>g({IHcibr3v}CSTQpBVIlfp8iUD#RNHV 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 literal 0 HcmV?d00001 diff --git a/learn/.build/img/01-1.png b/learn/.build/img/01-1.png new file mode 100644 index 0000000000000000000000000000000000000000..d05ff91a7172975f9ff8821809d97919a9dc5275 GIT binary patch literal 44839 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 Y1b`++NOTFvL3-(NRIs! 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 OkDRo!RGFknz<&VDsmYxH literal 0 HcmV?d00001 diff --git a/learn/.build/img/01-2.png b/learn/.build/img/01-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d36ae0607c7eeb626032e8235bdf31c0fb624209 GIT binary patch literal 37562 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 WjydX_e@CQ(fMIEN(e#xG3im&WvkQL! literal 0 HcmV?d00001 diff --git a/learn/.build/img/01-3.png b/learn/.build/img/01-3.png new file mode 100644 index 0000000000000000000000000000000000000000..cccc003bbefbb9d4bedf484850371876a2aa158e GIT binary patch literal 45163 zcmce+g;!hc6D^DscPkFX-Q69ELvb(ePH-(!oZ{{jg1dWxQZ%@`LvVM#y!Za@zwoWB zoMdI4JkL3EW@gXq*->gLa%jjz$WTyFX!2jBzd=DEe1d#;L4=1KF?|LZAs_H&igMCW zAOF1yy33NGpuRxKOG{{a=bWy4d27rQ_g!5m&v;Wi-cz|z@;#7U65QP2t>I~gw@>HisF zMNql*HS;J6LK!qeh5k^lrSv4?0wNl+%3J#C}qk9Vp(Yoq%lnf}4 z1|pr3wrVGZO0KOhXsvI4n{7&;ZbE>p`#&2m{oghB zYTk|sZP=;1tBOV7QtDXwGGa`xYkrJ$VrRUuX9(vK{BFZJ?tl|vAQCwjlvgj4j~0&0 z!_5v%-nDtg-;Q{6uDGM#&~rtmK;DSR`k0?(S>Y~dZ8XO!!jzveh5kHNqC!V(=;6u! zzXzL`_kWD`3;7+#d3}o)odDJwhF~3*BmFqOGKfh@R4Z}4js9x~P2ynoi+op4cpH=b zbaGL3B`Xxpo8Rz6Iu_&>mlw2zcdgjvROhBmpq2C-tzEdb^oGz97@aZ{GuV9zIXXr; z6mgYDI6fy8vkV$3emU?U;V9dgPHq%gld-eeT_LZ%NsX{#0P=_bUt3TLxoNS6VTMoL z?bnC^nrgX$P+L1X#1EsP?CCb}S^dk~NFjN+ifgGsQ6i0qifaJy&X-W&H}C+_oDil(}{K_Q*cw29&6~Gs^Y$QXngYh z_IX4i_YOYURqchUxfa(U?EhIO)9^P6g*1hm8BI;#$a((J5eSArWOPT*oZ9L`dlFq4 zvC~75(iJ}q$K4Iu*9}!X2OjjYN&ZmXXpSSfCQ4lK#`krx$@wc`eO_LBTNG!)6V6g2 zfvoCr6XiK8QlY$|5z(L2Aqwi@6;f1hIA&NuGG|Q9sd}XU>(SD_Ntn2jk&-)q-5wf7 zjud%p;!C%U3(3i}^2ND21cbOL+$^FxRpz!qU)$;jI&5l!9M&3FsHpk5SSbdp4NTtp z_`fp}){vsI5?PWzl#TZ#5v)^V(Vsj0xAWsY(W3U+pvu*$w-J}{#<6ZLjS8aj|NI+D z9ub*By3pl}t#veO(yk!7e~7sDc~{d%jUpO1cj=}VC%^&yNdBm9;d>Mq`~j6X8Lxzx z$VY5P@+iLOL$m8vn{}>9Y=Zs}T{DTO7%9o*Z^BJiW<>dJSNhIABDlhp8^4%}b^AXn zm;DMWz?9@)PPIG`l%1&ijL7brHe7b)5qlmoiLMwatcbWQv)40nG*S<>%B^VVC6A1( zep^)=cu6|7IoY=MVJueB^$k9>G8P&4PhJVoLPe!l+D1+KuU1FIy~WR606sqz>;JOL zx-#|;NyKgGbe*hP5<`* z&y7_S5qa}JtN+Qdptt5A2tezw^3*1w{5aNk%Sf|8WieX4TicOqvVf!oX;Z8Q?EF28 z)-!SCWE~p+?0~R?owL)rur|k}KM6QJV6EFd0*^%EdJw^fa{-cHB@xAg2q4DT^g~S>-1!_s zi@*mR++Xlh50$kLI%@l{Y3S=qDyB?oy!LZh9v=3z0#P#k!Kq7F-Ar7X?t~elstR{r zCx=6(2BR&Wu4LU^N*m)*nlaHzcLCq0QQSGd7)dI4{t~dmU_o^(h6~Dy$Tvj~Pc=#Y z(3)a6HZP+er6LwCD1Eg^fOdr?#7$(E_dC!sp_|9S$Cr97BD%#@1aC)d`L_Hd8ujm+{CmJrXOmVf0mj0ZT{Z_{*cgh3QUHLIuPp zA?N!L3;&Bi&za`-H1N>2m7N_0ho%`ZMe6sAi z-!m9kra($`HUqKIJRj*C{rcWFHTv!pMljX?AFR%?$rG^KmQZTi0h;A-rR=p6N*9Vh;r!sZUyL+j5`T&Uo2;8kfb7 zZui->yzU$N?(r>5aw%FRnu?PnqOMIT6qbc(2lDmxK_z->4}}PJPUW23}|}=>1rNhfccuF8zOk4w=J-PZs7! zY@RVjMk3@_Y1{JwW*L&WkxadvTHIOuh~B;n{$vlx0Xd{em`|*39~L{caL~`jLA8$u z`gBP-5MTeZ5MhTvHp3IB*juHG;%qn3hJZR5tq>_B- z7P?QXDp6-P(|69?KzOwo#bOOosxv#HE7$bIV9ENUx(`E6FuDQ1i|F!mdTwwH=#t@p z129jZtZ*19v~aiY#1x#zIz+U!dJ3DvpIeCu_csYscIm)W7n^E|`wZ`6qn=zqh?YV+ zOA3IV8H#gfUZB&&>ds(QwEZyea+ONfEV+gb0p6vLTZUWeZ18iCAX)9=x*i9TYni|_ ztd5CTZL3GF4i9^=EyPfNx{buf0s0}a*jVkB^0eDipUpzkAckT(5)`C{;-dUhT&isl z-eiKLuC@T+hBI^tLQ>BDs<3=03(a$+Q_XcY#xrQ~Yg(?zrh~vIPE6I&;;H-#C3aDD z3?DJz?3hVPo1{JW0r4y+~+#P`uuKgQ}Y(m2%x_l%Ij+YOQI)a$^Pc`>% zhN)&4*a!b*mC=rqkGO>?hl9r|j&fld!52X$YKrr(_&uF}!{Pc{P=vVPI}>Lfc?XfZb*9y?HRiu6W#;pXeO?RbAGUTb+Y4B5Ag<+A4!ODGtfIcaf^K2 z`&XHbyVyIP9X)G~R8y1Pudv<=L}!o`O|RP#URMqJg$sb+S`U8N-Ei}Sr2D0Li*!ZJ z5-{PH=JaT1Db+7ZFhHFtl|B~iE~k5wTG)Rk^faSEXLaidiR^#|Ka47D!1f)KCgY#Ks(r>L^Zzc&xwyEI{gUXa!&C&O0wpVM)X(kFm(x zgn$#jubgU;{3p&*K5fZ=u!{c82$^+U0UsWYVr96tp89P3WygP66wqU4 z{p1s++8DkGQ7<4A-wh{D@MW74qJ)6L8oHqV7KVseBP}e#;(32ru5pq@L0jc*#ae`O zM_gHxj(o6PM_LoZ<%==Ed7tMdr`ukLrlq)sI1oYDKAq>%&Y3GPk5fr!O%Uy9{oi2Z zBr5^Wlm|Q=k=bD(_!@yWn)yvFTbOb>)*&UqU$0`#e)e) zfyT4t@8>j^Z`_h=q}<7};JOH zM0)Y82)PHkOCK|A0;Qv)Q0J8Eyt2(whODr*Y`E8{-2fx&*oeOwvasGQ5<#(9sM6S8 z`F4T(Po-e*ilH6y~3v zwE0+Jui6+u$=0qT?eL_F+gHiGWdKI&eQ-j*AxXL!Acr&_Q)QoMX~Vbm=FTLMbxo3A z-7RNZ^ecG8$%9L+tNXSCFMbw~TYE5Jc9q||_r#NwoZKF`+aa}T+~XEST*%k3elXh1 zWZ2`z0J_5c^l=_Yh@Xm4;aZo+WrkHm|KH<$DL@AwbEPr7RBtV04ceT+#ZzC}u_eh#(G?;4X* z6&C+KvD4I3E5K3DAs`y`I>$$EQj2RJx&ZYnB_(XgtbVyvruFZfgC5TVc_~!;=5Dls zUgJcAMbo6718dk#(g7|n{7ht$J-Rr<5@GU|oit?*H>i6qZu}z`r%34s7 zR%fFB<+LiqO#fUMv<=odY~pf=qcFX?pO48Od6U{13zaYQ+zWdJkpX|%!J*VHYaeZ zKKFtEMTwhZAUW9s8l3p-Yu)+$V*)5!5T%W}TGg*Q2Uph8>v?QXAzkIs`@T~0B)zJbuKf2Lnx<0NOa zey8as_l~ zHGNbA&sP@_K{=#(vpM_$IZ|>M-|8i4B1GP~Hz#vI#OA}W>~2SmuiX}Chaa7P+5^%b z-^a+ywYoi9Y99u6E}~)*%;z+UU0v04P5GSG#l5BZL*9KVGIlldw76b3JXSZ0G5@4( zU?rneDW;57fQn6&3Z`pik<4QMp|D<;CxNnlo{Yf?w;ty#q*luMfe`3koBg z%^w;X+Li*=4IJe?8_u3b3#c)#qWO3 zZ9SPwYqKg#+bfm`82jO-M7iG@fN;o@FoJhyO#oOy+G<&60~^G)Y_(gy zcc;3^l1Sp>Ey8x2%uQw6<%xP@1fp=;relZyvg-5uCoNfqDxMJK;5o1*@VV&sn?fYX zBirfIC!%$uI94S)9+G4Uw_{0*-%{2~I`(?aD9!A+30ILL9y5Xv5uf2Bkd=cBv z0S)oz1~h%*i@r06jL3PlKmYbEoH(mWOgR0jg)BT3AiNRB@rT>=dZxC}kL_pK# zhnKe==FfB1{7oMSk~K9J16gbtxA*MA!fTJWHzs;LW{r&lEZ5xlU}uRNce^i=YePxQ zNST2_viX19w~Z=g9aT<+2cmMV`P;$Fi}B&ufxHpnCU4S1$f<~EDEozUWM3#b|JZVz zuXTpjU;y@(MvA?Cy*ckD9v<0_*dc#txCg~`y`2boxB0Q(WUK1c`?k;Sbm5FY1@flX z81B{oM9?V8=s<1rePbL}B`wxZJ$X#O^l}Spx4xdd^-DG{!qjUMp6V>In%ac9^gV(@ z6`djZg3K1Giqu8s3k_v7OKVKB!xB^{X+d@Qa-Ns<*CTlQ?)lqf=aE(*W>G<$(<36y z`rA~2wuNgR;MND-@_t1%sxqz0AOZ^2&i>DrRLStdXx&V}Pep z`yQGwIyXYn49jAORv$UTLt_K#BjVC`>vbG)=F7^^dV-KW=7r4Qojw49NhK#cB~8l~ zbIUQh2iyh}^K#b$MxWUh2z6}ich~z%pA*6^BME9$o~}4o7K$U=f?|3oq3E?pQZfs- zlV+}EFQ`E2IH??*%~LJYSlVne(?(l795{Y1GEDGs;2`>B6N^*a6F^B>kAa z%Bubglglk}qB+N}vy*57fCFlYRo)V*ScDP~Z+LWkC!ed#<%Kg` z{d{J#T11L*cRoBCq%+mJCkzMq5ic@tCgk2z|;eD>&JvVJoqX|hMdS! z9L_g&$(vdmZV;0+7POfJIj^$r=t$u!ac8m9td5fXQ2Xer>Dp$q3bOy z%p}s`{zg;a!iOCxlnpPuH2zPHi5aSRMFk_f#WrVCneOL4Ft9JQFTGEPaG@P0AS6G8 zp|6nHcsFn>oq0h7d9ANLY)ej@@H?h0{^aaV_4-o~JMG zg#gsN%cVYg{TLk5t4ASlio9Yq@<#vxQ5^ApVjQ0P>di-W@cm^ophy)F7=~WM#dW1;+h?-EW{B_-gh%h~H2Nt^4k~Do4usp#L z;xQ@1L=^)=TZVrRnQO!iSzEkdPtgqC3GbHupeY!}(1WFJJnn{C+@p0y!FvmY1!^jN z702+(i=4O949LA-3#^*;=gRfcyAkf|F8Ykis6X{SdeR=U<_vvjza0LvKUbqZeD2w7 zKR4oLq^@1*>nPZC9$5o+SnGghQ)SEIwm}X~XvK&VT-cqO4%HLw-JI(2O!uzZ8<;@$OBw zVEYCvf<$g`F40|5ezC~5bqf<_b0tF2V3V`pT2vC(R*;Cfi|d+@_9a!252sQ z*|ZH53&&5ruT%l5Y`O=R)j7Jm$~9m3V{{?quw5PvlKws!C#%5{WP^?cJ`|)N<5A-K zYC26M$bAgAM^@6H9h8*0evfV@!TCW-w|=WA?T1^SWBw$8d|0Wo@lWJ^BnPF%`zY#w zE?5;p)WRA|=8B8t4Ly5n7C94M!m*RjZ)KZk7x}uA!qOHJk+VYA7PFoo7RXX~(y>54 z3ZIlr2|A48@a$k{?)LG_U`KBEnQ9hIRp7V?S{hV3GJwY9VZS3*jJw)I0D7U{NWIY# z!^6U)57;zC_VWqWsKzV`=404Uw{F;is3<5B+i!F(7aj|F!7sv(f)H%^bAV+)5#eLxy(p6sURnZp9OBR(i|BP>F;&g#zE<7A58M0D0@M`rF_s8U*A zxPt1h+m`!x==RkhgP#hrLTv#scAx$_%-Mi1pvX1d5uYm$j8K>$>}u0aS8&LLS+KYVi=6 z8ybb_ZWv0C`ip8bgR`8@Mw`bx7frhSLoj;1^Nn6S-S!?$D>N$8n zLe?oNDh6savgWRM9!%)a?%1hhhdNz^h%_B7dbbDn-TdCwvAAiXe z6>-3E>!{J_(3Y1Rkkory)fa3zPH}X=G4_o?V(XT1bK^X>RMsrlObtq_QG_6lr5|Hj zZN{{KLno^o-R|5LCg#RBrqm9)XusLNWad zP_38SblddQDCo;7=!=^r?#ZiI88~wv2+myYyGLEcJ=hJWAH+^h&Xb-MR@$#Hty!O0 z-bUPJcRx$Z7+s`3{-d5CEm96_GC>n>?iY@KtDvq2-mHqLzr1*bP{HBlwB1NQ+9p7~ zhvW?%`>MKI|D=*mG@FY>t@2^JIManrRCr>pOq1;Vf0^tJm(+oG{UXTVp%@xr_W%QArZU<}mIwim*_ zD}LALSfmeUL`uoKyAsv3or(oBK^x78oTF9!qgZHQsCEAgFtb}P7sTPmcNc!3s}-^^jbix(3qm`(bfff&w8k68uEFVO6nu_Pj%@x{ZF zSKy>(|Az^FvVfA9^+Q&@jyx>TDNBY)WS#wq6x}=CY$Y2^HR&-0c6F^Pymva>T6Sit*Z)*Xtv51S_Zb&kxo-8SxxANR^ne8Uas z$4W2?1@}R8>LWdw47IL;`&OjW{8_K$NZTqK2}9N+Js}Sy%LyG0Sp#4Lp&|$9(jvj5 z;1f)9NB%$<0`!s(ir;K8De7_#ie6q&3&paeR^!6aMnYh=bm(FEFlJ;)AE(u*!$qO5(qZqaU)yl!-EmZQZb#Xz~T#WtJC+l z1HgYJs{O~>SATzDomvyPt#(t;;qdTojI%Z8*)GExVmEYfObkj#O`|!;)AMwBvX}VX zbiLurn&CQ>jczLzO-%Zp>=7ET({f*5V8U|O#!5kx;Ye28hm`^%nV|2lsX8^tsW$Uf zcPztVH{E)k=^)64pzeFrT@(N0mkYnXb6#yo*JJgsF%*iO$Wf?FCF_b868O`q3H9>u z(f-pomr$ZE-&YCdt}cZjZ-ov(_>*^{Noj%XRMQ}@gq=9Rn*?c2QpgV{mY?^A!(YwnhfoAaxcVH4Se&u6(m8H9V+C(yz~1A z49E!wT5$wuP`hiU*BYh3P!OcyEk5X$19$l4@?Pd{mI@X2N03ey+ zak}MVcHY+^3f5$=;M)G_F3x%e~xdj_QWd#mFM{>9%PTq%fFZ{j?WR1zvja>AG4n} zmHX!B!;LxNH2WeW)hzTM(A z!J+Cq`tfq{b1*k~5RlBQO0>{c2~kSWkgLm$x~%XODx@>-;yt#^ ze!8fN&lMi^%U2$Ngsew@e$KLANN9Efw!d(!*M@hxU*~Os?0nYgQu0EYR-<>a`hHV!7M2%Hy=U>s-I}#;{&e*GtzXBp}ucA@^59NcK$Q@uJ*aRA&M#{@q=I}vP;gB!)`9dQtr_6zV zu14%sLuO><32o|1iz(7&|XCfpmGZHiZOqm*8b_+c`InbOV1|da~fY8!?{u!!OW)sNt8sCN#ifDssUs7g!+jIe_zVdu%&oIsv-5bm}pEfNs z*WV#jF=!xb3XC{F?-MC8`u9Vy4h-B-l$u?SFP5d(CC`_*OB;=bn=f!+>2HVS*-U}> z(Gc)2B-`Zaz&pqx>I1}$)-Gtk$+f&dGG$Y}Dz=c0$PZY685j5dI~eFwHx6{GShz5C z9`(E^6VA(+IK&mGi`Qo|UPB-!_d86@)T#oy;bUPByfQ4$|Rq} z4ULR!mW$Ad7yLtdd&#e^qWoW@9L=kS%uMHQ$W7~YGXyO^?HUv32-;1qu7=iHcPl`e z?xmhxc1WFT_7G5PZuYbpo#5kjjd)(JhV1N&@qBMP9Ai!n_x&k)f_K%VvnCLxrza1W z>ELYY5LDetD?AcK)yux0TV}ga^|_{owXL(0bMNZK`@oh*(tHDEwZFlr6BxGjjP~M? zMn`9`gxQq9TMeEr1&G9x38gdC6XBM~T6&(ZQJaW@jQ(uA`*GOKZ5e7J#JbG&FueJD z96xNv*O9@V9CG$h&mqSC$ojblJLg(dfMcavMD(+xb`f&qRagFEet6Sv+>}mCJZud|qTh7Kl`NYWYF$sx7~{J3ZCn_6c!* zjEszYa$Fa$JaDv`??Qt#Y5SvEtKTXm474PtvNXH2uoAIi1K;${ZPg-P5bn0XPbLj! zcW&8^zQQwT?hGt<+U4p`bAWTF^T+4MnF>97(fq656ZCz-Nzf!~{;}je?@ukXY~e=5 zYVL_8Y$Dt^^MseMbM^mVn1}9lJxTdP9DfeHdO(^!fp*g20joatIpJ0`HMAbs5IZ0| zIF|55Rjrs?48EdBHW*bpS4#H~fh>Q>?@WTmniPDeLqIrx*VC>~#mzTdqZLN4#O=j4 zuBF96JMqg95|(+AT3_mQ7>Wh$aM!af@?u_4Pdb45teY1q%lLx6hWww#CAC-x!%jRt zxK8|9)59hv?%Us3QQ`)@ce{h@vrK$oL}G;g<0YyVEWf?j-HzI>I^g_>ifEG!wwGs% z`xf{uLI#%=Fjn^d^!b35dS5-NF1AX#_SE^K|Vll1kqiyqJBW3R=a)e0&{z;DRY zN2mF9uRO-E3pc03{~^yjEL>)~Ld!_M`P-tI{lZE-_8LJ+QlycfyyWHQ6uG-WC1=!% zy(Md)0GDi`AhSi44~11UutRd;$Wc+h)y-8 zMg>M5Rh~0!)LU<0RMte*vRd)&T%6=>RR2JbQYSU$Cb9o_6ny4or~K|ock>~2pWG1b za3;@Iu-^DQEn~C&YsmF9L?^Jwjhb3txDg;TQ5oeby4G{>}m=Oyto+SypqZ!uRY z>I~=4{>mfR@BVk&e1`lkJQ|rZHKd(f7JX_Fs07iq3AGb~kSiO1u(}%a>NWM`u=ed0 z{}9jOb4o1Jm8qGN>!@9Yu8i!w5c`S_?)IHkg zG>4gt*yhf3szU&tY1wK>t+5(^N*-t`-e~#GQvytl6HvB+)CWkK)^9nZc$TdJPCa9P zWEKsextWYsmlF}yOL&;HP?UcY+5Lxb4vpaDNeZC7uaxpnWSZZGKzjetbTu^LteSmK zO&N*ua6yWZN`kq_PJ*pgk1?+jKVcFcv1yJz@>Jm_HyIkJpGn%3beI52Yd~64vY>sy z)doxtzOKM_=>~T^K!de1?}l{lr5?F8 zA1K)+xHW-j$+p(tTD!&&Nxu8y2>9nVi(B2Ym({Qb$f6`~9}7YyPm;jc4%u=32_dg+R!Ybo<|Qqr+xRX(tj| z+!tC}bPNmtntGxCm-))n!^bET-W$Xky;hW9ZI+W#UXNO%*6jX-a{u9|Wc+qt7F;ng zHX^~)D1Ukc)r*lIYhgS_rFyYAnaoh`W`n)=KNU+&Hmk}23z1D(r|#sT>x4$#9+sGW zN(3u>uKh8suL1t=I|A4^IJUaASX^bGBFouvM?2!yiu?GA)QaVl3?&d~F`yCZ=W-Cu>y8%adAiFSFcG3ktQp7Wtc+LgBshy^=UhengRM#j<;NQ#mFHpzIXI zdB5#fY*vyPYen+$xUf@x_C2D+Ku#+Lb*> zkiIGwDBS3_mkN84+3js%%^bYJYFWA4L@XM{XQf~G%&2@TpiB8KHi)-m<;Hj%&J?t>*sXjwR9q0>S(!9Q5HdB zsKDDb->|!;n3BpoIoB8euzQPzr1N|s9vPDXcl_+m$H!#16={=Qn_KH9sh=8m@! z`WB&6xn6Q+AVybh2;$&O5G0RceAECqC=}t{QtN@+CIV^FnuLJV9H7@(X>nUP#GUjnp0Wv>2_#WD)>x`%Ep4CV6CeRnGvPT4mqx-$5*<*9bU1 zK?fcY=>E|j^(`OO>iJVhxIZ=<<-#F1+$w{lNT$RagN};!2@1UF_eSvgofW zDy43_*M{wBX_$AAS`sn=Klb_<;I3MXoV8Z9yg_cIT}~Q*4}Ec4n@n_+ZmBTu4Mhb; zEW?Y7XqsQ?Yf@RrA~CEU46n5u@85ZE$L?<1X<8@t0L`bV=l)^)Z6@-dq#4QEgG);9 zW!wD46d@`y9(x_>(^Bvhn8!~(8*CrZ)7Tz+o}P8HKC<=Jz>f$GY7P)wE`7( z8e(o_wz*g7KlklVtSH%bP^tc0I*?iLVp_8V$x?Iv2JG{< z`@AB9)K?Xa=`z%G%G%;$9lGqW?=39h9a(jFwLh3qQ$u?{p5r{x7%#{E2M*onQApeH?h_5C3&rW=_0Yb5y>SuRA$~?}rZCJ!U z-YW(t^;ue5D*AX55%83IJc*V-jFqz8e%Rq9+9bd>`Mq!Xk$ zxYbsm3W1b6-hQ=h@Cx7IJul9DVHK^NN&aT{U&`QL4o*JDHfG9mo|37z-l}dicg8gj$Y~c3RaZecrbQg6{n3=eGUq_ki)UEW(+2|i z5qW=+4@J9{s?ouRInhq}fzt9-auKK6qUY+>7JDYZ>fBb`NbSz{dH-ob@BZmVjG-vW zco$m_=-S6a6C2nbepZegH{ufngd74^dnS zgH~Ec^9jfH?^Ynk!i=m z+7NwT+84|17$?R@AiCldLx!|#p@u!RLaH)TQcO?Y5ve>k3Psquy^7oLnh~koc15*2+n{VCTd#ROVC1b3X93~Ho;`Bzz777 z)LQ&yo<#(27ev)~qth?T9zqiuZqwoX#SYz#iJW`4sf4UmNDud|ok2}#`5+^uWjBG; zCRY9$T6RJT2ErZX`W}lQvn|yAwdXa7A$2;f`&+n{BH7($86MFxHuaMbg*`{{Tmt{? zz851&p0J)s!4Qrjl`o%7dtLo8Phf;TYYo5Q%|%L9UIU*H&hD@~BTm9Sad_%)DuGYw zufejw1~IySucT=(DngffVWC8GwF*(#JrLmxm)~T5lU=53yo1JHW`(9+nM!;r z-q_KcEe~a}v zNdC`_FVxgIAThth!#tmV+lTscPr1T?MEgZ40pD9;S63vD(_i=9UMF*5&ESVRr#G?+ z9VXBL3j$Q8*yU%qVj_vg9h54V$%2FOI=ap`K47A4oI3t*B;eaA{TvC#bbBlx8}F}H ziw!CuI%QA+cE;0sr+}D3pPrnuY@uv!nRV?ahv!3y{?`vgp;!Mm@7-{u|46)^ETI_d zeE;}SLRpwJbj>n~GB<);&+AyCPllyU-L_UK`t$aU-xn~tgaU~!0(j#DHbGX;Wg6v> zxz*cwP{vN+5E4XQQt8uZvo36|b!&j*#ubKuu&MfQ0#h{m9S$V=lrsQoj=vUyC=wQb z)ViMTsqgIU9!=3!4_XbkrXjjMjy1!jS*R%6tusH%>P=s)_@8iX=)89 zENYj1IU?xa`(fqQr7*X?S4d&5cjt^bfbg(;Lnxq?56b2%n}jIL#JnwUlVeE*_z&qy z+C27&w6cNG`l}y$(rQXr9L!Zo{3_Mx$X8nC+84G2#|b z4G+-mdD&!M+}tc-lDl7JQ&X?9aP>6S0>{64MZOSQ=ky3ph-fj@9~^x~C~is3D1c9B z@4%~e#{ggaD>z>EQ~?#HWmv(m6CtaPOhJd{TWL*6tazSIzKg6LuQoGuwmAP;9Zu({ zRiPF>rPcVGFmVMq0A+)jH!VHxS-#AY8oKP7#2qG(noIo;j@^ac9nxM+} zV<+d5s_X;>_#e-)-Rd}Bh6_}L9~v;YJV3iG>T-NyE14Bd^$eKm7`5gG?ns$ zzPw@kaC?`$r%Dogx4@E@AIX?t6@HQi33nehSXM+EpXuW{rnTA@C%LYAk>d+Edn7B=?eF;_Z+Mh7*IJ#HR4iOdv7!$#0cK~e}=-`f&78noU;Z*^&* zpys&3!xDx!MQeYljrdBSx?Cy>9FKJYU^!g4r)3^$PUcn)h_dnHYb;v-JH06Q>`Az* zRHy=Hf?d;G>3OwvSs1jk>%-QvdH0;MkHE#es7~rR=y-do0diG+il(NfWj8%(Qv$Cx zJ7DyUaMAJmZ3HIF1CQui95z3Rucis;fS62LU>Y|*1e;$EATpru+(Zz$-6tr;w71cb z*=3z5&{_TNLvJ9}o)zMq-6BYf6SHq|INd5P3$_kIX*jHxvxW&ZgL68R%UVv}OV1?2 z)=;;{+h+#i4WK-b1raCTFC4HS`yjA9+gNY=$TmuIFJ`jeGuD zHwOK?yV>2@g-AMEGaI&@EZ>A$-i`1%57pdV$`&zrMlG05gcnzSh>@m;m)bHNd&ZseL8y`FzzET?_v)GA&F%k{yfY!e68!6|R!UM_I~Xuvev z60a2UEB7en!-OsW)KeflVWc4NTSwm8m%cUCS53qe2Az=h-m0BhLY$|-$?>`A;Lx9O z2aDuA=KJ}**QY4EK)te%^+S_Rx2SfLfY|g_jAG+)pHB)CD|g2mV2Pi*2G=*jtR}+w zH{djm@mS$N%-=2g;+`6@okHC5HlKKhZ=k!a+qHg=^z?{$9O!nQ~H>-fRPTxtCgq882 zbaj_YWe+2{SqGui*G4Nk`^CzWmg0P{b>n%+%c^>{oq$SC*FW%X@6H&=q-Q?i;1~Wd z`uB|=sfL|oqi+Ew=1=4o8r60O>d15|#(Udxn-(jbRL4H~=02Wa!=HK+6K$@u?+*E! z)ruM|PD1}`)`yqcZ`jYzYnp6~g#ZDNDaPfsoAtoo?$f2;qYHpUx5CyF;d^57H}w}D zj5%R9Jxh=~qvIO>5prLao7D9@2%5S{b=~Wv5M!GV94qaOSBj=Ul$JveTce2Xzhpkhy7t@TCaw4+o&| zAoXUq13OhZ)%@>$m+xIdrh+0J{0oT(`lBAufui?{dvK2H8D}@0o=1OMj0~2$o$)*G z&->OVau4DRN@|=}>IjYVj2+e*LUR4)J%rl{Ni&GMJMOGzz_wI~hyG$hckdZkwgF#w zoj0X5Jxqz&T^C<4QcuT#XmtVuoI0FdxZD~p?m?U9yE58te!@*HU-whckR(V40@R8H zvkGO)b%|RH3QINX$^fIi%RE0qpT!Jfqy2bgzEIHTfWEV{TkY+Re;i}v1wC^>#46XI zAdB-1UBQ9f2H<_}@QCp5SmNdXkEX8-imPp!27)9&aMz&0-Q6v?I|O%vEN;P_;O+!> zg1fuBySvLG-?^W!-u(kbZEYQPw`cn5?wK>0>GS;(!Tz47#C@bQTE64#=t0Xnxd)QL z3>#{+Sh%L85*-tDIB52;>DLPZZ$QKKGB0tp#ap&&g{|-2ZRhCwAihGUnC6gN`abrg z{?^Vi!t3*_GSB15z#xLTX}YsE-=#SvzhLxJlUw>eSj45y9&G262}UQTf654^XK942 zFsKjC?$jE}ZT~5&@jP`f*@FOD%T`*ndbiy)|5q$@}`21LN5VX1D zGxl_$l5X0{arupu&gr^EzP;sUM0NFb!k}|ao2d6iKwpcTB%*~h6$N^VEed-xWz26d zM5Ry{1|pIYy;q{W58pWCdOex=w!0k0B^$iKTZM{F*uOe!uG}W(g4vybSATl@T82&- zZ96)^jzj@n!Mtca#di3WP_d;vVTn4>B?m8R@o>A@_;<5bIT#m&79VQG*8yE%e_iWR zgCu&t;2nOZ;P|Te-C`l)D6BZvTiS%5r<(kVt@L-ZKxttss#=;ev8Ac!g?QrlY8W19 z2_BSpAT?R@>G;AkS72A6cwkY#c$#pBS}S!|}nhw;k%xuo$~1^@>Sv^M z+!ZP%!zKmZtHI^3o9bAMS_nes)Yu-pHWHu?DT7fX+|mLE#L_zPmn@zImV}&#O|2-l zGAOSgnI}`aeLh@v*AvEK$IQ6Pa-XB>u-i`;f_+#B(Um3U|5a#!4? zmX~NHowA!JS^6b9wX*n>&Qr9dJkz865a(7g>aNPT6Lrn+WC|^a$9HJ>wCiJ;_q3X- zfiv1Si2?@t)0nmEVLiepohpqpyR(NC{Z`)Q1}ah-c$_7@l%+hQpN7Bv;&?j+qx)J$ z(fq?ZpZSm{+s86eoy;7}4>BI#d^Qh0kKSHZxH8`Q_LPV9rm#CKO%esmo{*9(B*K%e zDaXlhiv9We{&5ks$*REe#FJ!bc)=)F@Hi8;Q@x1KAuewfpA1TzNYa58l(F**zt4 zYE#}{#|pGlFu=tfn$yNp{t`0zq9@i`e;~9~nshFXBFwJ`A8Q`kFU{K*p|EB{cUtxF zc6Dza5PhpEvcyjZgGB;mUxvbn{t}v8VrBu0f!XEUH@ZV1Mqd#g>fz@w$bwS?(F%3# z&qjz!APWyK#%%2rh^eG6W;UA&T}eO8X2+ZJCy-_r@=y(1`fWwC6C(;={pVz*mh&Yy zdS|lz@rtPD`iwfgb&cg4bbU?&$4C6!1?U#Kq&pANF~CW(KEmRE}h0MmP?iGSGhz zMbxXo0{HrZh}gYC^GQBh+LG5D#di=3~p9 zgVzH}agov#P40bCD@00&OR}8)NOh-CJ4{_DK}dG!pJ|~P91__@#mbq)CMjj1Im+*$ zful-Mi(p0LMOcPYi)6GhiMbW2aj+3I=`Y;mwon+IM*9YxZ&vEw>GNgup;n=a!vLP##j~~^6CPJ`w|q*4|bWY zl`f}nx|dIHAC8)iFA`1shvUuK z7Z1dXaFN^`p$`8e>N~fiQ&Ck_jISq41DZkpiaYK$f>Mo;nb)58{v^h5R*5R}6?ar6razK;m*>AAD{!WPECRs|pP0dTi}o`8wZ1!`bF_+nmgR(>oB-LxY?CiqU^w-o>i1d`r zquwlv`8^N!W|nJ~{g}PfbiG){3CxJaqadIlDd{P?_J$s??g)HTu#Dqf>uQdF9JVtx z-xeT-PEsM0QVZrf^eqaMWSiDnT)AeG8X7uSEQUvHI%WNftO;WtUwIR+nW3=I?qE}#vD;B6~UEBV(i@$Hf{#Zr$6tS zv;5pvje6&x*O4?4gHE(kGu@v69UX^_*yFO87Z}#`{5zXVv$f_>sTHxT*9HbCJRC=E zxtebdf3A#htCrEq(C~R5jrP48bGe?e8G9lR-Y!_uv0Ay3JtyIk0fv_BnP6`6WWR>{ zO_whdD>kIkCs9$CjQ0n2I@_C_Fcc5|w)8(+T()tAQ2t6vN`WCEKaJ2j&ue01nX?Wr z2E3#@#1#}`etuZ^3_EXok0V@KOGg@DOg?XbDOYyw{*=5_j@c|BP8p{PO%<-}O~Mn?jxz&llRWrVC^Ox0heAg-Z~Llh-= zzUf2NkN%GTO(UbGtz6x^FtLGMU|XCZpVg{PzSzPzK0VaH@@QvkJigL?2I7sH?8SzL z^QF{TkX0?y4zJY1Q(}zmE^GnOY;_cUoi(%Bn>#*j^LWdG-w&TER>BNMv?09o#>@Fp z6g*d^^V|Kl(P4kZ+it@nFgI5Kbed4)a_)$Dv%I$kRwsnA}S;i|N+!qRQ}g%4bc`&Rx1j)h^)q;O1-Y<4s1qqn*H zdrx)-clq&b9e2K@3%LpuhZm-j04Ep#$Tr{+cXRIWHOIOc4sYqiq-& zj34}Ggnt8mg?#M$(;UNUdA#@>+A^z&Pj&W!P`oIQPLo`#tKZ_J$om5MFIL^ksty?f zij8aipArw%P$w69h3o=LDbhZLPV(Y`7?U)^A;q6z=!7U>(lK*Mlp0(fJSn6dJ+hVd z-TS+@CADteSmsLXL`Bmj2>4w5YGwzg3WQk^5slE*cp8soDoX298@-S;Dh&>@CiP@6 z8ReGa_HkxhaVWyAD3S5-aamnQpgceqy~-i|1cFYH*glgrQuf02f@QNMa@Z~Z&I_+! zOV#Q*8YkDyGk1N@)-Ol+vs;1p-Y{`F&GGN(>~dv;fohk4DgyYZMyrv_tn=pd=u zLD5c(B9y!c`GB8PwMG6ej&Wa}Psamt_7)TBu334r=({ zQG^%3q2{lqVm(SKfter$BkPKQ6mwGvDy=lK1AExdK`+FiL08MtI3I{&au5C%u9#9- z%LYGD?C8!};F-Vtt3N#~jl0XB!?cfSX=*Bvci_ck@1UQ(qP=Qo&oJ@fB1ZkPSU0VY z{y=7=kAdwZ=IH)!{pjPgor$@-Yv(gXI|XUjKaL$It)M4@j-t0OaSvLQxItO91jP+2 zE6aj$7+lm8iL;Q&?5-V$8J3B!;t0ycZm4z!t;3ntjdM``NWc^j^%i=tL@-`=SJS`K z^)Ck5lI1#0Y4o(5Q=VeJfU9Hg`4*=MTWzUqaQc!8^d{rZO1xEY47$M}OnOp0iPJ#J zc{9SE>3rq=9KCX)tNli;>#2MTl1xVR{c-R2wYEI1i-AYsGhK2!#T!rew;fEFyG{_j z#JNYF7QZ=e*&*GJnvPF`us75~dw0rr!Dj5uUe~gDtAqQi9y;Sz$=#bD znAn$X(=ROS-aSlq8%<*`=WxZ!y_6fb`dm&K593x=HXm2Q<-`}8tpPfV^$`eOM6U)L zn<{bmp>(C=p~}-i4MEQ{+84L0rQ@%b}F=f9l)0 zx9Q8f&8Pz7p`Owc1%4jl7iu%4i)L-;Gg&A_f$UcKj1X2A+JD-ry@KaWsN!6x;oVmt zfM5jqCYI4qhl;C=|2r*I99~d4x-ADMCd;a=BD1|QaIb*)uk%W$Xa6k5*=};*%uFOR z883_3-Xn#x?=zajgH5Vdy_ZRC^kf!2YXlaeyt60gA~(MG` zW|JJfJTM2R!l1nQdAJNp_VH`;r&K|WR?n?o&lWwohl>UNDr%iV!@Np4%HcI}Itx3A zxqzwrx2mw>*+pWIcoV7-q~qru0d1I?Kcxs7IXpY>2SHLkM+#?Rti(n;U_1xGc8PLA z3yQviYn^wgoXNesm?2pC&w2(^I-xKd7FOR*##X+SD|+Up9=GCi#eAL`7xwBP>*Pib z4ccM}g-!#78XDe-48Zo?p6mQAd|%9NLcl^mX;_h#c31tzlBYtZX%6qKNQFVj;;Y(W zKUMf4|CgLwjV5dVE`eI*pQ&>Z^#Bs#!zvMW^C>4_$U0 zjr3pvtMp*bhA7UQPP%|1FTi-tG#hC*-jcAA#xiUDnpLnY)_p(nE@qf3(?K6j^eVM< z+g0(5FJC_+=2(A3dws!dY@<`x`qyyleg}~)nYLV?Txms_G78ykkklR;)W08Ez^C_M z1%nb%K8qSnIsJMv8F5L0acz?o?DjARaq(>~%mJO0LQL_7spnxF_FC5iMi;`2bj@@J zEmZN}qcFJ+hXkKa8_MnIIeC;qXtTUVy>)%y8SP+>l6AXJ|CHo1-YeKnYZ@0=1uDZ2ECBQdY!fAk5H&* zN2LyDhv0`(*=tgmopbY#Z52BG_Mlc|B2g0)U(c%QgMAR z6|K+qxAJ!D_uDZ9S|zF-7=U?O;B~w++>v#}@z&C4I{zw-cX-wD=11+M{_CX&Q-Gm+WXN5coh;lI{%?!DN@oor;?%=;<2Y~@A(FP@Ao+F zABzs#H(!aK1&YL`p;SvWgTu(cKXdablOyG$Nf!d&Mae-&GCXv@o022Ht5;BNBJ388 zEM_zH@!c|sEMptU_4PFLM`$9K zUk&=QHp-m}s5r5fE>`su4Q8%&f*7X{otNV<&|-tSc#)ppxfGz;aCYFO6q7b{rOaZR3Y9O9!G8WANXweneRC-37+Ttfk?k6*sP z58QE6`zV z`MCe_8gB@qi8q-bGXbNcFw%LVCc#058fs7kLR|=uGGS+nkfb?;$}S0nmQ$hNYM$@> zOD)-T`_U(2?!0x0xFf#HM2d5aUR1%_gJy(kWF!cY`%ShKZ=Z^E>o`o21WQbhx%vL| z=^my2lO!4CIT$57ii3R|N9_7ED760;tze?Zni?itSxIJoEZF7YkQ_!{IdIQ$2vqM& zb9cXr@>k~8dWMDNTdjD=w~NhhRv6x%A9j&V_8{R~B8+G$XU&MA@HdVp4B|u7-{q;H zLMCf4|KdWSKikTP>Jc5bpee`wz#+X#L`xybis9(u&!KO@wlN^d3K&I+7@G(U3Tc|` z84VTRI?CP+g~mkjO>ZiFfDJv2heDl;N@9Bp8?qq^asgj9X+lk+PO?yterwY_MBP-N z3?jc*6W+XSH0Z3ho!!I@GD4VE(; zNwNHrRb_IY2OUDb*spW|w-xAisqf$P;qmQ&AsZz;>L%3nK}gJhtTAb^^W~P5;wjM0 zTuRog>rgT7|Pcta-mNYD!JcXps@J-|n;H(Z+!yp!p9*HXBDQuTtZOL-~B zG)E$XtRaR{`4P0rS)6XxOrx`#`_klix%O*PNnvhF32#?E2!z19zTJecVXdT!RalL_9HHzar$j)Y<8_ z?a1+<8;>MY%Pao+%xM>f=5k9e;t#d4jt({?TA zA%mWI;~?q-4@165jmVd10|{E9P>4CYzKp@Q)63tV>$SxD-rm`F1hDSLuW#C!ERCNw z9;V3;>t6L`^8d)tDor%h*9n&W!H|cAjpbd-B=8CJ99QT+jBZlcA4&dhwt8;80qxtq z*?79HwffUa(|j^qF8;-><>Ig>tPT&dyH<^6%jr;ncfmeiT@>FYjYBH7MzZW|1N{AR z(s_HYIptLLYp^WQUr;N#^uLN_f35rv)7V`Z88xcj3{V7jlP7U2XT)OH`)=@5F{tkH zWb@S7=9Q0zB@W^HgmPo*xohUF2ALcTwC6psbi_ht3b!RRQER4!+J{0z6Vd$Vgs3dO z5Rb1qM|{b>wcKOrz?5LR#lwZO$)&s=g=w5LfhVDHyt6KkYur^eQ@x+zJRV2W`PxW{ zaTeaV_dI^g4_Kv63YN?!kbir~WmXF_sxjKY!{RPSN^L{Uq~Iw7iIri0GRO=4`f>+%$9@Ly+MWXOdJX(r z9U(kJYUVS!Wne|-8#;LgCOij$v)149&A`Yp*If{~Pj{V~ne_twy3}yqUo=pqLoX$; z6XT<7ZO*rFaPGHksO7F18dlPjb-gT0Q&e^TM3_{9g+CR^6_x`dLC`_ZFRMRzlL)DE z-x=Wts8z!qfMb^{27FKi`S#Iq?alTik*1?&y$`P)T+D3i59{E^%V6b_qCy$oOHii# z+rT$zG^4hR}t2w$mHE@5X3W;fm~_6Po(?VhDkxNz{;IR2kmZ7e`#| z)}l96Vy$&*%+9pmDrjpS;@sgaHj3(F8x?&V%CW3Ra^od;npXq5Vt(IQE+?McTQS?5 zSjc>oXh~2~^R>{jeFG#I`GsrAH6y*%c1FEf#m}Xxq#LgVX|KBFy|PyD`dhP;U~K^+}W=q8h?5iI+(PUko7 zS%ThU#Lc&mihm{aAmoW}O|FWEtp{duNtBYS4Q+>xkR?_56o z@ycHE57jl1+^0$uwDVYx)&O&@Mc7Bt;jkgTF2NQU81Q=0dLLDFH6m0Zqef(0UlU7LZNIQe*)&`>7Nb}> z=t&(_R^SIIj#!Io^o+uZiTq&8x@Ldv-V^S|`7$8J*j7s3E*GMk^nG7XSb}mnB|UYv zIjZo6LSW^iVzsnQ3+hX@!|_U`XWr@vc?9<7eefhXdp0&pi2}299Ob8Y+w-$3Kcfh= zjK|h;ugtC3&dcF2*0O$XS9uBdKKHxt;dN_e+6wo;g`;HuKPbeP%>>0p|JZBIOr~%~YOJTr|vQ zgs;85?#S>#P||UlOb$V7(}Mq(1>oSmJ;<|MO=7kc>HMrh81GCe4H(DVqs|LwysoNy zjzJ+o%+=?O`?B4n0Wes&bWN_e(JSvpe}7E*>h6s20K9i+WcdeGqRhZV=Gbt+;L_#J zKw!`;6B~={%>C;~L9?GB+xYv1P+cdl#d@RZIeawnN)gC_?a|H}t0yuBDkyxq?juyJ zQP9!Q-XF5Ncu0y|x@U^1VaqH&$+pSmneU>tVcNIp7v*RcE4KR$W*>Nk!e0Q!C~JfC zz)c}WuYi?3-{tMedzp+>EU6+_X$12jAvSip3&$c{#H#f3S1l`R^Gix<42Da3GGu*+ zUsAU7d6EA;WJNII$t`RBi^IO2pH2Rt(k+lmMCbHaWBa-9`SJ#fwdYVgrQ2gPkth56)r{xvK#NH2y%KC^W!5x)^Kh1XO}mWhdB zLH#=UrupOQ3rTiojv`gDYDdAq`v|6(``PN_985t-bawCQ3Vp3<&5Tte{8ZiSrkh@C z|1pBbo?lMn*d&En1>Kq^Zk;;Tg}wJyUl{dfOYocWe>NA?%XRuFo5gY{3}9@4ypT{= z{0inOBUU-csc=SRapxYeskI^lHoh-6n6aK>R~^jXChPKF_Rrty?@_13_#Vg>=-&p1 zUD!|WHUDR~wfoT1EEH#4L2mx~l?(@YDX0wmWr9E;OikTd0z@1M{GgdHg%>)){z+n+ z(dV}{@1#$#if!OyN7q>Caf(O)>w~zrow@X~pB;c+wft+dBZM)#Q?c2FoyEK+&nH5n zseE%l$#wFwffSH~w9XG0dzw`wm$sYK4`UwLtFn0pEO-e%^)4Mb<{!8m9Us@YCVQZH zJj={0m8j~rdI^}VKC=!!UA@!aK@g+Pea{#?BzrQTZS8#xdf= zD!i5DDlO-khMr5hg||EEK%Y+s4(BZjJyE`B*@7WxScSOhUlAgS;AD zjRk)Rba#JAq>Yb_jg?08KvVr>StDX_?I=tgQqRe{_Fb1zU{ELZsmW_S;)A1Vd!XX9 zWMy(%MG{!4CO0qOMZ~|g*Nk4`c)@AEiGrKTbIN}K_xPXxBO)+5fXKqq`NhLPjlx=W zJN6wQ3m3cjc=hmL@!KUGw%TsCtp163^L`JNZm;J06;CKl?G%+dEBE?`Z*wl<@COv% zm_lpo-_1_i)Sf!Q4dmuC#flPCXIbEO2~34dSG~pjs3Rf0XdA7kx(qWRMiPKE_$lJS z!dXxB3&6k(W(YD2b}YEmmS|Dy^R~RYe?V+)?-ofDB?E{lMXggeGr2ziiSmNkDPs02 zdl*~P%?vGsE}-0veeS*nI#y|>qBUhWnD0;A30)M*Ku_4Od2* z@xYUO%`(81P0!965A7*EiM7pH)cF{Q%vTsWXfdvESWwT!%dN1%8NTi8;i-j$8`F9JGE|1UAsf*ZTvh$kKs&0?`t$Pw^Q`msY^K)8; zPh8&r_nS6(LTOe)>}}mOf`vm)XTTmC9_1YbDXcNYRFVIBLoh2jx^8oRwm(modMQaN z+7pE)NBQMt?P_$xd@pN3b@(w-BIwg_<}4{p>r*q(QdV5kdLSne66jL1Yfa&I4tNgD zk$(iF)^XN(&D$+}a3-nK8`p`&WVgxenvvC9uEt^hkOYV#t@`D`NFumgiyOX|%jK(J z+^o5M^+9KQo5wxTVkT?78RiEPbPyiv=^tAcoz}=?-v><-lNoq;CfQu~Iu=!2{eH4V zB|fzm4EV7hHGclDxN6YerYk9=V&dYtsx-bbn?zZ5W4dgaxaejoEn=yvr}=7&SbL-K z+37`>MuvZGCdofs-&M;suo7kZ=XW#%!H^y$WMT&(#*3+0+D3lr~SzX|k5A8Z*34xwKJNQ|=yP+MVmWF&N3I_Y^OJKx5~e;w03( zc4DlDp7uz0GrFUq#o=iJ7sy>mDgXc|Q!2+4=D<I7OwVxAM>*>N<&a0TdXEX!jPC(z9$$ zE$a>`abq*05A2UO-fMpg(~9@~V!cjg4TjA9q3-rOBC2u%nZ%0EU76`SqgYqh{NP8f z`0ob5{L%dRU%{&mA@lNmGqA~{3;Rx%k4Mi55oT>={}F1=Io$wGQKx91zeR+q7^FDKz2Ejp)xbiWHc(nT190 zv$P0lP_`PXN9@`JAj8!UX@LwOJDZC3 zH@wF$0r|7Gd4rkaCCQi;PlP4HyXkCr!ViBs^(HPr0zWt^A)B+&#%!zr54}ldT?$l@ zOCx481r7~Kv1pr_ye-t1ZI$Va7VadRiccF)4na9 z#Q8-@gM9g@w|AS&GDlVAP4b{2@?wRYUsMDZRiY99rSex<;r^LSQaxjlerw`Qa!hy6 zukI95z0#@Y=Pc3y;zj@6bwE5@Rt}eo<#auKXL45+yucgzLRGj#uHx0^PRUZk#kk-H zL*G;vL@QPUkWnyth^r^y@rm|-b~N71&s=dpMHK<*Hwo#Ns?t@|#l#t4(siPt!S^VQ zoNBPJ7%;z-$K2=NxwT~FH|r7B9!RyVAH?*%XOjRNLZT}KLVZlP@9Fu&>L0Zh$wSuN z(MA9cMK%M`#{IL0%?uhM?PBG?1G=ZBZXwd;+vav{H}!_!mgk4-oyE#;zei%rsan(k zN+bnkgKryU*m7y2!DF~JZ)MW5LQEcXnQ(XCbSBOK0&=|<%Ls7ko*^#x{n7uZxqn#Q z^{)+4kI_52wc5CqsFQ(JfgjQ3P5N9z(BSDNd)L3x%REaUdxcf~Oe%d2hsIT)7#1R; z_0$NE!5LZ+l`RkmoD;XOTMhPnsVkYEIw+zjC?vP!`1bTLPWy1SBw|)Zhe4&uruI@j z_9skO8_GYKG3)2^Ye`^8Pz~tPfyHFbrw;xLQuyN<@kTq$w_8rgZfy{Ljp2aX1c4v{ zItra)F%I|pkZ4U@p5igmV0~b)Pq)v}67&aZhuaB)P$l2xg2hUmWjq70_p0YihD1tnjzEL|F=5bRjoy_ zOk{&|&!&kNM&Zo~fCe){GM7q=Dr6F)^ixotP;3L=gLhbLEabZnQA+x019A$pCoB5* z56RRSm}U>^XV91i_Zi8zZz?6!W?G})>1w* z77mBQAT>^bQKL$(&YZkb-q~q{YboNZ25C|oOi>y z;nMGRB~w~`U=Ccyp;SthY3tuHp{4u4b9)wK=UJZ@6kg-FktTMZ*-wKYsjDzmC`lWg z#UoO2Fwb3k`s7-Ma!8-uo4cC@;r02+ueWgix`CaEsjE;z4<4K9M$h~Gb7?!ZLMpPj z!^q`>X93aZ>j%kC=%XYX3RDWE#}9Qk>`JSRf`+Wrs{oJ&I-wj)vm{gkzdb!D!+LQ*3!};t0DP>zI${l@KXusVtzN zUlg8?2lRCkg94m)cNiu^8L=^!0@b$TW(^g~vePsDmb3YvDq0&CtKFhT7u4{ZJ$E@$ z>olj`g^=sL=T8^&jP7OJGMk5HN^Wrp-2R;wh^zJ9iI)|7E`!_o8jI`?O8#r65L zb+XewhE+Hl>bcAoTb^KJ5NQzPd}bFDWy?yVS~}5}`XhzMg-P$dax&W6oJrce)a5lY ztarJ?^6Lb6q7V9JXXo+tOHK(2opKu+RuZn;-4zi{B0N7oKO*8+ZU=EHxxBo*rIw$V z{}qFXg)ddv0v~uN8O=w6G-_oPHJh`>*q=O?ruV8`BC{J5Pq|-QZTq)Bd7_=2nv+n^ zqQ;eNE+eSOX1$)(3>A3eH8dztk519%EagETvfC0wCB8h-By?)fPsZkJpkV$r+Q@*^ zju;x;UjY#lfjh(u45T$Rp}|NQMSAH^tX|6DobSXb{H_SNEGeuuQoK=b(~#Xw^#CD- zp-qa0Fa*H+wRneB5I)hhPVMeiE0EaD3QFg9i;uQ`6!h@|JP|LNH>o-VSQw|E0lUaUYHDBQLq80xdsYP+1qN1{0 zE~VrbMKgiUBH2(8p{wD?L)9!K_RRBSwf}0>2^{xCsgPU0Hcnfc=akH;Lhj<-~^_M}+6_@MaWYC&(s57R>{u(lGXmP8) zyec7wfnG<>f)Mj#{!8^oft*&r+%cP^|K%AaBs*pZgo~ zZ#Z_I-S~)hykV&Z!{AN*YLi=kdd?Qg*tl$c#`B^$WTX;-VEQ=s*6{zTs&aLG-C|br zALmLxqaOu6PY*g#Suj7~-?lJ#>`P}$e1T~#%|Rf-o66}!IoeRRn5c4c3S_h42o5T` zyun1lZ@j^0%{z_4dW{zBfaigOgE!TZe&CV#QO2qH{g-`IB|=$+-chpRf2YfFTF#+Z z-tX4ygv`N-p0?*w!nti&YtG6bbI|D=j zCE_9;7XiC9-?>%2$$a@=)k-~jEyy*4FoRw%{xE1b?;gupD_Pl@=>{j_s5dq1g?zap zeIDODTdsN%X|x}QZ=;>YaM#pKe%O;)zfP8f^7KBhgl8oH#GiPz@jP53aU_~(yaN3Ey?(7;r+Z^XUxiIz?Z+d#Y~0%bNtu) z7&Cw+ag~yWFhB&u1AK`7&KxShhbZ+g8Xchk>#29VF9*ncmCtAy$R$g*Fwsf%;P|ro z?QF%Hz%RCUrR*(hB@(AT!_Uu90?9rmDCoe1*%vm&QCU5-9!MxZo3~O z^Mj>P5)FwZ292Z_;0ecz*NSWwSU`c^436o-lFqdtdgu6&CCaOTWO=OfVWo`-3w8Fe z^u@0% z22@~Y(!Y0TL1g2>7CkSI7m5uI2VeP1-t4_o77iA(M>EcmM4A|onKfyC9F}V20RSy+ zSL-ib3+$`)u%94bqK?J^ZRTjXgp=$gMn>I$1)$^^$nd`ubBec*YiS;HGigHz9JN6E z=EyY7Y;_fK(<0BIk _<)+%JV+Oz$Q(~y04C+t(JOfGkZs3bg#GP6wQH2({UJ!vn zmI81)ya!LIKL`XIkc9H(Hy@{RXBWJe1KQ6y%x2r)&WaJp~cC<+DlZFCgsnDJFvQkmK zr96eTg~`yBWrjED&GiO9|wDsRmNtU7a>mAZE|l3kVX%- zyvKi>b~qJ9Uo9OH$!RytD_-@R_B9>raycPZ-4?m+KW5l)zi%B00}cDm0`NxXttt-B zhI?)FTO%*tf=dWJPlMeW>6Nru(Ph))kbwof3H?r#@f12p+Vz)(w=e(8&n9j<$t0JF zc|>gH>!(JJF%GdxE1xM*VFLe;hVI*Pz_SvUtsr98pKg~htIpAT^59_DNZk1^At1Xm zLEyf9e-yu}>B%_mEtxjh3Pu9KcG!FSiu$OaAZl3Hmp1P*%ZT!#qQQmncxI-l!6mN# zwdc68Enyb|fs>%yXfoA`IF9mHK%@5gO8%7Rmk%E{eh}{Jsi&(Ohb8?Y?*6zL$}aHtSgUpy?@KF znOWJ5WAfpA_fSp{fY!8@i8R$@`w2*`Zy(ovZ?bWwPzja|Yb$-azH-jjW8^J;*NQ^7 zYnlJOqWQ3iCLv3ZR$zRSJV|)jf$0R?FC@b3pr&k)q2=yUO4AKTtGu?_0SwDwx0y4| z@NJ6>xJWU8#4o;AS+47?b5oXq)RQK)5(}+GThd>o=%Z6w+?6T>?piIU+h}CKT-EASa|L!Myk7!l6+bw7 zX1dZc?}eR%rRV2$=hDBUiIcAr&@drP<~LV3K|6pqYO{LWJ$qP{!eKY%YET5oskQEu z(&79v()HI}mGg^ec3B)23m1$F^t$8c)|{#ARy4fwttID^7Kvfar^!=QYOVlO2RG;O zSgi)!N*5}Ar#9m%5WUob?jqvwI14fV-CSi=N30>|`|NoPWFmIi~-nbol5H{Uj>VA-CG>tZN7HumPc}zU5l* zaiim*$hx#_0d- zxf5rr)QbC1A1gABdB?$ne?Ci|Bw1_q{scUEZzBE4e<}S#XXG@QF@?+6VXWDCyFq7Z zh23I?W!igaXox)g5q=3KoE}b3JqxA)I_4=#u|yRJp9lQ?iwm-{WYi`|)@dYm`sijo zS)c|Q9d*O~N8j-CXE;8_sTQ?!RovaO#mnP05FP~9vD~~BgwKxyS*$j1?3{-4-VlZV zj{!1e+=z(zO+1{z%*xAok!vG;e^>@|!RSkV zxmNuOZm%5_Oq-Ev$9FuudAT;z3=R%E^wMY|LQjg*Cm>g?)E0VS0`&5#|7yxYYDLXR z`Km2VFXwO;V0uJmUn57T_m>K7w0Q^UK;Jt_h$!KWcb<(Zwj!RP$EI0EB89e!)>-2RH5$^ypBNErg zR_{_DUZQMozg8`<*71n)81P-Q$q^kv!>(s)%J1sxGhlZ72l=l$dhSPH+0$zmdYjbx zCqbTje9;f3*`bVKH2xq(*tE2?Kl41SiL?oY8ixyeV?@>=8m-#m9F-~Ee`@N0!)~`a zHgY+udEe4f=%G&)fd5EbIvnO4{3NUcYM*t0JzU?cRwYtnz`v*RhE3d`H+gIzC@j7$ z`Q!(etrs&`>vFQR4F0DJo0)K)4}WR!WjA{tFa`Ll#Th=E0~gS2Qw4osfe95B_dgFe zQ83|{MnSjQ?6gze=U~T!YN;$$fCzVj6o7t-iiSaOs~dW7S!rub#|4U2{4ee?FOf$| zX1T)go2ze;M2wa=D$=)&{s|ag!CCd>V$9fc2F@^)ED1t3dSlB}c{{)~l z|@uVevkSu1_lNtK~EvU2n6iQt&az?Odd9=YL{=HLEro; zDuzsK=c?i5CnT5Z!D6M*&}3x!7OOwweK{h4|Xsv*WBWar>hrfPV+IZ5-v1fKRa2p9Wt}9L{BDqV!7!9Mdrt6c}-0#9-t3d;=9rhaGFwm{8N4WNK zJwOAb)+9k*uG`O%i)i>y#=Y+SUhBB-7%3-vb$!!6_3b+R#;*&4a*QAXP<|jV7!;xZ z6Liygvlkxzi!p=N2B)qAel7h=!y-(d0tPJ(-ddTIth%Y4D1ng)=L)dL*JJrAl64S~ zL5Y{?*U>4Y3f^N?0VvrN=A`D)Y!YdT|H}fHvF0oq0!ixZEv{Y1y}6%BJam+q(mWo{ zXZ<(zKaQtb(D9S)r!92;$p`~UUGgI>Dv3>Uh2Wr&pINlF^WWlQpQA#dqNU(75@25{ z;Tjlza7FBJ=32L6%~h(${ul9mUZKAbaFabo3!>I2#pyk3Rjf^U@ocY&MMWr#Dpb(m z#j4uqsZqgpzj(xpxHD6&(v4=YO`oms{!!tQ_Q?|j1S%oNG^>Mm%^9xOE-^7j5^@~g z(h!eVsr3;iE`iayg!brBt^XgTa+(J-0E17e6)M5ky+hdD8U{(*2@n%vpCO^YQM=G8+OTRoWk309(v~R~@zmq3`~qyugXm28^}_pU zPDSLCc`J+FZC-N+$EC6Db~yK&E9UF~(dvn$pc^&XnNn+=%oBD$mv-LiVi4;CSIe)t zH*ldas(2yiN%L^$}ozvB9FG6q?fi^E1>;EEc3{sM1yv2+Y5X1LOERNAFn)(l@>^_!q1C1bjD3A|;Iu1*^K=ML{%c=N#zCKi?fpW=rh zH!m@9iJ4YoRzNqJnw{O{S9L$TSz7LPqT}jDEkEZRplBBEAJ%ASq@T~9ennD2x$5Aq)o55obz#Is;fg580s3VNs ziXfU1Fw`lwJo=4}&pAL`|18*Zxa|Znk|3>XXtj8O?pGWFH&rc60h|G@mVA>K4SJZE zr8TcJG|8p&HvQZ)xKHIK2-MrIJn`%7mqP#Q-nI1Z4l-UZ8lko<>S_Mf(lO0B%5EM` z|Bml|Yw}$o%fcRL^3QAkljf)W-ceDoXlF&VdKQ~CdE?W(_``o1{~9 zInp2v(%ncm(%mgFG}7JO9Rm#Wy?)+*;XP~3Z*$hY=iGgEJp0-ET;I=az)9TkANtNj zV3TzWGRMk5hYZtv)57_i@#mq~ctj8&&(q>2m@c?C{%gDyqDfhnI!d{tet9?v>bwAY z1Xo%ha7_i%I|ETjnoHj;VeU9Klh(*g&CHm=Q$L8-n1ff=TE8z=(g3Ix&9WD{-w&cO zI1TxLK@!Imgnw>CPihd>5ebm*?!nSM4Z5(dpwzN>VNam#S~D2p${rJe|Ed zrtV`x(!tmikn;K4ZsvA(V^!{m9Fv|uz{3PUsPYj%9QcF3s$y!P&PP3K##`>7*d2ih zk5!jtFKol9yCc3-nb)Q2*Co?@>2OWo4ZufI{se?SQRK%l>6^LuvP6sbY>uS{i2)Qi>Yux#=<7*@g;4tZ7U1c9+c87Y5v^PW_KE}M#`9=-Y zX`Tbc;dhaAFC!KuYwO3UL;c)w7E~u2mW&X!SlYXv&Oa3k$atoKpve#U2WXx z7c-fowg2;XRR5?|b)r(ZX zL5>v(r05C789g8R#s6C;3!6%Csq^BLT~}3ALY-R57B?RJKbPpr#EdXlD<^8y`({QW=%>qW-Yl4= zSIVFOnN*{-hq&?&B#2LnUe|yKB4-v8+4?^i6gteGw5)fIGODStD3ewv4s8yE-;!-D zw|c39fByJY2;9Vy?EFowH|&hF^F!Gi|7f%r1DmH$g-`;Ai19Vv)rLKbNgmfr zSQRc;FSpB+RO_#$MXMY(0QU;)-(+&?aYyaFA8+wD`^+FL`33#KpXAyx$2E^TLJ>+0 zp!7PpznZhcbb`kwwIy8PZkBJMp-#?r%3aU^{&siNee;o(2mpjNW2(*+C-SVtk?NmXHL$zWy@W^M_Tm{?n#+w_%6J|DI7Hwp^Tyk3l9Vd2y-Y=4f_<(Nj80W5n_9{XzFealxDe zylD6|D6D{F!%WEd;l{0u_v&h{;euFOAic9g$dO|iGax`Mr9IjCv0V7scx#1Ty^(#{ z?eAul#*7BWoLn_a#)fClRtDu0r^>t!Mj2t{L&a?9=+2%Wu*A$u(%r{?8&L@8H+7XQ zacc2Bb?LZxLTI`4hUaRw%9b=wwa_iK+yE5euoqTQr)BUxt=W)nM{tovVpG+(Q_lOP zzunvBe6A?>qh7QpXs>?1Z536KmOiIaN*$Kd>QC2mpDzE8K8KV}7bqT&pi(A%%S9eW zFO>Z6s~T>-2&biLlCVFknLhgQ<3||Hi-atXK(Cou%5wdVf=o^PV-a$D5l6hLMX$}Q zLTmd)!b79M2z3X`71rg8fArYucTh^cjKLCRdmvfB8K_DBq=Tgu1As2nzwocmp2i|)eo(t>%Z z`Zp`%p`qs&WKAvL*nTMyh$-hV#;W#HAfRxJ2H>=+uc~Q1vvJ_8H*d1+2Kc-W_E`&g z1{!t;tP+5ef#hI$Mv8*w_w$6T*@+1&)3tivVj<;uVQ*aF!S@yCgo+C6u&_1*pGBZ- zJf4@r%n;dbKG%Y2WVDYP^P;iZLR!E}=4`RS9wz=45k^0q9^KWAr>35Q3Rw1G&w6-L za#6b#VBSO1qqkezJPNpLPDB-~Qz|Qu(MkNuYpuGvSRLyAqxKz8Q&S7suhUyuOy|Hd z1zH|yHCTs);VjRV_ z*JgwrzkMJ8x?KNyslDm>LA8hVp98)|RkIyL;Ek})3+q}03JXh*NLQcIQu2MjhylG= zX}(ftHqJEiK0b($Sk`rWFxKo$wmMDEYsoPGKlW;-O3!%1G3ZZ;0`hIEmgKo7M3{=0 zx_j0=lrIFMOllIz67& ztl6;XmXuCXuB&K7|H_qeSH1;^|o`Ce(Bv8$zw$O^)aZah{t2&)_El* z{Vy}Qf_!f9Dpy!YrZ7Vf!7wz*O-{JkSrknSivM~j6m`Mf7x7X4tYPbow&%>k7h8O@Z6O8PO7g^9&cei* z9?g_MSNJNsDbtc#ig6xzA#*(y==$6eqEqpmVn%-=R*{L(C8(GXYH(J|XRWIMj-U#s z6BZUz@ zy5VL-(DmFZL*U;p@9`TRH7R1&K~_?Kx}^9Hiu5ZNe8B_mC1_iJ^o2^GcunLnMS*XRf-~;KhBi8RBR7^7t-@)z zjuaNoAkKi2A=nXP4>oKK|@RqnuIpC*c~FSu8|w5BRk`Wb9+?=kjEdZk^RZWNn4Y%s~ZF1Z+$Dl5fR(rJIg$^ zd7f$WjxKQ7a@#KJ_MH(dXaP`s^ks1)2DiUzG{HIr^_y-$De5yPGnA;s-Nc)SLBTym zMAMbIy~XGK=`y#!E8LvofpZsTV>QYmBl|8ND`B9D3YW~~Rsr^ggVPKuru!Mz7x()U z_^vKok&DQ0g^ff7nH9UQSt!sIEQsm!P$=ney~$7`>%=6}!HSA!ftGitz^{QJ?_4rE zI|I@Y95RB{KIj8pOcYy9`Lh`*;EWO}NdHuTr*6^`ym{)r)++v&_MLHBZugwFUw@qr zu1Y(N_GZ!rhG(IhBJJdWO5vEu>)$RttW5)yJ{!#TJ(q2goxXv77SiB1 z^-g*vwP6`A2)&(3L)#4==G%N%eeP*E#MQQUD9phM6iDg@K1Xgnfjfeo3^!CJg7P2t zr`Ge$P{SBpUzX>uCv;sUX&RLc`KJwnl>Z(W6mcH#dbhf*r4uI>zhWSGPmEcus44;=c8pOP<;{ODKE3+ata)Bj{UnCpB}jY^H|88Y5+ zIviq?W-abMFdNbT<8ecja{zaK^JW$DkXRjp0InBjMJfIWmE44P9rfRKJ-i40p1ZQO z6cijRGtgcbq;z%ZisLrQ@HGi)s%*zP|j+0axz_pBcrY+#MSQg0G$; z+JQ5~b4@SUE*5~r=y^&(R-BIweih*s(syyind9>HsS)7ZksW8>e;V{DaG5nzC0_ANci~u)PNb4qvHRoh4|!Gjms!H$wGr zGk9ClYRN__S@fqQsG+ZdUbX!6+7X;K+j}I+qR7_Lx|>gt1LuwQb1`i6zox75+CJo9 zSd3uRvi`)EV&`;-nE#|ZJ{yDQOEOBjVLMy5z)T!4yYDJeNM9h9OF@Ay{brr0G3b5E zior~17Sc1)`L)Jso12$na~=1dfRaa6d$b^8K*qObcyCw(LR%g9#P z##)ve)>$7~q%a`FG-3}5hs$DLnyZ~e?3eKcULdpWWFu=6lvgy^sB!I9432l^;;3$o z&l~)7ZH{9TGZcYsR1Q{VW9Og%tyIXf=7&8d+%q-?I?K|^JJ~ojhWfmjT)zP35W3nR zdL3IXxADH3n4Mj#o=}3?%e|-J;(E8XbH*b_dUZFmV;izYm$#aPu4PAsM>;ZYA801D zQ{karhQQ1k%cXQdq&PeZGVg3X9H4#x-%Jzk?xp`CgpZD<%g&{x{>d_d+N%6?`@KX# z`GPGtLdnH`YzG3P1}r6gMs`QFIoWHVLT#((y zqn;B_9G$gBw^jNN4W}iVS)o({G#rJvazy;UBi5o%G%9UR1e}E+`N)=jDoDn%m(Aks zFNug(#qTsSty5xE+*3Ki5A3IZS?fG((8@4@mTG;Uc1SxL4AMcvH`1cXc9F1U2T(aH ziY%grG7{Ehe)O4`V#}oO9V<8AB_wPuTR*MwDuK6Qy9h5Ry!6&7orvH^no{=~-0Va; zJNPR}^hW0_PJ0ci^?WYPN&6#-bo14d)nAv1wU6v2@7!uF$N@+{H9)nwT$un)zYyU1%B}IvSrrV_0lTeWnp2;H$||7R*&z)xG7x*PT*O4{X=dI8L;V&+;)JnyUu&@Vbo$Vm{+v) zqT4a)t)E_4)6_5Bn6=$`;u@$i!g!Ich#;HXpwRFI1N;&$gg|Rm_XVe@G^)3PDrHR5 z4~){!wagq++Z7wHvWR5`J`celGUyUtvrei7UvFI8?>cFW?VnGV&YvF5nzL`~Bs9ed zP$2@+mjpL+)^b$n-S|9YEqRcLmFDi=?r~&OO2n@4%dG69WC#0jrK@+u1`_;Z8Si($ z=CS{*FXh~(4~UoZPhUwXuNUtBQpKv;{hJw6Tw<#&1*3&cvWEN=}tos3$5pB2=q zrPU?LF5ccgZirvCJ0O8B-r~1WGuxZwZv^30S{aa2GFIw`?x0ucpKBi=^L3KJ&;o(Y zEcx<@wVH%5`m<5HM}9Ckf@x(mStYAHDiL`>>%8PXY6N2k{F#4_|G+|*i^Fc$&?ljk zi_z9qY7S|;Qr*-6b8AqAojQ-%HQx$bpR1Yk#-czEw1ehxnGR)f99f6k@51FQ;{sxp z^(+|L$Z)w6PItMh)Pm{F?O4d%pGcrM9nW`qgOEWR5-%=i24S6pu& zWWt02Er#R3Vq%T6dy<8miCm z2Xpl%R2xkS0la`cQA_oosvvV# zd0TDIj%QH4x2o(L!q7%~#oX7$pT&a(VbWbEkEdK9r>{*6JHLCn!53Q%X4L1CO8QJP z2|!vFkoS#kpS%-oQi?6<{e*)1Yo^6UdvOL0%l=tmWd6s}pE*C&06Sqdd^a_8|AU!f zK23jL2ZE1uhM`?blyhEC4ws5ybnfncH2rg;&8Gk?*uaOTx##9!FF3C5P{v?-$C(k}Vli8;05St^WdapAjKZkSUZAr&rexZk-j9jT!BE|0$|p z1^MaQE~%4fk!j+*PVEIs8?pW?CB*@ zP!B>x-E%bM{RP5bBUB)cwF9a6Usw~KPINe8F&s43<>;OV(Z9SSsHOBdO8wri&Zdvh zuHOiXQ|`Rg=6<+l?cmdRWYfIRW(kabSlfE%J$RQs9y)jXrbiS)TIgRA0!gg;!ln~Xd47L!Uvkqx}w3=-q$(wK7^$k^~ zxqKhbhxtU*X0qW+Plv}0{^LE_hazh1DB_IoUntQ~Q`gJITRB7MBf{}9Pt}?HV&k`+ zXrr|x-JI4^Wb$|EfKw9J2T6sYf=nh}h;3UrgCdk_&!(#?0qa4@5tm1eZNi7Gp($*H z7Ky5MD?C(YrA{6Ml1ZZAphh*EKkQUC_;)_k{s^~Op;%GVjU}M6popbvUmEn;0bMx4 zO0`_JbK{3#BXf(2clhdh&qhPps zNgSb{Xcsp8iOy(m8rz#`qjB))1&hhlrWP(AI%=w61mL|Cp>rnTd5>)t1X)4*5%pleeRwL|IIaI%m%Zj;>7QZkcJOq6WfKxTD3uSb|MtbG8I zbgDCxY>~A;L5~;K9HF7F?_C13Y(a#IQCToj060MFIrvFN-N9k88$&a{V4|i9lGfkx6+~jW(JkaM!oT%rrt_gi3>i=vHM~gixJmbaZ%Wz>$ zG1^!lV2pk?izo_GJ7iTELsL?NI+QYc2NaGs9ZR*T9c z$%<)&XSWo=R+aHC>ecarUxmDEhj{q>w)vw0FkS9mDmmEJ_vNLj|rOOB58kh z9OLUpq}%*AO_XISkmeN|X;!;`X><+$W(&NdZ9O!o*y^w5F&z=H@=8dj6%O6RN^T~w z)8`?)Ed&s)3B2kCue;DqOaR+>of|;iY(vYct%lM;Yp?wp*{?cqPrW68{bZ>242kkO zkii9BoEhS)#qBQdVy+SuzDFpRzu_X7lCA}sIQqh($ij2@3!8XAztQVNG&-55;a;(X z3|(jCG-TDH{vv4dYs6ZndXoERwA(uL#Sm64$X8@kB-x8^c%z0T;rAn@FIc9OCxqcN z`hzAN$A9xFfCY$`w0{wYg}&!H5PZ5b+)gr|iW_B)pZJ_E{-3n4M%f}RAGl!llBXBj zsSJyz2doq;(KDY9*H@P2NXZJ6tOQAC&s6S zw@0t?);DkYM%A#ZESk|~zn~$u^SUj@b&5(cweRbE5F~WW6{_~qTGbfN?gZXFo@;jG z(X{0=`w_CfOSY?(H8^hnEhJZ+?^-Iw_4sbO$r3OVw4TZB1Dz98eFUX?YS$Ibw&zp2 z8WGZ*P@=i6W9AwsVYxED0)sKnL>9YTC;ZfkDNA+eQxC^!%H!la(PmtyW(L*n=S;a8 z>w6eXt2&{bEeSG)=TWR4C9J&IPYSs?lD4|_`j_KUGQXcd+wq$mCabnf^3r$poGf5%jadv==7neC|=<)+`QG zPvN?{o_M(q_vx@sfp)B>-E%K?EmSAM)S3YoHEqMLghjmv_yP0K%CCJVzGw&!G0s^z zoRy}{ijOk1Qzq7FBKJOGTR5`eDYk+_r|Ho;6g}XR;dzYEs5*X3baAi^w=B#jSk@mt z)9~~coU($K0XE_BQDr^S#*C~Go00QWIEs#YR^_S3KLz<^ZQ+)C7Mn7^Q17?W6!G(K+-ktcvbmr28R-Ah1P-V~*`lnph zJE%!VnLPx|^|0+xmENA#m!786rNWYa94^B@`4F#!p>iN=4(UF-6sq77f79V75+Lk8 zr{fzPwJ-+eSsGZPYlI(g{;C0Z^Vo+==4kmZ!X%qMaO4}Wce-j)E*pk?Y-j5McRp_q zUtgcd3A+9`DbjalwImQoWEMD=+;|gPxi+z@^k6;lPR+AlJLJ8fIoU=4_29;G_x2-^)Lj^|8(U$O!_$4Hr{$3mt8E30>!VgnDU@uJ zZW4>-gWM}6{*3eApDYj7Bw=mKFe0N<8o@lOci&=yA{S8DIP022Z)3}Vz8KLP3d=E^ z*tw$^w^DFiq>0TANwk6cij(>0$g2{RYvlBDrkVA;jB8A2Avi)%%=nWN3=?w|V#Pdbbv22pvVlu4MMCC+f>!xk0JsaO5Cn}|LJEi}Sqg8nP z?1EoaOfjjp*GRSZQD2g|gk~?8kSP6XRF}qPo2<}^hHT@}m>)_5<#QaR^AT?=T!;qq z=o!x7pC=IXy!=}^@=3iMzX;y0L?P08bRMl^BXg>g!kIjAnk{9cKq^^gV<7b?`9+0*5-KL|$i7kv%FrYYd$0Z&es&$7nsx^ z*>d(O`w5>{57eNLikp>w$UnunUUirALp%~!QT7QwN=nViZE!8^MBTD-O_>|Z)y94l zb*!A0LrbW#LI`@A5JuL^^C$jRgPIV%X|az-b3bM27iD`pj7wZ)tT&*Fu`%m$r{5Jz z&WpcuPftvde=&ZM{@80r^Y{07#=D}X3V%t=UXaGFpDVZHbpwOarq05Z$rsf~BjGoK zZSX0!x^@^40LA~2lDDILAt&L?kGU;=2^SQaA>%`+kl1$Bi1FG8&A!EA47Frbh0S~> z)zfxE38D}Fqwe`}u{!v>=VnJ2bL2*J_t;C!By|LD(r0ORE%;sIaiwvd4W@wM59VUKbWz z=~4RJ;7NLSoinl5`N=<5v9jdVa#a@kxT%p*Bw4=wgyloZQUp5-d&;sY9DB76Et0)9 zxS(s0|BR#Prd`Pu-3jB9=O~9S4VK`On%)gcQ55_-$BpBc+)HgCGjIE*+I%13 zN#RVBRX{i_`^2J5_)~;Uf6W-3jrhcVWEdpgpWtR$&%O*k^Q*Wv7h-d~1=(_5I)P%8 zdMXIvyas>T5xgj}i~{P%2N`=#d?egUHIymqYHE*snS(yC*!iA`$GO;Atv9)0fHRdV z)9}ksw=yC#1xcI~CG&By83$SCt=bPRid@XYC}}X;U=Hyy z{itTN%wP8N{~kcA&Wo-9{$k3{0eN{CN^LJI_s~BRGrKKl*kYqd#x2g7^lEuL>t|Jh zA@jEqnke@?Xw5<+IQJnxy{RRBri(9g&j;2T%&5WOz(Vk2+KKy@5{0P|Y*(!gU$g7= zVfm;)ZRht<{gl!7_mck>L?XHUo|v4ZkY${+Tl&cZp3vBplveSC>{+*%11Lf1mw%O3 z|7S6B&M2HHGlChh;~nG(;F8*Hn%3yekL!+#cNDhE%w(D|Xuv!r3)pdv zYUM({DWK9ZzPr|45WCRX`EPL~s6Yx4Toe~32)X|RkwMv~fwJI~Vv(*qSP1Q(@_k4Q zzdf_Q4f?|m>5xLPWSMR(+l%jfUtb;EH?rRO^0jDdolLq; ztG4nm`9OFDA_w0od?}oaua1WppDgD5iMYeuSl;Pu0Z?QI3~nHi6Qo2DE5ls(Jn6>! z0wvp`o+ImOxLfqLw7C=PIUI0ioe~D!fJt;pqfp)o${>;K2BXZ0ia`EK^rpMsDdP^@i?gkrUl|vVpu+Y&tbGbr2_y!~RM088pKexzjK4 z80HZ;1Nv?j%lq(dX^4X*-tu({1seY~d*&lEmdn#Z>lar|(=3G^PlEX&!RF+pnhaV; zb3yD$l-Y)U87&n4hrbd7q4t@%s$!B7^#%>s_^pB=|K3C?J~MKgh2_KSD8%x4P&kf^ zRcRwu5SGxgPWC~DvO|{uc*;Wky%wI@d*4CjEthTdYU~|rw$$q6H{0$nC5t?$&eh_ zWt3Sg|AtmP05r2`5gaDHDbAV+Kw5<%@c!P-?1YOGZm4&-K=hP2VZ3zQ-8dB91XLi5 zA+e;jIsKKLQo(|5c2X8Q_q(>Gsu9M(EJFc%P$&SEpgx*~U4SE>n_1a`!v5RqzVgC< zPZ&N-rnYawf*SDc_#L5(I)h^AZ&*5{41;|NCDQxD%Xk*ooVZ8|Y4d9hen^qn7j#}? z?Kh<AR$OfBb}1c-QC^YcWr;)cgDGA zoNwH5w|_VU_ndRR^*rzM&K0hzEQ^jpi~;}vy1bl}Ism{C0sssv5(4;>!clQE@Gk@t zMOi7}8Tu!yIX@l%C;@pXaZQi!2aE2Sn$yJaCq74LXpA%1jcTS2^4t|^7V!8Jmb5bP zrrg>uyE}=LI&g4J3B72jbw)a2m}g$PMbljocoCAojF}&O^7EI&SKUy*aww@y`Z!eY z&P#Wm;-Cx(`~Uf2+(#;b#lkjYID0ujzBF_lgHj!%`9VqUJqslYid@jtY#8JH7c4cL zS8x(bxJCSIKb@O&0xX^*2wqyJM~8(mvQ*TGNqt)CQhJG}&L;XbjzSzoNQ_`Y5Epy% z!x?h$3^nDHh#K{jwtXyP2`)A^cC!^LJC<68tAmfPKGho|@s*DNuiiFQwgD3nj^fV= zrK&?bUzPorQc_Y5D3PdA3EQs?BPBRJBOM@U@Nq`_ucZyAn}T zXAg9*!C-GPT|b<;5~y0hGA8a({t!zpL%W#5|53$?oT4i}T7@7k)mzx@NJ)>D7dHPg z)G%OoR!OXgqj4jNd6J4wP^N6SC<@$EH=qlix)1jVfx4Bw&m;qHC-Dpka!!5R-t<8+ zW!@X;YI2-Hpv^WAkM@n)SqTbj!42M#?E(Jc^&lI6>wWF4iYydtVJ86|iZ2Md(q$kY zagm(X19FyjrSgv;X)7U}mPzymZ?j4fQtVB=JPST-Cs!V72Y8sVgt0gc{{!^}&OH-t z3mR(bK3k}zO5NOZ%wVMxNg#Mia~6ihkh8ZWaPL~ih-qoEo7yi9=-Y<}B`<=4m|R6Q zDdz}3M)4<1)`Kzd3KwcUo5jm2%Uoa|(4n&>G{1eXWygp{r*EAQ6?T?gAT*EdHU#^M zepVsuCH{^*Gvc9gNM}MkXP#Lb2U+^Ij5j5994Afj&fzus&%mj!KO_dpY##2$9X>w$wW|`iw zFq}A<;EeBK1&uM+$BsPX{M^a`RtZo@iNe>R13;~Q!O-d=cIjTAJBf2xzDN$iNn{TkM zPd3^p|E7!t4_;@R4p=Ac;@qkJG5|Mx@#h=VoGEE9{G#eY%?!GMSY#g z8)mDjl6sLlM7A?5(0nLR^YwUbSmMh$EEQf=uo3}1S<56dCN{R3OgWg_ z0&_k_I*>7e_i55EWElx~^`x_>UCy;%!qk$t`z70(%*S--_4KOO9=~o&In9`9c%&%F zi9>K9s3~V$kNc^0g<*(kqXYwnh8X}2s~lv-NEHa4$7oiNS&01{eD<_VB*B0nq&w zCdpDO9l>^6xq4O!q0Y?Gy(0Oqs_Mi5W5Y_NHH3iYMRX}&yD`usxtP#Xo60Lnn$phv z#g2F}5ZRRfjK^So}d&C-4_elU&oL>KA#tM(C#fFR)Emd!BLqL+eXbZFHEk8PBE()v;i)586` zkRRJQ+kI=>DorYjFozue?02Y<}l;Ff@eKl zgcS4#t;Htr#5c!xrtXv9!^~H@2$O+fAFABWk+M8Vg%``UHrGzJC^NCdlgsUK0q?#F zt9+rJvRo)9U~baF0aPm_nD~D?pnblIem|WD0Vo9p5!mMH-Y*1;p6rZj{I=JnNJ>H| zzF3SwP`WeiuaPXf^vOMt+&S+k7Tc7vi(o(9Cj&#;?O%TANc^2crr0lGvBD3jRF#s# z)BEitd2W8ZGJx!`P+viQ?QokTzdrYpSSjU|ophy*ud`8@KO3T2(Fe|hWnqf9?4{j_ zk-wZ)2T(MNbcnxv8D$cg9yTR-e|Y!qra3&-@)4I2w^Wa)Bu3X?gJHX zae5fK4TDKYtq@?`Rb4-MvKc~C<))ODR{cmL&5aGS+3SlESTkIev0?+u}_wNW8Pzamq;F>$ad|&D|_I{ z6!yXgMvAn2Qoc3M#NHk%n7y28rU?Ek;w3w>KfcKlUnl;*ADvVYCInZ6!1LEcs5<+( z)irPL(wpgGHHg^35+ZW4fbGdLY|bd$eGR(madT1cQQ};h^Rv>uF?Z^T?(tRmZ*JOs zhehTZRmZ!FO{v3Zl1Yior9-FdC7K73S;Qk#!jATK(s+7|-WkGgQYCs7TpyD>>^pB! zr;YXv6CRO$zCMz`qgcfUgG9&21CI`SQwJr^h~9(Mg=0A(*15Jlq!GAjP&W#fB zaAvFz&C{RzNiLVxZRw!%?Xvac<)*KfqMk%BK%L92c4f=5?~7np5;y42DRVnjmi-7? zGuwad9ZjGti3H449%5Nfxys0XrYAae#pJfu*{?oQoQ)sRrMEM=OaOt12Z|2YE5A=( zvsJ%9B7AOy0E`-W9-&9qO_nR0WG7=z5nsJ=j4Mpw)qa`7AI$lX0w>a~F)uui5_T>H zZO_tzdHfDLZ;%x$>^tm>=gUb-^rt@GtN4u8VMn6XD|}tHfSka`KsN#rk2>3+L~a!Z z@LxexdFAr%)|n0fL<~hGqvz81o~Me5GY{sVtCcSHsO9}e2aLgjU3RP0sg4J)Oi|l; zO54JLe1aHZK}VWfsUW5aVsJ zz9oUh2JP9L9)+-``$_m*4TJ{*FZ<3T;hu4y*x(+WJSn6AU?l;8hqCcBc`jXY>=eY%0KMNEYy7xT^#0fd%#&} z!jDp;Nmpw~K=*yNd-*egi2nmlc8)1dG|xB9;NajMQFa4bw*s%DI*+W4|{B!y2<{P-$=RDGhUh3 z@gg(POe=1CJN3wE&KLf~4b8Bs?b1IMT}e`Q7DI+zF0qr0Mx}5I2!3dQ<6Rw{-qW%0 zcVTyQT!#D3SdeH-BoZ&G6?rq*V!Q?bk4E(a)<`z}=Rf25tEU2xEXCU6)2_B~kBc3v z>}`W)dL%%#)v!9s?<07`uvf@@F0`OGmDKPCGPz*VMae^&@0GJPZ;Ys2fTRnuAaY9Vq>LQTg3017YAz zV$og51o1%^L{Z$sqajq?+hJhVFr$NV`+0M;2O|m|++KiShz>Fq8K<;% zqVtc-thc^6AeYDu<{|`ha@uC1oFIl=FHod;KF2!I`Q~_x4Z8BDDp})>x$pw*YVU|? ze8xBmo9PFX<6O}n%|0~2p&}hEuncNhVj^`Idx5yJ$=!qmNo5NW>5Va~&F;UlWUqO{ zk2IR~K0%`kEZRieA6@}j?}Jh;EvB&&m)V1sh7JM1+}!h_*_2oCw+)#X- zEPXt(xW4d8qX}t)t6e}rfu-$26Gag-=LciL@BB4*xe5ecLCfOQAcaX@uaE(01^m74 z_pf4u+p#74Pe?&_AK7R?o)l5w)rjTkZSY_U`Cj7`wecwjj4(T=EhveCewqouGVq_z zPBoOG{pV8_0*(EDA5S(A*Ric=>AS-MBoBT@dNsN4ixuzc;z!a(EYMuhS z(lh31j(n3<6H#vKlos?A4x{>LoEqskj?69+>$zuL zh5<|Y$zqt{$gZKU*w~m^n$y$6B{vthy#WcGB3>67=5lh({n#M#<!ej$1#<^U`x)yl^`b3>}0ZmfsCi>TK3_uEIvD{a_*;cWlK)a5R`#hE>?>4B{5+Db-s_}-BJ31AnJ{*1Q8c5@ljI~ zN2j9u?`M^!2>9p}t>-BlAs=t*W1YGnqJ{nzrKrDIRszWyF+A!Pw8V~|;)8)Z`pd*A zAWRMxmGJ&OXbbQ2$^4ze1@^&HkecjFYzl4kTR>V}an%;`90`677}_s?0vfM6IM?>c z9e2$#cdwt%%hEweozGlW`8bMI(w}>JU;nkc(sb(=MF_Vqak>m z;khFSkqQ8$)r|FQ2gEv{q#oOvLOPs z#imA54bOY5L@U+QDC9Vm?34>1qiaxEr{0FRxjp%yb+n#}%}*V7EG)40XQV-m^!}fb zly2;p$X_D`ByY!#?2gr5Mu&t5XtPm7sx1$99BU-ye4IWm_q%?W)}wxJs)6Kc)0)1z z|0Li{Kt;7$_Rv&TRHyXb7V~=m`H<pu3okrKZNWT7&clIO{Z?&ztm0Zi7I>f z!8n=kD-X}q)UD%TtEgH*9O&sxkp-A$!{n{H$9t9cOM$M1SD%A}TYrldnn*W5W+H;xQ~!>TN#i58tge>%vZlbLlrd_g9%P zzy$?2=d7O)Za1bB@;r6#(HGHHc(B!zY=yMGziO(!u`bXE{RlQ$YI7hWCs}fQ0{hwHnESq0YTnK^8^P*2TmAfdqRa@oniR+*~hP$BxCWZg#}D9 z9?72RTfxBJxvqz`wKHO!t*B;;^34>=0;9X^Vu6J3uw#{G`)f7FIr5t+UT3K?yhv2w z$Ct}puE+Dj+Liby37^@=7A z4-O1m?2MFRgM~J7WrYD`wOKk9fkgXD9J4ciu|}QHHhf_CaqK&tGngEj1>bMRUAsnD zpG0V6h7kPg2H}kCtbM>Pr{&RJIl?DLMli?m{r=kq2leIXj7X4edEYAyPcyRuVzs4g zSPv_lCMIum8f>|kwHZT_*p^@cB#8IOO{5gOzqH~*{Nnnj`JdsEIBXnF6pcjF4hxcR z-W>R%&AX9yZyR+IH03=Ug-YHN&^N>?W;!ABxDp%fHVPh|tVmaz*;wl7lJ*$Te`D`8j4P&Kb=I z4TI31ynYlhTTd3J6kfaUnqjiAC>HCtk{Dg=hskRsneN2JIvqXm6wk+5z@&n4}IVwEw)(rkFxZ{8mlE= zQv^R*7G3lEtqQh~2=nRQBRn_XT;Fuv^lT3qMT+u%Tu9+~RWbtuqzm!%+!!UB95%dz zf-ZJ5vD z@x)&st>Slb*;VYZU3|g>D2@BYNd-JbM&*g%QT2Yro9@9^ZMT-mlL`NZ1F*=+k@u**}1J-+XnvRgr1QzH$fMk0F?q=M-%nIKjY5I|No$JgWScM7KWvB+Au zCbzxrxJjq9Z`BX|TGUq4E-G`_ONv=FSlI`R>O_3QB_2H|M}9k{b7%I9+n6?-*)BT~ ziP=Kd8%IA@@ECh~FSqt^baPOBiU+|C0JgTuuC^5mzjit9yE&T6fTzyp|99#yJ3B!H;r zU_}XwiIG3A6PL#3ID7`_re>)D{h-0|L`jsjRzIC}quV3-6$O9dR(0VQCyHT)*L%)= z7l}li&b&1UZ)ATFT&g$O_YCC_U0uEbLp&@ZLVQ2zlJot8KX>M^-*^jm4@gfVGkr#W z%aa`}xT1;sQAkH(nqC_gXjX7<4ZirYcL{A4l8uy9szsYYs$~5BeZ~R*&;>JyYs%^M z4|4+0j1qtn#LBSVpArh+jx+%EvlVfq8vAEN=|}=8ult!CA_f)RfEQKT9jRe*oumgyHNi#Y$4clUsvBq*r?5%ej*=rDSBNj6@<`w%={xhCKz* zOK8zOT|B}kF2|%hozBx9<6S$;$?K(=+_5eP<|*wNUuUcX{==T`Vzo!2D}P7N0h8n} zaQ4Cppf`u#DZPyeR{q`;EZW4IKa;F}KX=&(7U5pSNL2SD5$}>mWvEA8|C4R%bdjq= zi*H%j7aGW-1kR@=@-<#&`Q_iZB^zF)@b{8L3O}AsCTDiG)(_Y%-LIUxICS$pP3ICl z@AvaD+Ja=Z#x^6V_z~P9MX;C6cdNtbm#HY7QG@Nr26MQX8NBw-yvMXQn3%C*0Wi0x zpN=t@4Bxg1x(!E_mm_VcSU8DmX@QA|0ae;`FH!&XsI87~hio3%jG&)qwBqn}Tu!$DljNtfKht9e6@|LYk|!eQI(=1C~|<_i49 z`k{5ESlRR+OYT)rhSU6|Cn9;>;_unLXDX(vbP*qTR02Ey z#6-HiiPB(@@~zw-8>mu;JhFl1!Pv@5?AutEM$=n|&Tlt2Tm~FRX;h4Tj(La+m?iC+a zExotK*jtWom#}hLT)XR#{frBzx6lr`oNxQ=Ui*?p6lwM43a5af@d`T6$Yz}=H064z zK5~F6s4AjX=|}s}wx=uOiP2z&_(;?zqxd=Ap6zu0xF74{T8;JAlt$#4byKCMY7A7P zPkJf`EpNn?iuFVY2ne>yTw7HdsuiXPi0{xe^2<8Eit=WA3+O|IN$ChYv1&bG$JI6T zD3WvOhZ&GQ)S;(EEP5#$$1iVaT6jEsys?c?q*PA#Y>oyxI%vkffAs@d2~l(&S~G-Q zH=9u*$)j0GLgV9OxA2>^-AMa)`n2=sYeS2lby}QlDs8TgV;3FPE_VASP&K*8u8`;G}bA2S555nl^7Xpq%FGK#E zoDB+P>Noll0$CdQe11jnU_mhKgpNjZa3tVyNWD4Y9qN)7p@K3Om+;iXi-e=KcChaq zncVdvil&dct>=g!U`w%$!h#_mZfq5 z{n}^P=^q+Sn|owY4|xoI$yG|1OF4ci&vUnK2d`0@l|OF{?!knmvwXV?k$1yU!h{M*j1da>iupPt4Pa*~6ZJiPPgpqFUZJ9Sc^ktLRFR=F2ZOM$1lSPA zsb|Z5fg$3ZK(-GORp$NG<35N1dWlug-8M8^8g-2;8DmG(lA2!WT?~cHrSkOj&9;${ zT3tEM)AYllOZ2qC*XgR6X(2E@4kE!=>-+P;GF!GAsU+~H{%ZTw&QvwL_MLstDem(R z`}A5PL8y!X9S`)xN{-IglNlhoD!JN%h{CzG?R&GD5AS&J8t2|^STdwm!Tc&Yj$Jwu zPwLWo)9Gq~2WSgH0oA0>&*$0YKp=XU9re zwGgE92=TNbmA5cRd$%sO=YXg1>f5(nkUi){Vk? z5`#k*VZ2=W=_Qxq({^aJtB3&@T6DIN#y4*lIkSxNZ`M>LKn-mLq#-}1_t%oe!Q{oz z$SY|p!%hAH>PZDUKPD7gyV>S3R@?rJuWMp^{`Xw~XsbFcSVG4lZ|X6YlrRkkdfN;a ztaED0SG23JlSHX=b+(%zM?IZxXyr87IKbn>^FBJLx7k@eh=Ep9FgMDLk7})!ye4X! z%WbAxp(?W8Z?_JGOis(0Eb|D{X}g*9tzP&tg?tUiwE{w3_ygH|_T}8VYjGJc)S*ok z28F=q2G^zT<5u)g)6eu^zsTSsNH^;NO^woT{upGklsm^c;A-^R9*sF*ER`kFG?NQ^ zqX3Nd8bo3Ty@-Hnp~1h*FjwZiS>S6_IJ%Q4k$o&e=adN-RFSSSkNg0cLGJr60~hmkmzQJDBTrWJA;D{ z1{ITK%H|`CN09h{rCw4;N9w^m>Cc~VYsI=xEJWZKr2S7&zSGKxmB$;37;vi7ep^+c zb6kt4W$hbLAquv|!$zd*-@Xo}U9ay=3iZxiABw(sKHe6MWQf&Zw|w+^ppKZw3(d`i z0Od40Nd7akaD}%%eP=VdFp~Otg7l4ZpzH50pV9do;7vy#jzOPTX%?%ZG`c-UhD1I( zULVm6q(3TE3NskG!EIfVT%TBRxP$DU{GI3>7Oo#zLP0C2kD?tczc?o-mL$1e50E8pD{B{z>x(gykSyaS@wc-Cda!T z$K#iGINq`Q+6LXj*}nP=0S8Pi6-E9r$yvnDgws}3PznzY5tanm(<|>xP`9Y~LG7E{m_(C)~uu~)WHv&`)TSvbB$Q}AFfcOzE;uF>3tK9f+-^gvWqh*D9 z930m+{KA*KpLyS7fhI%(y(cTw7+U7?461JB!j1@_E-c-PBaPeqMI$)yRj!@-0?{ks zB5kJ6;OGl4ndbF&qd8)z-#UZ}j)3FlC^2K(>}b(1n8MEx4hW0k%>FdqV7c@_#_e#2 zu{ZTeNVf(={VZ@`xw>18+}{MdQe}c#TymbPV<0!2feJ0G?KhAV5mS6nmq*a@shG|H zl`v?@HtN1a#JWdr%0}{@YV;{GQfW&H3Jd2nIXZAKyxsn42o*G##@~vUO;gc#BQep< z&00KM+qRb(rU-bUpKVr&m66+V`r7&>u%vn9ya9`lD1l$>)gkdJSh+dv@;E>>4MV8* z(@#BvIajC%j}>u=^B9%Sf{U0GoJ*(yBjYuxAe1l~Ps5dWKy3vC|Rwrm%DbPa3( zlxu0IX>vGC=gRqN3OAbI_fM7@wbWV-C-FoleZS!{aIBV`(Wz#&MQR{tyhiRgj`unF zl!*b(fX+;rDPAM}$?dN-yessw1~uO(5YL+~82bhT5Y^e}$?mpU1D`}9j+9Hj4z9@U zU8N%u@p>Z&y4r?jWYRx7tb#al!bud7brM-FdItJ8SyaD^{G_ zGhIuvAVyh~TMK>@at1y4ilREx5AG$u`2CEZdy647o@1D zQf1vlZah}+A?gv6#9dV2E_~22sccn6gbw10TYF6yOFxf-q|}8cHTylSU4wp;W+wt$ zCAg*;+`k(s;RG)g0;G}WvyYzAZS!40nlp5BAu6@_D5RDKxAn0M9?T(RXbzDMzovIi zg(L6clf!>+kq1%t>D7{wnhn#$_8o$i^MJ$HzzupiFv1K9{r`1$#&9+EL`v+9!i+hZ zKy29l{_hAd9;mRVr>VDkzja1S+?K>i{x!9OjWBxsWeN)k#{>xHv}@nC@_9aO4lBw+!FHMi&uP4i^CSHN^`uv`$a8|^SxddXt^&@ z$Q|P}Iax9Y42P*Y;D3rG_y+bEhWOC4{hE=I@#P30L!OWNk$iG7WBE5lwZsG)n~vI4 z;uQQRkt7F$HDQ}uTG(F-o(OQWi$18;45a!0{M@@(gc!)pke_d`N<7^XTTj47f5pb` zQl|;RJ{xxId@Ydnd*2&vpaGR3<@aCcR|SKiQ=_He&uOgvL`u$OKp&m$+X2+JqASZ3 z!jgO~ZtA`c6Y_Mio$ynpL->#LAEe^HQb-$4QWAhu78_G2>X)osh87i3*U+-P6l45* zJ;hL7Jb@~~In79hy-7EQ7ZTRLw^oXy%eXEZ=k!4yxDlSttKbBh-}i$fDvg0p`u zK`J3$o(G|uAs?H}8&Gq9_MJtjRKr=zU&FwT5B4AEgI;R<&rQnw*9I1b9X~Y)AeI+n z0TPHlPnD{yisGF8n2rtz)C%FmuvTb6yR&vg9S*>&=pb^qD`wSJ#x1P$g97O6==;BW zb062tIla-C{2;2j`>$VMvz>Rq!LoMIl;mJ->Z(;z!fB>Nj<^`n_}gHms2=EUn2__x zOG(vZL&L|Sx8r4_-+Xo>7E$nT^H-J!6@@tLvb9~+JD6%iwtRcrX(g^d(KLQ^goiCL z7Bq&UY|U54oGWDi#E%+7@x(?5LK+twB5@aJV6&HPjrF|UYFB?RzF&`vJ;YBj2_NE; ztk!oxk?`ssF~l`louoxYVSvSu**Ui$Hl=s~JVZj`g&ra$^_ao|#od>FfVHu4w5dj! zIb91G5i)_8bvqO@I!639TYp8th2%9)VzdLMT0d0{@Ar>M*0Q~UGZZ2)*U{BklH3Y= zv27BcvR#YOga~grhh$x4v^oSQsvjix`5h9FxAQ#x6qR`p^MorMX|{@0I&^Ht;s}m= z)o|Qlzfj2Z>j`R2CHbS%q9|lJYQL^ch^RBOwe{l3liu{|c4z*jo{tDXataHcT5a$z z^oLu_W-=aLH`*SQRv1wm9Iuj)f;vNSkMqJgjuQA7SArN zqn|4Y7Pw~c9cEN0e=)@lO8Z8@oidZ}2)+0P|+;E(02&hfA)V&|n_l?BF|ZZK4mur;pP?#59s+ecqP1rl*w$RM3C z{X2j0eeT^9o?(t{O|B{dLS#?uk-`@gbp1V-?AC-zF8^%{qfo#(v8eBpq*twI zo>})>o{wAfLF!W1LO)4~=cykD&^)&x%685d3`?IX>Q26YerpU*wc!oet))I`;f*>t zsx;E*ZS}>NKVqxm3i1PE*jUkDZjMX5ux6;WcJp;~Y{}OqsIMKC#HFM@!GL)nrE7bn z-a1C#RZIwnAhA{g_f8h5pp+ISpSaEHa-2;oiAG~vQ8J}9k8?3Ko09%jkm{IYe=SS z{f(1rz<^-n4a6Ig0Gy~I<@UrG+SOX7vs9JWVcFcB+0}SX@+0F6IVmOo#Cu#cIB>Hh zW=%W4K&wR3%1-SD2|rqv!B;8^;0BrL5m9LVc=HSygi(3uoX<1J{KmGhSYxsU5P>x& znJz{NAdAx>F!N~etc%wOc0=YGE;b8zgb1Kk^b0*Ds=GLStmywj!j~7?71FuIin^1f zFc2c93*f*kjcyU~F7#$>Yv*;GZ{kX1RVUm~;AF1hqXhtk3a#FpqYwe7VSI37edxx0 zAGj>+Uxi6z=VXUHsb}M_8QRb}$^wQF*+wtxEcW|}dj_~OGQ`1+vohLS?8~}{f`mSB$sT#)TC6S z*kXU+7QaXs$1<;$?Q|JQic~lL&{77A=)}KKm%O=`T?uDPKre$iycP2P^8I4On8XfPZkS zayQzB2+WIZa=f3l_1LqjD62pgmw%CbXeUP|Qj52vk^RgtczexR1VocC!=QYNIO!Dj zF;4p=iG4!hH3G`!A1PZ6R|k$tjlm8>2WF5f^>#!UX#frtJ`-)%isC1@X?Ryi@;}Fp z%la}<%0ONsp1@(ze7>-5jJro{Y7h{aXHM13$)R4Mq!a0kj5h;TAaJ~jT6gl&2pETx zPleh7;bDvpP(xW&R3`tQ?LL+)>Ct+Dx@VmXZ3=gHa9jtjJ;Y+DaZ_)fN*Shaq%6KcA=W6m@>o| zJ`71LhRNPTP@tAzsWP>!tc;z1tU+zz3|SW7!HJDaro!`^qE7BoUI7Rej>`nJI?doRP3Ghv(EZe~>%8|-@odKCX(zp5Uo zqfO1g8Oy7o4wmvjEN2maUMHnz=7=4>y8K0KwvU@K@<}S(vIk=c+vF%zZej}0mO1Y` z-umdh9%W_#d7?5@G|x3owNRzv^7)NR`57pP&)R48&oysI>4Np-7a3tROiDY%-(d_f z2%RB2aFB?IefRDF!#bVi7g+H2UN`Z&<4O#OADHL=zspT2cQ4CJKas3lydqdi!PPFc zI=~oa10r^VTsZ%-Gj~w8#2E)$?dT7vx~h?a1*|L2B&b`x?S<-hh}DZ}e5T+Nu5TNG zKbj9~t%zSm(#;sXhXLT**mIPRxr=4@y@+MUL3&HtYP~DVs~9BtT1)nfni}||3uRGg~i20YJB3FiCrl_CNqmB z{y8?dxL=xDLnL#JkDP2=)>d1U{tYM_M*amzlxSi`Z!NrxNOBCj&~e?@Mn|F>l{lyGWnv0t*-sSkKU zh~Tok150WAb+D*bx!Swf4RSnY+YijrL=&+hfeqM`OuRgH`)0xK6M}Eu+SVkt&QF%c zq~u(w{PzF=*)Kin1%E;9I-6bmrf^G3OT$V*Ao@>#$%A49U7_AMF5m&K6M;nXuXx5C zh-t|GsB#9F07v#x0R&D}cn!cvm#Z+shBecKuJ<`w9U}a0nbDg+_6{j?Y2fCl z6UQ0eNW9|%SwgcPYTzJ89o zZI=H*(^=$5REMO`*WGgxlWc))h)zn4S*b0Khl{I$d4Cv7#u=jgUo%o^vv{6P$Eh6J zTPDVbh5i0_eVqCKgpqC9{{u#}w{GC3G_-CROp?Q2hs0&pUs=~`?;Fg5MA#vtv9VFT zSUtzr|2s?@3Kq6Msm8*I%d7lV3ru6@oQ$-@^l5 z8%tscafH5f*GL-s5#jo`P_`Mnf_=bNss}66e{MgnVV_yzT#7u zM&wIq`i`b`{TrGAV5pJU91TWRNr7f8cl1yL* z#{!!2BPB5W^i+?o5o>$}vhz9K=wPj$g|@Dw?^}+i0y)+87+{>U;Fgp|Tp?{Z>3A7I z1_wtri5&SUo47NiMHkWs=C+uS)52d>i`pe0H}P26Ep7gX@)148yC3VHI`Rh+7vpaU zCl-E;SBJt>i&S`ns2>d1AS57dlMLSUHiU=Pu97Z$-h%%NP6&w}DrD9@;+sQW;jJyS@EAlo(%Lm(~0~Fcii|bN1N}Ib0fZ;z)>0xx&Cc+~R5+QXrPEOuGGJIeE#k z6*R!dVxg79ZR_TSP+P0Y9x#CD3W-na0*Zq!oRNqZj^0^nO{6{0q&1)rz{F~Xh>3aZ zXG&udy=*)E2J#?c0>Vm6%wsmCU`;}p0t_JTWV$ycAjnf_X^i)U*@UK%k)n@O0DXiq zzeF|*r6R))FXRlMe9-)HhYoUuD0A`fupLkaq|CPc1Hx@Z;Y?Nqo+ARDg_vHqt27GL z1BjHymJauJsi6Y_<{YRV`VtTLZ+~*a-29oG_068DcV-3;K&UcI8Try@!yU#1&qU?* zCz!A=lAhJ?{=QHWm@XEDu~tYwpr<4Snm$YUmG$*wMYLLf^$q=N;&+Y*UV1-(zPC3v z@n8m_GX)3%BcLE5YafY{Ku1QlbZ5R+qd?}=nH%%C%}qjqqSM~$(n7g%D#cX zi%8Vj`2V4x>{Jh)&FYXBEn%n&N@Sq}lHU29G(W-)WpD~tz%HaGx<@;X)Ag=EFn zPj=r{fDAlB^8dZSsKoY?Z4F=dTm0sBpbOapby+}xc83?{jFR#pD|M=}9PA7(p{8f$ zaxXNpiZ_`;ZKDJ>aiNV{AQEyWHoMy@aaTV+0f(W(}g;P04ON(SSt5!83+M^^4q{RRp3-abCAm?kJKrs`Tfb(^@CU(p0$+f?Ls zDZFo2M4;t3Cpeee|1N2kyG--EnI5b;}Ecfzw&- z(VxU*_z*;eP(I?vnxzS$AaMZjR8EqN zmHTmqhfkw|Z|ytQB!*u3-16!-TYrZ`-mc42RF8N96H|yAUB+QTbJ?HT{7Yq`YA_8g zDFn}Rl!ZDt&Pxl~ubH?iG(Qr&NktB=@#6#E>w76t!ec<=DTZr@c}0I|BZGJ_Mfm<% z$tdBlA?12|3i6)C$jT=*u?ILYxvh?$cR#tb5d`8z1L>JVLOvhFfU_s%XiyEMH)q|V88_` zEaDw-)O9UQu`9z#ip*Mc6>ISm6$YFkqXhoJSmc-rX?}-G*&%BCw)LP$C@}rzSP33) zr(0a%Ef4{B1UP>DSo5s0Z=2w{#ky!9s|8lv7+?u8i*~1l9s=Gj6^^Z^rS9VgpM59M z!xKR_c$b=N-JI#S@`x+0KPKL|!E^>$sZ}CJZQ5bgCRt&TAEW@Fe4}t%pQhM23A+6d zbo=e=rdn=n^><-2&GP@DFMoGWKMg-@`gTRpS6yMXiz`@Rwh4;6^V0?fx9$D!^jJ+9 zN>9!H{-fj(9KbD;KaA7hs6^PMzYgBRa*N$U;{9N1Gi!N%umM1s39vJ(ySxCq1%-Ws zPmoB|`u{XkB4ZL%tJa@+nMCh6itd|bWyDR#4Z41{Ap6U;2Gmrz3^as&fm&vO{LU-h>y;Gt1X@d4%`@D3qX$5koiq>%Ck8Z<;-!0tg_r2_ZKO!JcrBcd?R>besmroSxA_p@MR9 z5{40Zr7(EsaLcw<88j_Olj@WI?@CMdf!WlUopgxFL0q2*dTa>RJY{a{QkK4GTcJ`I zwA1_D26_}C$F2B-!T-T&@QVbJ=#cuz&_6|C7t%(Gv;EW1dt<8O==)Ut8Hg6nqL^ia zi@{hQ0X$e^xR-RTqbp1KDv@&0IcmJ?yDvUq7fJDGxwK=e`=VuO2HMk5Vk(49su%b{ zd)qwdhLzB=^=XECI7`C(&XNHr^)F7(+D8L83hty!oApH}7!5Ocp`uc0xqVIW0m(e| zTN%0BWhBBzN4t27SjL?Hz6-Ev$I8BeKA!AQHTkPsKF%@5rhzth=XlGcR&v}p!H#Y= z{|AXbv3%(Np0=!0xN7zwXsD52m?Q~nXk$~sJt+MbPR`K}%tL$LoPxegr?i1@ zfcNp9t*F|*LFbx;A(w@&7^0R5J6s=39M(w;Z%_HE@>T5bA(`2E(o8#irJ_+z`^?a1 z$ViVnia|(%ez4}lCAKv9)@cDnj;<MJ}AaMELIxYwh`9(bYwBnxJiU zxt`03N(?7Z(UIlI9@_vl#MthK&Y~fYYmSURfu4eVAiN$@0Mb%Y&2JsW)9kK58}F@* z>mFM1HL6}($Cm0P&&|D|fpnJpjH+RS&l{axu_ANNp^5Dp>Vp_D2gRs%OsW`l1^ZE| zy%}qgbcE!uxg==b@t_@3Zfc6fI})Vuvb9|!+Vtclpt(<4ovdpIe26n3NS*Od_%$eD zbi2X$ygwDGsfM??RIm}4z0d;#3tRnaa^8iDs1uH96R_8+Yr$|1FYPbOBwcUqPvvB; z{WYicYI>P0PQdISDWGTrgs@N_8%1F z&vbQCg5H@FlB@6d&}KVW|5)MYuF0WmJ&Q`HC9hOp%I<^G*a_1{OD9 zuXb<_Vqzco;m2O1k@o<~{KgySGk5jA9{`fXSGzJ5Z?~CpIF`?O3)0{-+n#SbRJK~Z z@z$k$LY#qlE8fIyrdj5oN#RLXc2qR}GU|8dB-^el@k9Y#M0NrkbA1-$b$Fv56WWIM z^UyEK4wudY23YUs8uKUxnJS5Fo4s-gF-c1KcExcR@7CXL1`1o6&f*Xxly~EDJE0C5 z``N%ll)bO_UdqzvV&W9_?A%E!tiL}f=V0dSs$!|6g6)&A-NKJV3kA_5vhif0+tDq& zMWtp=?>_3b!EkU1s{TGTHidWA(av zW!_g*h19k-vBAa0LsLpo`0wJh{@(rLM|T(RJ3_yekY%b@4(j5q+P|8=+1AYeBO+iG zQk49YQNig4XijyQkdTlUCb$NTc{S=pa75}B?;!O9l+{`CIX+i9TrzYv>I$4J+*{Hs zKCBLTHAwNFb`qSLO-o2qzrrROs2&mEk!rj{5eK9=IcLg zS2e!RZ9I{rL^8S`RlTN(C#}{CFU=&*s51M?wcH#@ zxR=w>Nm-T1R@7&bqM&`mo@lnK!8rK(cd|%1D4TIA3;L)7@S!2INlGreWmYDE2`<}H z$GikOSytWk@v>L^)?HYM1}J)X8gElJV|60j=oC_3v$eUmfEIREp(9K<@UF1*5y-K> zgOeBA`$?l7fjlmAZ~NQYxX*{B)|Ht3ZN)t4O!co`JtyXfGhf1E2sXM8;&zrAHhxVI znB$0BRFbqZ2eBO6|JFbH9OQiefVGP2n^yhW617<9Xp~Wl6uRg@2Bn zk(lWOmaC>BG}IFABNf;g6*ynAc)Sy48Zh|P}ae_N2pt<1!`kX1jdnKh5o4{&r5HbBoWV(e>%+PdQF?K$7x;vBM z*Zln19Pv99HDoUDWd0wv-YTH2t_d3r(Bdw|-QBIYyM^NJ4kfr#w75foV#VFvio3ge zae}*?yx;l%b9F9qu_ZfejXd+rtd%|R^^g<8Rt6u9stiI>4xwaq&n zqk(&TFN;-?Qo9zX#>FoDS!g&96E7H>t9Y_+PA%QiWFRX-l} zFZnof#@9uU>i>+&X|v;d8kJA*)x~k!%H#m8>sq7Bg=V zV(CQ6ZK1uC`Cq_D-j)APH0TqCco2emhd@RG^;?~m7J*zdeFAjRFe6O(EvawU;Xgs} z31p6{#9nWuZGgn0mNd~V{rD4`AW@{n)BKe2$B!T0Gm>T41-bDhEYP*|)u%QK#uz8_3KV*X~!GHb&%|mN%P?}8jW)hziNOR0d1&8kIWG0)=dzH|ITZx;Oh|4Kq z*7a!xt-f|(o?VE=qG`Nhy9Bij-2{4+vXv8vnP3eh zu5!l_RIbU9KLcQXtTs>Z1g|q-j758of;39LsgcL}yq zia$Mesy-&rKV-6gsbsa9SIQwnkj-+HlxH_twa$rIspRFPvSt7I%yUKWK&yO4X!I$M z0gG8WS0UqwixieG zteHTL5C+VT4EzZ%hJ9RQIP*+?+4^1MRtQYIYfb<$dQTW1X z!jy=QZ(K0KORy6l4kP+wm~DT5;}gYq`M@bXDU76RV+cw4AKK=UK>Qyz{kp$^-A`o- zIVL;8eAJ+vid^cjG~e%??%EjVfW>wnLCTA*6vF$=DcRUm8Pz+Lc!R|h|J2{80wx`$XwkP*{Zk z|6j0i!sjAE^nX)Z=A>3hudqO2v}J9Q(S7>@&(G5w6=eLSnYaH`VjsFy_j?%Rzs&!q zzwQ5kenfJ5w-F~q4h=v9z3@L-tbZEm#g%^P*P$1LAPsU1esJ>EVM{JHhB~zOotB?0 zIDk{)j|X6)PRD!*qQNgxIC0n82>*wsVKhKH;}qznA}R0~RM;;u*4SyOV}zB^wSr!T zv7lk)S~>;N3B1JBMxStxa@fH1LZ^)ApQejU!oD_{}uzdH2#HyiA9l+e_a#FlKYSh z*g6tNlCN!>PgVSY3RAQ^ZJRC^<{$m|rZ!Y4s2wD}#DzIqLp)Z~oZg<|7BBwOL|yuc zi8bR0!sAO5Xt4|TAwmD(2lkgQmOqugpJ%Mfo1iz75L5-~TgK~77t9FzRN&_W8!W^6 zZ$Gv2kd_;A{4$S{+k1`0bybwV!obF?*C7+I;;_4cNVQ6eB>q|y0U3FIy>smqx?!x( zmN$f}LDR`MCGn^#f!5pE2Ur;|o&D=S9&yHWMiY1DQb^WROov7R5v+W&P9D1iAoKx{_Efw^?LPu>&)W+Vj-tR);+c|YGWi>R2wP@Pgv@r|1xvtTcVn$2 zukpeNLdj|5C29IzBO=sceE)s%lN=2;*Ze-t2^^jRF)(FB1f!0~F_Zc=e8ludD5@cD z(e|(_$4*z)$YlQ?e6B)z+o6?ta$^3ujB_9c$u;C7$O^GJCGxkhfASV$Z$Ol_@@vh_ zYW!ydmgAqCq6h@wY}WjqVXqEUPln}h0Wb5o{Ln8Qz_SU&n0Hxu!||cA03`BMYx-x* z1<=krkI&A@Y@1{B!6eNw{n6mH`p|8Qd1XuTixH~C-apyiT^p#bIPCA*k)}AU_+H}h zsnnTgnnIjarwbkSK9;icrs#b?sL1#0AxfNJSw6x!crS(wv6k0Nx*&_LasrnPT2wYw z8UI{Bu=wu(BcAIF(`lRXJv=>Th2C=&NMPgx)s_hCQ@-lsYRl0W` zEVv0At1JaYNPwBu`&8$!m;wM;g-CJ9pIE76N`li6hfI zHG20|6#UOr?3z}B3LH?Qvc-RrYLJ)0>8V=ZQi|8Hoo_)u4JQM-2Noe-!}ecZXRj=4 zOUbn&hV`d2*%fehQ-gq&aU|?kFf|p5lmAD1tutB>Vvq|eWW7#Ga-E0CL~LYXWnc9p z69pguimfNs!@lsD15?c4;>4N(gH;jfGClhqtkZLPB42^2!g}&>YdsnG*O_%885c246 z8VFvYfag`15b4k#6<*1!4b+7{CU?`3r?}o|(4gllJm1y#V>t8uhQ-l|)~SOlmzJoH z_FKvI<7PVLx;qnjmvm#s84R9^{A4$%3)FzW&>N^oCp-me&V^_qmbUMAXkPKehQDa` zmcP@RK1^8Igf#B8T8X#konDs}Irrugy64~%U*$APFqHCW2w1Y7J8`ic>B7X503(4w zb5476D2_E*<|<3m)|L~w6ifyxO=S?T*1Usxm=9ySLRiw~);DbmV1XgFGd&s&`7NKT zv(jJsqyQ!4XZKg?ag~@6vwPwGlcYnx{RpQK3SvZd4KjS#%KSQK61Qz%q4MN70z(F z+ws5z$dfM~S&KOOZ78FhriR+yv@Z|g6TcuFp*$wKfol58l-$DR(x@x`p+8s#I@Z<1ySyGU@NR?WgxeE+eRc$xSc_>CRxtZ*1lzf+%+x`_0x_5DziVwRa#)4 zICCBRE2(Cj)4xcdIDsrLWSH06`uaMxdI+J3c?NH zsSPRB+UT$-A-+|F=(3HV1$=cD(=^{OY(f>F{d&Ug;*Dq|!fq0^)*f{+i$c}F)?J;{ zHD{1c_X+A40`|!{sj7cofJVKJA`CyOSbD>;^n|F}65=<|RsX^yf)$|*ms*|A9s+%d zX8}WThl=q4uJdQ{w8TvMD}9y7z4@3MqIQ4SB!9h4Z4iFHZ?G6_7BRk8uXn;1R!n;T zvQDc&QN@H|$8~pB3-#5XNzFn$7BP5P$kz7*haoxEiaU{SN>zOQYRBF>`bk6}LpinU z9@n|(*ieJ)jrXzNrDP@5=f2;LgQJ3??a}J(Lllna6MPI*i2!PN%s( zFrN0dflk_D10cs=NpaJ^V0+`%E`Ee2F%CvHvbA_ooMoZkpLmp;iv?wWhr#zCz8RSN zWfn)Sp6HYlfFo9u9EN|8YA68IHo+b>Ftkm1Z~l>I^Fn7ZnqGz@RC1(|I$QV~gZdTr zM$D8aJ)+YyMZGqS&-nyeZsZv5M1#KnRx*p#5bqqzgY^aef5ipYCnEBq19##dvNKdE zk!@px=WU_Sz z3^U7Wu@3J1%dS>~isDXkG@UBUfAxNL;>lj0T*<_pv!q6%KMZ`@aLHfNJ@?fkdj00p z+#TW}#`PH;_J^K=)o6o9o~s_Ny^ss?6CjG68-&xi5d)b4uuY#sXI|? zH;cdae9rFqZ5&*b0++R-IC|Ad1%3-H$RcDn;!EM7mHI0+FH8Bmn#p_i*7^uxrY ziA1jOoi?Ew&?F9Drg>dZVTl|x#CQB%xs~g@ zx+B~^j{2!}Hg%Eu36zqO{QW1c3R3zQzhbJf%zvmM zKaG^muvj>#KO)o1z`*PIoC%=z=It3HlcZde#{MyvKA|FAmhLU0h6t0{;lz#JYo&g3 zf}bKD+Ac!R*ds7?5pk6Rm>HsV&7d$Bw%O9U8Q|oBJ(jtJRm@sr2gYz5q%Kn4BebgD zNZK28n{XrD(79Ril*>24ndWdC+rO$27=OKXDvLsRNgJee4;USZNfsfw#iix zqLg0fJN$v3LlyD9rZ~Sv17xZkc7pH6(%by5ZGJF~n6>2LPtq0x|JfUAo>4ukR4IIR zzCJdEQXO%u>IN_{V#2C;y z8BQA4tye4gHBumXID)@cefEJtpp|0?^%(9e=_OFKWG~+a!?f0Yye|P3=-0|FBU9 zifDbUXi{A~-G?L-Oeb0p6t$PW{Ump5GgF1hU4Iw{TCMcljXzm^ zmMbzZ*e~eGMTsD8eO9p>($wyS=k%PfdPoSgg4zLYL4ng4cC|M)SM%sc{r1GiHDW(! z4Y+O~f7c;{`ATX_q=Wr}3du`Gy_@-|bA6%gm1TZD2su=Lw$!-Q4{Bjsf6^dBXTxLk z+~YXmj@CHE{d<8!jaH!C%PNA2D~yzmhxYvod6*YVSM7lzuiuo4Bxi7bG1)ID>x1=; zqvbLv)cdlUeE^z65sTOHXriaeF0VvFpIxYNX#Uolyg?TvpYHmg{I9a) z{`AY0qcJqW6O<|fxp>x%>e!+w6cB&38VLYs_A0YDSz>2yhMDIT`l4KC@va zhLZhtxtj1{_~xymHKIe#?j&zcCfb>iV!c>WbYk}ch*FU?-E(T6vt`$pN_$rydVKSL z0Ey+P<%eM3(EZwRb455Tm_CK%t=-dMJhoWBR3~yrVnpv|m4mF_97^;xJ1aFDpI$=J zbxwh@O}BV&8wCQ7?-+CyJnNcb47j@@;=Ry+i~I&?Iu`Doi55_1U>Jp|6L&Y9iOZDFMtY^)q8wde31{u*ASFoay zIvMuJa=ZXchh3~3Vjd>)H>b?Ni2=Jw6CfL4B5>?yTCG!B9EVvX5Z)X_!5g$iVY4I+ z;QWb@t>EaJwsn3WfW^j0BDZycD@pq@#Nj?G?rV&VwRjKDOmkGa@x z=2zWsgOzjs#xma>m;4I9{P@kTue@?-BaKp&&dV(IE9nQmV*iX0U>x4z=xsX4#OLOm zhu3Mj^3y8{DuLT(d5|8Gm0frt9a{8U6wCMB)A_Xb#A1kOt;5_X+jvu}(3Q7-;4wxw zwM6^CwbK|>5U2iGm*aIYJEo;_)Pc&S#7o%vg8bwyj))lJFDWYyHB*tti(;!aGG&?k zlDuMW`Boj^@;=KQ$O$V{xbR>73ZmjyYZ5x*^TrTnQFSz}p7J*bXBS|^XKFiImJ{C= zhV&3~^cvr>Q`A1X(V+a#CHgf1cJ#}i_#QQ!hU^>$DG{wcnH7FYl-=BTCRQ8rhxxh0 zRcR+z-BZ(gFVZ6#Xjwh8U2ogM<=Wsb8+rYgpC3#_i>nV{I~7O0aIbN0X*3(HcA?Z4 z#sLaP^@IUce1ja`nif&z)aqPaMr5d7uPH?_zjg}tIsR)cGRR(YgA1*)p`zLX{1K}h zWvIyZ%&Tt!)$^&-H@EW9V!gDYrmqbeNert36DFT ztzbg{fmK_e==Jh>a!MlHB2eNbQpvOMPCG?g^@0q&W7eDLoKf)8wl&Y`7$eikspEOe zwVg|4KoJyFFImZrBM#&~Y{t@8YyOVsG$=M}Ak$;%W|!__EWHSe;F+Ml>=ToCpW7`V z`JX$3Je*h6U4Gc7I{2JQsXTDcQ3%w<{V~^ zYoM)q2i=CeTc|3wUD>;hD2Y$-0MWp-o3>A*nCeiq`XwLvqO3mo z#H0;56_LCQE1-tj!%ozsTF%uVY+(|lw?Q3jQ^7+^!3a4rph_Y;>|c60V=5xZWFt>Q!`Qs!xx=B?iD+v z_tFtrQcWUE_CYO8W^jRKbZ9V#myi zk~3K5nUyFK*T4%L)B()%L_9845UV(Pjz2skULySMmxd|TWSC;!O@>pw1LCAFTay7Y zu;<1;KU_C}WLw>P%>5Cp&6yku23<}(wUT#xo1!tmR)r*nIecXJy0XTMc-ZMn0ED#Q`K@7!1GBY0<=yZ*EOy z3ym$$P?qY|6KrZQvgcNdTsb>0qLwdPZ!Qx;XiDHS%@ERFC9#U~YG>v&!jav)v9A>& z*Mg|b2(65sn5gXoJbiz?!&XeS^+7zS0Dqu7P_L6?0Uuc0-q=U4X4(BgJ!j;X<5Fyf z=m?V4DFcNp6)d%^yFan zP67g&dZU~a1J7mu$BtqD0PdDgqFd%t9eCT&E?Pa#aVCjBJ#pe>xiT^1VF}+}*_j8Iq$dIzGpA!lB|G@qjJ2`~f_T z8A1I6_KVOi?S@r}NSt3JA4I1$=D0F=N)o_iU>V4ZR0DD@DjrSv#pg=Yn^~5s4Ekn9 zB5*{Qieo)AHhnAm8}=Enn|OG*%Jhf<5%6XPiQj*9LPSHGBqs ze9k9h`7K~%ye8-*d+*|w|Xsv>| z*pSx|`0ZoKk$%Ke!#SoEnuie6)_CH4x2o}*D64{4x9DBKEoigPZ~1+R$1eQOzM7i9 z$3pg{xHWXhQFH6R3)77OO@-JT*kn%6Z@qbaWOm{C+6h&s9k2IR|9#e@qgZRsv-!MW zbI9ZB9hS18*DQ-d{(5aEk$oZ?)WfxzjZdYC%s#zuP@^a5@tyhfvik z<=yUIy#rAg19qE_UgJ=`dG3+HiUCn~p|1W0IK;6YWEJuF02Od^N zBVSYkcmW||f?7ucu}W8y0h zI{|1{JwD(XXTK}I5_z*}F54wp2Mm@2ti>1_J0J&st{XEXAPD%fH^nJC-K3qm8PJG2*4!E4-CI)UV>260v zYYzs{A9v^78x7P&?S!6A1_M|p7Kq#bQB#svBn$2&5ZWx2A&^{1sTcjt%RI=c2~kUv zPIaEJc_KOyz=n50#hOTWKQ)2vfH`Ql4Hd=KIPi^E!dI^B<1+U#98`=x6AO`h4-E*yTgZ`JTIKSK8 zKC}^{r?#wbjsH`Z-TPXBsVNe_T*Px zBoD?CpG3ycUmY>J-LOd`l14fHUb^5u)~%tuKM5N?A1npK75Qu;r$L(@{@v|92b&w$ zdN>3F7abrF9V%HnV40T{zw#rwx6tPE2UD%(xymm^%jYLI=1T`FIry`T!p8mt{`dR3 zxAJc7hAyJ%Z}4hN(m#Jj>P!3`-r;go4L?a?guIAWW!2(!W-Gk^JIH?hBQn$-JtGbmh$Zw42X+yw*sMrr|NrECG|9BbXr+03HTq9Yl4R zz4rMxlo+HMI-FjQ_sj4-gMT28HQH(afSiE-ZVcXv#$QhgOCi3pakAmN9ufG+Pc0r) zP_Y#HDYLDhUV2c<&U=(E(4;q}KE^Q^!;YayERtNs++m|L6g(t-`){Z)eTih`d<)dP zz?eGyCsv?Zh!-Q!$x5X-wOE>2CFWlv(~yq8@LKP#HDif^@OiQwi@yg6CTU<34uPIP zB2lGbTjH1OJI}iaT)^Y~Ty=>JlZuj=S4OK<1x_u6Iey#&Qm)d#plikE?-7Z?dVYpO zUG66Xu;Uf)-Xq1O*)z|nm|1hiBiBj4i&;^f)!~Kxl3q6h0l1C3)UbUY2k4mQPZVLg zawe{jARPF;^fb^R{f|$dC+7u?liw>Wa~p47bTd+nz@pXJJiz^8^7VJaTeX9tw?FjV z>f)bo+%hN4CG~|6&i`muqpV+bj^C2WL@k_Hxl`?=Y9WBajzfyNzCn{iGj( zRm%7T4b^=mw9?-%FS8EUYf=<0%k)y^rc?mi^(%98&4)Te*NNK`XhwaD3T%$6M@%1^ zhg+VodaOM%oWjFwP8zO!ZnlessUGJZ)h5IA9u8C`5X0~IYGNozau8JzdzmcP4;J7?biSJ zW4^z|+UNj=kH8u{HG+z9 zM8e6mBmv34j_8Pph-W4&DKtYlT~!0J{Xw=(O2?)U&J$PJJ=f}I*Y$CsH*U#!b>;ke zfBNP5;mYi#Nlz-__wOv5jUp6{GTlfMt!gzGBar~#w^!%Z@_TVh_w#kJv43c_=2zk! zopv1F<9ubB<*9!!vQ#AR zn@gh}jaH~^?W``0HGt<~e0<_x?Gb`*CXxH}e(O;=b5 zFlf|9O9ESMQQGcLB$f>Qj+Pf_r*a(BN|G}4Wd#-^qY!)i$^qGsWvITofe*38N=2KD zMhCgV6AeI#em=v7_d|gmINSBijWLrnfHPJuc<8FPg?0tImBdE|Zrn1fkul%10!(L^ z^J-c&&Wd@Xe{Up7`cN2_zaN1=OXKbT%$JeTI-4k~gObN^PU zj}l0*&~dy`kJjmPFV{^Q5fx{4^eCq2`A^M%>z)whl)HCwG4`Om*Ltq}bHb@pmWx7r zO5T58RS@ddtk$LU^AT#-jvY~xV3rGou(8C%B#yUCt?C$nu`yUcZC)x*2lMlPcqD!xCJ3ZTKiBc@IdYhMNdH zIZ2;R`*E}4L(1>p;_Dv(7QAnU6%aU!OvBJ;W zLSI-B3+;70QmeHGYLl!UT@JU1_wVKDFjOJ+>ZV$6rOAbNs`C=lpk2r6)msZ!20689r?Bf?xa|>H{R}BkeTe#Rx&=w? zWa_-lZH2tHKn3^wC1h2md8*3nI9;l`J}Xe;vR;qQaVN1l++p;^<{}MjLm~MiUD>AJ z35qz^3m8G)x-bX;U7Xnv@H)sHl<|wWPX2yJW$X>2HyF%v!mT!0`++3sv@({uQH*yz z>?s|S-SM<$*RzT6&e~#~`_z=; z^Hx#=IKiv&urf1%`eIPY`<5|%0%@{0pyr#!D1d=csYv*y$Z8&(2 zIqkRqIePhUA<__+KNP3htqf!9v$gWa`X*Hg(!?pat4nIh>Z4O-i-wB{?}6i@LKk~v z`!!!4tha`2|Vh5mS~>uJZXB2rf_#&+{;^%X7rU2rs_Te+V7)k<(f=!wP? z)o3Co<=7t+6YGe*1c`XnbvK9kh5t!~$?D!P*P#6Vmm-aDrqEmLfy<4~;XymhFF}8X z7(&7R`|c);j@y^p_zkrqKdAZw-yB5y>UvHj~%~l&dO;Y+XCujG+?stFm z8Nw#jp0T7#J6xC7YB!<}JK_(=xZZYM3(O>r%^{X|_C(?UaG_5z577!nUt~P~*>Xel zo1WIqC@0p@3iZv5Ca=R&5+UziWW%QT*E|y@r{kG&%KEo+ix+I_!V2i^-QQkGv46gA z|Md?7lAfe7z5K}T^qqadOl8!HO*fk8nVU=M^m$-=IIU*GLw>EH2!bG6fm6Q03+gA< zhTl|Megg3~hxH`EzPkk@<3{N6=c}nmCZ#~fSFpy|^zGXF_;Lr62rBW{;hlhwLI{8% zld^O8wFL)DJa;Wt#I_Xs5v@Cw;%zkQdDz?A&lWFT-v4Xlg{}lrLNoa}XAv{=y3kNA zRA5Td=&tp90hBWUXiI-y>`_S@d{xfBdJK84J$!5QR)W-c&IP)P``{jGw=?m-m8(Um zbfXS5ChI+SXr6!NwCq*?E##NDAY$3UMnz4-K}+@f8izT8Rc}pCV)P#Wk_})-sb#6{ zbWSkF_3_>Zx@tb#Tbs`;;?LW0y|_pt=?*W(CH1{>@P-=pdC~(hvO=iW6-Z&DIQNli z5g^2!JzDjk;*b^WbC1PwDu4_NvsGy*dk*V1ci4C~osn*`1%3IHN$PU79_nMr${7AY z3Y}~45bHq_&Ru>puE%QOMO0;kql6z<$7N$sLD0G{XMsC*MO9E?xgpD8*4kZL{mr${ z4lH7Q%&}6b9w$&Wwz*)3`8X?p9~-*8f`ss7i*a|#C7Yu)+KBD+0Tu%E=5u7%*RMb1 z`HjE2!*birQ+VEsuR97Hy4$X$+r)_slycJQv{5)6t_PZ!0pO90GE5A0%R|NdV6A@P zVqnF}=%DHHIwcjq9W1>|ygZ&Uj@=y(ZEHNbx4ZdWU*DQu9Ny5SdHb!_e~?-R@M1wC z!ikg`SBX#gUx)BTVjT`Ae!vgX&dF9J{H;J}MK4zZq(i1WWu>vFzRG06Y7tU+?Y5eI z?)wgZ57^)zxoB3DA=`HkMmv`4N{bbG6N~5FoVgEVZ62c^vanKb8ygd~@+VH3*v&VQ zCD6u~B|vkZb&pHA+V~>}LxOL597IZ*GhD5fcl&S} z788R3nG{^LNN`L{!rn&~&Hd{D(?@L3=0z_*X|44IEmw)&O_NJ861ejo8^B1(Ye_KF zFeFpAzji!dX<;+6r#Tw>H;qYdsB(>S(UaeGpU-H*CV#RDfCD zaK~V>WuH+iC@7e&PeZHJweq9T!^MhOu`lx2^7mXC|HW;;)CE(i+q^RvbNbpuy+^pp zB@vD?by>JrX?2|A&%)Cr%79HOY~H#u&ZG>^NF{waRxjolAB%cMVaDy-{(STFuqE-( zP#I)tm+3BOPK83+8iwG;SJm)NLlHAL-x5Fsp5z z`gM6ay#lKLek3~huB)Eyhf~bdc6y6%j&Ew|N5flo`bU!@WLP8Z=fvrfvAkTKi-$-? z)3D&FE_#51k^Tu1JbZ#0rX0`G2_iNt8HS=YRbaf9C&g-D~g)%oMDG)3g{$QdM(n^boP-TWtNB~$^@NV2?g~mEE zGG+!OXfwR49xe0BARhXKf`xh3>hE{Wu+Tb-8(m)kuH1GN5t5vH@glUt@O!=3tNafV z^ZP@v#jgNvH|_b4SPS34QgxA2CM!4a>b%o@vfsyXvQNzm*S7@SW30WruH*)WD7=sHf=MYIf(rN=n>)3>T+#C+L$*{+(_q zPJ@IGv(zL3mqYRa2TBIF>C)mk(W(!id!FUXlL0HEAIZg39h!jK-L;(Tg?2P zA8+@b=vn_g))4v#r$W+U|2G$;fVcXn{2}JUf&FCjs;w4f`la7FwX-CGXe7da;f%}$ z4-LCaz0|$-JvS#?9ShfwPSb2|}ixV^2k`oLLD zW-Ehx6Nt5zfP(xx#{xxftv6_pPB~c^!7{sQiRb(ru8t;Vnx%^m*W5K z1+YZ35W@7WAG~|?TQfRa`CyfJIlOpQZ~Jy{Bw@p=1zA{3&2+wMT{RSQZRnZ zJBZ6VTkXuD98!i(UHuI4p@L+)N$zX;cAedtSH~nU-L&Vn(HN&*xuwn)tGVSPS%7%2 zX07|2)4bDKoPU>r*VV|?3e8XV*UmVIE6r?eAsSEs5yEeARUhK@(ZykG7It#thy@Tu zAq$Ij1{aPt>=LJ#UxSbu%2o9)DPZZmE`l#1yGP{4g;Sw%Z=lh^3Eb1(| z31_~u1BARc_1L}gXNJ)jDdCN-C`khzh07^5YrmS}7__66a$$bbGGVxUJ zyM`30-AZH;Moa%=j-dK#%3m++m*%F%sDs;>=$_?$Ij``8%Wj3mZ{-8%M5Gz=*{WSB zAuif7=LcFnILDwO3QypgG;p0CU|)_zuK!-g^U4ns03jHo}4&Uc{A zmos8bsY-K*akP1y^boRkNUgO`$~j*-3*L2?GU<~9%@`NR9A+b*3H@B%bi?Dc_?CU2 zl^PE}vGY4Khu0JeEzU&e5!kEAqNKtiS!onpb&7nFC6@+q6rwZYqtvLPrwfbq^!NKi z55<@5s0T<}c8~1LSLhhR{VHFj)h#1_+@)eOTBe7eI2{b;t%{-7sV^fPRA$#ctX`~J zSw8q41oIBVtfkCMB!t}LM(hP6|6%y7u*?rS;U(3m`N#<=+{J{w)KiUvNFAfjGAm7vov{59=;n zg&QSDc!SD%{XJNR({p3uQG<6!vsL^S3OL1E!^bvrl%YFg;8yQM?aZ4GYH#n8LV8Ur zVx1OSLLPVJ6F_Rr346ex_Q{g6tvR3BVYJx&8JAN*JGeQdy5QU3ZM3@?4f$pX`Xh^% z2SMN;sRb5cu=>y9%L{`*-FvhRRuAFtM|5G*(zhp@p(9B&HI5Suy4+4d!Ec{e8^NMW z7ps2Lg(2S<3SE{Q*E?PUDTgW{yBA1U(z0yqW+>!rl`uBQQ{-*tYT*v61x-$W%zdlY z$jQ`VooQCbx>)}%7tg3Ix#6_rC=hKF&#)4Z`Jh{<6V8+6O_3nmax1kSs(?Fb-AM&4 zOo$T%4FgPhOBlMQUd`4^FpBFPn15q0Y=IZc0RnLt?w^7wJMVzv3@NikK5+J=0`(K# ztP7_LyJ2%0&s^uDS&VY|;znQh{u)gXn#3z2GA4{iK}M|?P9oTGHd^Y(GhePjT3nuH zy;;bv<=93wEF~>9Th;s-E*5NdvV7bDLfXFVaT(8S3S>ENFZAk`LMqQ`g=;1uzsQO| z>&9wVDQrFe5~B+wGlF8#ucK!8im2Lt4dc3q?zB_!hM&Q0`y;PsN-s>KCHL77W!ls? za;hakU+{CFN6sq!z1+RbHS^Nsa|h0nqfwzM)Lr{+kVfiOK@>|>mh3e)eL=P=$~48h z%Ij>m!;33W=@k(z{`rgsv&Wk0~ zREoOLj}PHTvy4;uLZG*6-QT-a8+%lT0{`K+tth6tnd@k6XJeb)b}x=un7md{k{*yeah1> z7`l?lO0T2qSKTHe_hcAK>cRv)vISR~rI#=sQ)FeF9(j`6VMxCp9n1Q{VQuDpY7;C; zk>>@6q^vJ6wjI%@Hhgfl^^0$NE)%!<()re zrQcfgU5ZqH^v+jgEOQ=qYq5TR0KFXBAdjVJ4hef5J(FpyJpTHkvQA3NxOZ|zm2md3 zf9jI8X@`qLJh0E7#Ky_J7q;OLiOn#&N!i}&Py5tjMsHwcU?7mNPj4hmjC<6xH@~Mo z0QcSZ>>{gT91AT+rA~x*Z{VSSa53VvnUtfz3HQrXc6?36j*Mzu%c}M9K_*Y!BWr>z zm8<m*>-dK%ebBDZkO!Ye-QMxp zf%$fsz)~tfL8cj=di_=aJQ|t`MUMVx+NR$;gt;18%^Mqy+1QPZrZF4ajcuDZwr$(|&hw7( z{Y%ElIQM4ny)@_CYgt{?6wur7mez_l>D2WvUGe#oOcvz1=pjHLrIjok=(ZUA$x;~p ztm_diR;-G4HRt_zM)qy`?e4xV9XJ4?#VI^Q@*oXRY)vP9Drtgrbz?*ShsiWVv%)$5 z_Imva4i2vJ%Z0~=4kLH$NwDs7Y5UA}l3}0YRoQ3d;&@6MU!M}y7-AU*?;QOH$SyNi zFF);vIU;)YcUio&9V7E)GccV1zvBbpT5nq8vY_MG=y?$&ycq$w zN!`+cjqI}tvR-^_%ee~C3xPM_tUFyy#{?q_NH9f4L>SCR6rV0vN7DMLnH&oC#*FL% zPnnf;0tcgsrYobLdyjl;4`Kv;m*)q(>_|y_fR=8;#j2!9BPt2ep`kfi3GneWS1{Ow zglG*()H&mv$Gc83A+UEy=yuNME2X?U;?cW1g`U-?Sm!lg;z`E(u=6Sq6IIgY!{Ri-7Hf`cPy-+5TE zQ(3seD@LW=+`c~WttFf+Mt>E?g1eD4B~DPf_;Z|Y=b?yk({dNe2V$9GJ({=pSJ1a4 z)-vx-&=u+`aSKbqiy{Q2rhIQ(*8xWQQ8~u}G)KRTY16 z)Ia^BQX!qB@#Pz{v*TTG5_XJ!xa}Wa*6j_$fB;G#O3e&@NOBJH%OlcmUc-jJ=4m~G(^4HRb2*zTK z3bZ*|^@ARYl?hE!w*2fmLD92gXS8X7f?zz~dE?u^)w@#P{B{;*iP{SbX%R2+5?-CL<&PjMl)*wUI53envG~mm|}a zvieVcWF7Cs`CKs55Pcu*nz@?!A|R3+ndqme#2rl&ZkS*F+?RI=tDxvhe-8Ot3kY>km8U)t(MC+@z!>A zl~ptzza#^TczF=}hRefNEFP-7N3GFBU8&!`*=g2+gM&9!_rucxr*I94 zPGQ|^ws<2sO+5pMo3l@)%QTxj-+#S4n+&U$q!u`S zk9{&wJNa_MrjTXSLKkPOraQ?lzFUVm!O#8MMV6~Nc|p>h2b zIEXFC=VM)0y|Hy^>mK>TN!=fN_ytHQT`j%ZGlxf0fT)!TloE8}lX=`BZWHhUqn$YZ$l|HRb z`Vu6G@SRS5=i?qJsghS^BbP%A9+M(TlYy^oO^F(#&eF1bNci~5Vyldz&Ep1bCH#O! zzmjcFI!OYHN`{FI@t8lr{zLY$VCOM4e%x8Ba_te<=dnpdM0)%BZZ`!8C%^^V@BWkO5N3#q9d=phr+u+}7fk<`8^ z(K+H~nSI&ay|l)3*Cvr8t#TUu0;rm%CMeO~za6^COr$p5KUwiUdpaAR&41Q$YL+^W zy(`u%M-%X2g!TIPZTQw|f4A@LRS!E*h6F@yLM!^CMLPn{bG}@mzyVci?byZ(|3?%w z=~lH!xAzTvzD`New{PU}RUwk&%lf7t%=okkjB-b4zME$cpEwotM{jn2c8^YMlzb!Z zZSpzz7H;ajaWEE5-s{N#u{)j-T-$J8rrQ|<7&vKkI2rXNp z%how{Y{g}=%pF0%3s#&mZmL1oGb*FbbY}hxyf5&!WW8L2aIQlZ~EoF5*g({dP+ zapr!1w$%Svo%hr#tScfas9fqos8wez1{m+*N~Ny|{`2$mA}`O(a4tJ-0=z!4o#lK< zpmK5F{5vVkSHgUiq>4VUG1sEL;skXx4!GPin)P_zK0uY(Jq_KChval?bx&d261IIF7YtbZqS4@HGl&K>~i9*1;rnll))RtpUn8 z+3qmm#IDmIClTA)RUMwQo@!mHqvZ}>mK3^gc|AqlMsA1O;=dU)rTW9L29Itr=~5dg zJsVD&+?$nw2uDzszS!Vl)+6hv#l+09H7kJs)tWm(td;`=690AbE+-oyhr@477p=1z zi-UN3ch~H-PivD}npL^(qc>Zj7yQsI^~dbtbQRy*+xzEFqr=4l$5@I?Y=-4rMQW$S z0VahCzQwGzzJfh%i@DiP&3sW^!0LXqLbuLe?4266LTREKq01BV4URi|nlJssBo>*y zh&!X_ad__1bSYbVTic*g;aRX~b{Q;-{;|`LDa0l+@xSOoJcz`b5KdG!$iEYpy6;ngf*NXpZ?75T9MwAzBUMM zw>ny{#$?eXXoyk=GRp|uW&B2bGc=Ris&+T%K#}4ZjCc!*epR%zuRM$XlLbDJ4)L#| zuQ+ddP|(m*9nLZla0greWP>!Jfq{_43)5-JeljzrC zQ33>$;G!usQ@x!#!hq^qhwUHIW!8p4rna@Vrd?;zg%U4p4nYC1A)}{Xg7Di}!I4%L zqdml7tj^{{x0=7EEE`R z7V@gq2i|TvJ`}n6YT^2Tg$jlZ2nyWZI+M@TaW{We-!9s(;ljmcRYy}D0@t1FM-{FA>3@6Vbd+YZ-uHR#~ntP|=`QN(_qteC~LZTvaH9e=A zNMgB{yxpjPw|(|R64%!kR7$yFry|WGwV*0s9(4*?kOzS{xpdp*j8)kP=~yJ-huwhJ zf2G#%_ylpHRynQVbJ-;@zMNhl5*Ws2vwUh+LJ5r2BhCGsKFPpV8pc1@mmX^+uzh1- zn}z(4PZ{VB|5~Qs_(cN7Gqv7ac)e{O>PyGYbG=oQfV*t7!ydPV_ZLwzGTqYe|9$fH zAI&BcXoSYH$$C6eQj1)Zg#wVc*!AKYs&sy=?#Y94lYw&O=-Bs$YWIE)B>E6zr#>qx z(p>z8^31`8xfI6M4aSpZlBAIoCR;=RB6BX6MhnAa98WlDOkwwAc-E|SxVZ938eSx@0FK8^0J0^SH-_C^g;BQeRz1!L0p0G_l&v`deI;i zjG5<8lWOq!$x?V>dNr?{IqlBkM9;u{D%g=S?4c-R2zzJ`Ui{Sxod3OXT2%oB%9KH8 zi`sF=+AUw8w-PuxYq4ioD z96Gv+=~!mzoB+s&k#o!?^XIZR9p3G$OH~d6?8hA0zoxdq!}KYlVUTRAk!o|8W1k>OEH5A9%>h$)@l+zbnj0fXK;MO_xPnh^lgZ z&MYlTIPI1w6U$fn$~?IA;iR4O&_J~rd|bFt`xfYfjY)lT7f@6r|l zzM4kOuOGt|iM@kzgDW$Ua-~>``Id62b=D7}&gfvT|JHw}^((9OHFXQ(kZq5?|^@C*-|d|zz<7faBLuZ_&lrSukA7`l|lKz zv6%$1Qf$RMOZw#oltIdvjkFAyvPJi1}t2t8=~_^8)ELA&@zIm?+A`?Iab+wiNyK;Y#i z)BZ%7%+obyl};0v)RTSb44m)9T6-LQ8i&J0%y*>YxdO8bfe3s-<`^%Hc-bAFvT}JA zH-k>&J1)0J96}!FFG7@YhM6M)SLl@0zrZz6DUfd-jxYO2URj;ei!LAQx~c58$=0p- z95!3e_1{O+1p*7kQsdaI=8a9ne27VVdNtY~ed8IlV`v-`DQPYQo#@V^i4DBH1X~-+ zel8{#(5}?#Q~jBj%Y~#kaC!J0%U7*3 z?$Yz>_-wU#t7n`0V>;Vj=C)se@j5i5>X+J>p2!irNlFX(QN+PLW?Iwnucx$!8`o0G zh~&iUvi`?r6&yiVhZ38mOs?Jl0Wy^Y?KT^V#>!RWt6g_C>nWVKvghJ?R8+8eKwvO4 zfZEZJfPN+)nEP2Nq~Qiti&dWYvssxg zjaTZ#13}%!XuU=*`2gI%pWukY@BGciGqPuYQaqKjB+27*@vp6Ec8{+a5z6^LuTJ60 z7OR3yGlG6SZ+_8Yn`aO@imJb{Z}y3n`p^a-!#gl-RDbdH2rjl7z5YNja`)+CdlH>y z&7mwZBcp=D-Z)ax0^X;#E8pHbtE>emU)Zmd4Dy-gY0-`9VP|-FxL2Lci4lbC{uwKc z%%-c4#M$kVrbZ*n6kdpMrgMp(!S|jcdudIcC$yzWqXl}srfk;JXht*XK%aZhKPwB2 zGyhC@ht;LIY0Ha?Q|3J|sWk$oK1%q1<>rwxvkk|9D0yhnL`BIScjaOjeRDv2UxLJC zQ>*<_(1Y#GCrrOY&-!elJ=HJOIPnwz5!Gn-jT`X!SUsR|tyq2Cf5Tu%^jh8|4*VVS zEpA`IXkYNd!F)vTivq#>j57-AlUicAw(KuSCQnX6S%+v^4EP`gyf6~j|B~*$W5wL- z*ZrrXrDo1x1tO@;$PX*Zap}}h(oDm*V3lI8J@OTpJEETKbkZhf!VAqzJgPbVH1^~Ze9hKrRFvDe`l=k{*qp}587!hS$rUslcAdz@ey zR9fI`2xQmgEl7oO6ESqD?7uB`g3WJt-_XcJKVyyK4NhJ;F1Ec;$BA3qSD`(PGMeaw zL`Iq{R1nU7ltPYa*Uc6)$Y9Y+){HG5lh3PB#aY`~#c(7obi94}kAdT`(_+NK2K@#9 zqw`$?qz-eu$dZZV5rtUXBXzsfPrlx{O@26_Pb1Uyx%UZlu< zrPUVmndfT5QBZz)t#7eUb;qaW7OyWV^7~?|cZRtR3ZxL$_npgzw^>WY;7)CeDBW7G z2cEskL7gLi#p{K&U=njDPzq(AW^f`8V8UX;IoG8q{?Im z=J^NBH7dVgjp6;H_3atR1Usr-?%R;em8y1GZfJ#ls61Sp`cut~LB_WHyvWgoo6E)89=MD$Ok(|%N=DrjWkqRMsV zC;aY6+RwB<9^VXOCX4) zuTLx+S+lkO9I(V%NEHt@PE8X1Cmt-H0T&)i@lpv;t`Kc*ZyaLHl{@raq5qMvNw$xj z?!9@z$kX|SLzTUEBT*m;vFQ$(<7$CpPU``~w2|Zk#^kE8!I4N9S385L3M&!6etS{R z$;}B;>V#p8mAJuvd#4YMsPm1)zd74AJMO?w1HGPEdC&_L8?d!H?#aB)PPxyFna`W2 zzoK~V@TsgcAe^)8)~aD3-z)7sy#(iWFrMz3eWn5rhp3nDj?1imKvW$rN8L#@pVw||DL+}OU~1S8Mtfp%XU!Tm8qMUoya=NbR#==dT4(oLnz+#c(V@km)f z5r!<2$+3(lEg%8i{sJoU!=-!!K?tZjXYvbm7|hP*=U$&&#m@O#WCl4hnlEm-e)N7S zx|ljA^tjwVvm)X{{VyaV4~}q#MuZi(ChhKY!TEP3;6Ip88=~+B%kPh^2#F&d^~=5V z!UYAo0qD?}_W`<7m3hw}Dx%jlTL`SRV8F!kZz|~A^;ZF7)6B(~sitS-WH|+dsd5e~U9^Fc?0BTy5Ex z1snjR^L~g<^Ud`|nOO08sY#kVfce2ntjwrZXt9p9guQBUr8kHI-#%CLyKdh$4UDyUFEUmalc8fb(2$8 zm|&q8**@Cop3_#xVRa&q!(%h)I_!vNRQV<)9=fRE3@;<6NvPcWkb=Ahij1EOqFDQUvPq6`6?@RZlGH$3T> zEe^2gY^z|bSGy^XhI8&j>(&Cw0fLS8RL$`Dtxd1bKz$oet(GjHyTWX@Y<_zyz}V@w zecsz$lQi6LNj^>M+>!_3#ZKPZ`w z>i70(jx2f);f)*_9nKceC|gW7Ka8KHR-4~(luhXVGo1_D*cyFnc~u^OR$risr`C-9 z1P%X>cBR!$aP($xr~Azl(2S-Lt~6X<#22Rvx^k=yK))ckjAnA{eqZ|b=edw}=X_5h zCRdC-@lCTHPn_FfbDwtH^zGcOMQ}fQJ0H!R%nJ-8)d0{7*ZEzwLb?*t65FbA@PXUN zIV4C@ zV*Y1}jJ0xrSPK&8zI5NkGczVT@XTcO`}#~YTf5Ty;EtBCwXkgb zSwD=k&(w2L?dkCY9d!y$fsaU~#*GG^oRwn?5K$zgC0dQ{PL>oy)2C@Dwix4LP3R4@ zX1fy93gj{)nUu-e=nQ4jpJgHdT=~x4iPTEs*;3;XNdb-9B!vV(ae)ov|4{w6`LMpFl4SY+D?}=@7K4b;&A%>jI~DB-#$4JEw}u^ zR*)i#3=NO5dA8EHu^J`0PkFeeEuAE@9_Fz<$zYtN*8xGd>?*^=Fq+1Jmmu9=Kk_x6 zL&36`UY*2Vb(N+ziRtGnV)?ByEoPZ64;A=6Z6O`)t;6#M-qlV|>QT2t`A6gcw4c`# z0vgU{<=nfNkIkpg>q;#mn!2SiT}yfM>HL;&qRZpzOvJz6FMK3d022=LbG0j0v)YcKO-Z(A%^ zX|^CDTwx69qkOpjw`ng|_iLWCiF4_~vH)BCYlS6V8WXeQ?^B+n@I1r3EsL7Iezflw zr=2XwH2_)6SAJ54Gg+!N$ zpzbOOr8;{G!|`&jXfMxm-_O^ZGpsWJTXQ_#g^M(i!Dci*+784UDUi#P$j=&Ni!{9HUhBo=9^?H=_|LVT>6X8 zpAYBFBuO8tD*rGSfac4V&=O_)$m050%3QU9EzJigGVrV{(akIL)g7AIFJISPdk29p z!3KIHSrtE|Ha!b-U(s$pJzd?Qn8|=xkO3ipzoh1>$2`q05sPlX*a8D#eEeRHOywIw1%5 zZ@`QVh{vbL$xgdt$fpjFf&$~WvHYQnQ00VspAQNnN}#nQ#Nk3S4X|b!bsnCnDlGyn zo^h;H$TPWegXH!k)(XTs_je;s$Lox`&!*tqV3BY4Pa7GJ2i{oxYx(%^DThZ#Ie)(^ zV`HF#Nyyvcj=W#23Z(OUj)ujK=SmcOZeHt7moZdk($iP-?lHpNjRB9dMxxXn6Zg?A zxG5KA0|WI1qRQxlgMe|RkXpO$lGs|WTFxjXGn2@{-Wu&`Z1SFvjgm6asXx$UU|`@^Y$3nijxZ_xevFCDu((uXo$pbu-~N0@^P@~MxBiIc%5`U8 zaasSLNPoQUyL9~IDeW!$l#-Gb*js9|)m5uM3FXNdoVf;pK9ddwZMksai=p7n2N10Y zT`hYz5Y$Q~vS>R!TkYD+%wtk3;})#{`fmi7NR}4SNIQ|2kZO!#X96A|g%q-K8t&sdodYg=)^|G%uW3&$OC-Twf%B33GZn7NxXvo{ z5}|@~&=2Zn`IxyQ5OL=_wMzy9{kk47WV>ZVL1T@9b1=x+c1tx ztL@IcG@$cjwn%2Iq<+!S4SU~x^k`lw!?evcNnli)1OLQP4e{&&88rqKNTnhW(WAfH zF0?DpUG1jq+!9WO&mk-@pJ%~YaJPY=+B{|ME(kC*LFBB8Or6d11A)X+OUnJI0_Iti z*EywBkt@g|SlGdLzwZEkihGX7ZUb6rM}b)dWcgpDnl>5P{?#1?piQ#MaHBJg9}sx# z^8y-19zfM$L2+=tKdkR;mCx`P`k~iG!gfoRKN2rhX-V1%OCg=GbKQRf3JA7%B~gf4 zKl|8!&p4qO_9C1_HJA(ueYM-d6S7%+L?HkL+j=ttj71KA8ZMtD+8sW}PD&;APGLzq zjxh^vD@SD?pRaJs18!^)KXxHPlR%ryM~)HmNCLi}HQxMqyU$a4)ns%$b%MSBW9VU2 ztclE)wPy?f8%4bAMMDJ_#nbD4 zS_&zR>h(&t=&VMmlohj}{1{WI;&~`Pma&RrL%#=$^V3-gKG*#Ne1r)wKAPpH= z#=jr-)7YP{;zBIfFECUBhf*e-gV4UNet(#PciH)<+_{~SenmOgi96B+4DAF}q_uMs zuAkZV)O3Ow;4@#gxJ+;`nK=e$LNO9=wo0|rQzBLiNHj;aTVhPH2+@oIH?1b|pj;%$ zyicc@wLOxPcXkx8gNbS}Rn7jj@a$LfCg?>k!}qx3p<+eYz;9WG;r0(t#{6BldY#t# z{i_O7(aoR@nm-#BopK1IQLtsaYh5#hPfiUCjK8fa7v%I`^3QB-7qd0WO;GS{_s}Tdxy+s5FXDtUJ#OHk$=-vDw?3A-!HY z&ff5`X?F%9pCWrFNt2RoYwP*1uC|&|z7~CIgJo9H=>0A8dWU=eB8{U;hI5UeEBs8l zDIlHU5^Ii#xD@+%MoGBxNc(%GDxFAaqh0jUKG&ROt5YT5_NkI8bd8aCy=Aah`=QQu zr=-`Uf=A~|wq*t$7!R8vohQ0Dg~k~_U!XR2O7c)47ICQ>N{G}LH0Fc4TxT8J(IKFA zAYZW3go2L$2MAd|Xy3sbcX&#`g<>H3MJGj5$?r39dSblqg16cCwf7{Gn=~& zTj^G|a$P@m+XO{rmfi6upoS(;rF-`OaqlxI7B%Srk%od$LX`fEndm1ar>0C7XnT$m zH+GNA{ki_(v(7W*h|EB-ghlX}$?llMdb*($KUo#f4M>iNfDLV0iUX@tcdETK`&_|C zU+@_mc9OB0bVer*mVBTDNUmAt#z=N)gp3hfaHgQLs<5^+T#Un|88vzEy)9*JGg#dtjRa zrF$R~Ty1&42?hDL&o~3zNC72x-Mm##$op!?#c)R+$A2R7`f#%+Kq`!_0CB39V;4RVAwlcdoM_63@I|0jP0KHCX|%U4Q%$YsZNFtM#t^@gB8* zJQHDQtPoZ{DI$qhX3D;;AnTKF_|(G0@U*)Md!lCxf3D-m^3cw9)2UmgZkl?o@>_b+ z@zlroe!+`2R@th^dE36VR=uBTz06Fr82x|zMu#DC0XI7`L-zU22z{ZUWn#Dzy4f2> zhA+lcR_9u8j3kS!dK~PrnKwwM4#>#S=4;hAFbV&YV&!tnqxDQ=T{o71wbpA`yj}l%;ZRy@Lg5vrv?O2AoJ#Zi58|! zKqEktg(NgaRW4Q?)nH1{;dW#;+Wzo)e)m<6kIAAe8u~Snzy$G)CvS<*hfl^-fxNUs zKt;gBNh%6p!&yvDH$flbR6G163DQd~4_v%o(?L0^EWQIcHD)tD#qRaAd+vun`vpI@ zTmiV~8jQkrbsDGWi?d1Ko^kn#Qn9LRMq{jwX~FBo2CCM=3+B^mWBwKmIN#bdLGl%q zgoXA-V=D}Ue)zC@N3+%v8V30^4^Ttm?GX?%Twd?_N;PaWJAdD@1LkEjwN6G8vo~x# z@4~s??DqrXQ;fL1KK!Ce)fonR*CH`X=P8X_hGb#=g+RB3*z(1*`h&;hDB4|@-uGR} zTnS-i-%vj_M>?b9(6(dHzQX*GkpH>zoUcK+B-+U6Pr2UeoRHJL0#r!Joj+M0Z1v5a zUXRji1qMQ{W-J!}{fHI%J7+oHJ4r1On-A|#g7SRzc?k)TnTcsuOAtt?Z;7~K0qmsu z(ih#{g-gOM`FY23uO|hNVX;{($s4WNoFs0Z9Sx^boT#vg7=Q66Mbisok==kn*+lAE z88Xlp#`04+PE^v(Gukmk8Xl zsqcm-(@1Wu?kuirq_Sgm$g`<2upz1vC`lylc}72CNrW%4|6)WT{WVghd}7wwU7^-` zLgSLxq+?{f^$koyuc&2iX3RHQvT2*B-9ncp90E zmb=&?>*XqeITv_rnlM#bla0)^|86nFjA#Se0YHW)4Jg>r8u;B`LpBZWfUd35vV}6( z_0v!NH)h8l`g{sSag6#MAQ@gjRa@(}<;CNCG<+G8S7*tYhl-0CE|LR9&ir)S^XgQt zz=td@x$8?Ga{uYw3q6;#(SG02GnHv97C_RNU6;+J;~6CGwC><8eaET<&O$2=JKipB3>50D!kfqkc+~apd5I{L~0mK+o3Av&CT!oN1Lr2#Ba=%Vzwp zX6Tx|rV`0K8(G@)VM^x8246ALX!MIMMZ5taaGAD1^gLn9jPK!MmZyj>M5UmYR&3}c z2#BO2f2DOPXfY>xUzOlKDHDq6Dj=gD$ZbuPzl;*HX2kAEE461XOKR84J3H)w-`4I` zYP^mDI`sv-y9@fQxIO#u@|C6fEfZ2YEogq6{7Vn3aBjS3yD;efE!V2qX@CjvL3_KI zWOPf_v`V^#j$g}ab%cSg)<=TkH-j4D62P1mqW2>T?fhZIH$ou@^=$duS`7!z=XQM@ zUpB|WaR5Egj>zB$tH~c$s#M#4{0874<2s5kVr@PLc>FwffUu3?Rq3BbY{RYtcXf>h zq9AVL1JM$a(A@Bm%m^V?Mds^xNtVNDb|v@4uf(VC`_uK_uHwkxS0fcu(&rS99+iad z0j0l9pU&bTC!ESdYUvRwcs|MNb zF4dY9>b-R)^M2Htc!P7H7xk-TX7!SyQq7FwdXM|;%j5}QvaKG7PM2Jnaz;mH{yh7# z8?4;x^2jvbmbW2?J^+J)J#PhcOJC-(W(5Rc~Yh!xaJ z7D^O;i-?&|Hc_~b-OLm%9fUNM^EwsUhc~1*3JlkeMDg9+kzQs9PBG>x4($PzDY-I@ z=3Q+pyEX?9)q|A28japyW@~_1;DVt2_HGKRJx44dfG6Tt3dN)k7a9|}OL1ts03Qiz z6c7clF{U}ok_)g0Qrh)IGEJGjAvjdD^>Di6YXgGVMSd4R{UFVg}z%y%K7tr-%N# z&(J%Ru)%$i#cGRJhBD?b)06{VIZxwMi|?`7oEeae{l`u@iYxT)`J=Zk*qZHB6T-1{ zoUhMXD+XhFDvxK!#<=4>2Wf~A2)RxAI_q^o>z2mjgzln#0yUXbb98} zSUyq`avbbw%k*ClE``DwL8t3gj5F(bDX8v6{y4t4e%Xb}Eyr3}m2OZZzKPw|M1pYM z-u&`s`q5$!TTLF1NP<2KgCA zCD(X5TN?mRZ?>NtEoaN_R$TuPch9vFwRxcj_%ZqFe9R}d%pb_$jYgl-OL8fbC&dTy zPjiIF#K0lENdlewrt2cU(f{YDt|x6}9a5)qoh4|!Jfy;5k&Rx0_TuUFL&JWQP^I0o zWCAX9>)=c{+GiPBbv%J&BNl~K;LFh+LB4GE%nP&QO+7R?Z32I1V|@VrQB=E;v}WU*d)lVk(9%n;tR`w z4)&!P;X-Jgx45k(w=FFgsr8AETze>s|yk{*!`?15gh=G~FSVE$27 zuK8mLYrt$Cy@YW!ldcVizIFg+*Ed)t>Bpq0b`Q9O-KK9iJd!AFxyGOF75X|DuDz=| z3Wf4H3YS(J*d7%cm&v0^Vyq#c3e{cRiSA$WXV9KpG6UPYkLGkC<_h2A4K22nt_Xi2 zZq8MZ5L%IulExC)e}n%d@2&WuD7A2;mbS|*_#~kGSJDL%l(9nT!5Cuf5r3hN?zw^m zAxtX8KS!#ChwY!W!426o0LEcBJHG*&JP>RoPhk>b?ob@VXQ-}DibPA5i<(? z>(>tyEc&mAq#FaZB$y9b-y!3((@U+ql}U>}o~Zo0;1Wox_UubCfh&Vji`PT&2_z&W z>9EJX0=>a<>Ltm@LxSs3B&YWA|2v^mU1{FJ)JCchjrfA)PVx)S{{ph9=p;k}w)3fK zB*uX~0qv%R@YF)9!lMK@K1GSIP8UR}4M5At$YA<2p?94eojl^2qoS7ox^b6sGe-Ob z^_PA{OZOxWkJAi4{nL40Uw6()?t{PNvr>scR`@4K6qNgoWUJF0s|;mPlw^b<+YaZ* ztlwCuQ(-cU7Y^UMkc5KRWP&*U_0ODI?3sP)`RZr(eIqvv9ch@96gL$1!F;`VCl?k? zI2rIo%48qiLnNVHmVQHVRwzIF>?mVKrP5dK+&-s>l?#WQ3j%prpR{$O(@WWOO%mdb{{goH$TWCo>RvPj0>n< z@_?XG{!OaL&(sSnqwr0RV}}~UbuGh4z=a|$)Jv15_#rbJmU+9~&M+EYy>uMBIh82l zDN=90!#oalBwl&AX8&)Qp8{Ca`e*juvp+%Jvt5@u{8-?z5H;7>W$BW^B$C|)v;Viy zzb5W1QsXh6-u{C3beeafq$wc$ z=aM1I%bm;P8gyz$x57y1J})|ojkhY=bI6d}G_|WaoD2IOj5M{@$_gARXVv}XEZt)1 zVkhTF=jLq4!2o?ulf0&erf{^gJ;+-|xg)3MF;A&*mPVk@TtF11z=ei=AM}T7ro@{C zz{yV&Y8@2&`G_d%$0q0n$6CZTG~SRYe#SW25$#4Yr(?A(YEmGOCln^?%YF_#(kD0p z7O`3XI#mS#?faRv{~Il|jxqb%b1QlSfq642#8j2`x0NejNJJR48?CzWhr|L;KBjl~ zFUUr^`!UZGsd3uBNn&A*WGOwXNsHIj306=QBPJ}@D_>}H4@xZ~noOC}ugk`$_B76^T*U*H$owFj1Q)SL{+XwG zqgQQ#Hq=3QO4YxsJXoO34ILJR3RYxc>a%w1u~y}+!+|gN zC+K)Ap%DG-FlX%T5Y!D$vDj~1$_7u(T>fOx5X9cXQ|~=)p|T4e`AON34fFn8^<9pRn{+j?La8_pd1?&u80f1L$+~53>8n$1ZHt zr<68V#JwYBUlbv~xyUzKv%~lXns1Q8sF+L0O#qpM(qPXQ#+gD%5IJNg9x-B1TE0^& zCvCdVkL66xfA(*r3Bn+g-Wm~hP?Z)_{lX(eh8(0w8?V!THeQ}mfxY-0*PUuv= z(CA=PQ4OQK?2mH3SNRk8N0`O(CDGOciZp%#oo=&s1chlW_I_HRm07iSBs_dR<$y?p zSjkNP*_*dh)VI3U$xbV5@Rny}?dd@46Sw#Bj?HnTTzJv{vDA8TNL}&}y>qc-4G$ME z=~+3=J1y*?9+MjaOPXyU22$$$`KE~?CiD*hUKx}s1l#_=&@P26CK*___Vk8YixwwD zI}$r_lAbPFAmC^w$}+L~vMLT!x?0!NTtBc2+TEl6`c**g@e1P(7Bfb*h3$cdN}$!x zg0tJ$a%Lw%r(j$}8J=M)F}S~+tZISinp1cKiTt^b$Sj!RnsJ8Q3mjeDFCmBC%hox} z%X>RAOEfMiL+5xf?%ayC$2-r43#(XT?sKfh7(O>fPalklje+Nq=DhMjPVJI9E!oSK zWuN~E=t1lYGNZumrfcwod-l+SB&UC1Lyg&bo;Wh1LiEN9H-=gu`?TemLI^WtB- zBdy@>WAPDYn7RR(i03|XBlQPz_ww4=MZ+O05EOK?H4kUq=>^*bwR2Y5o*3oiqH|>P zBlOQx84+FYzo7o!?7kCd@WQv?+Fmqw>ZLqc`RJCuIg^k0A54!O;A&|*9314aSts?@ zeXym59V3&R*5-bYLU7Wh?Gqi2g$D+@6gn$u`ec^iH-`-^h?#)so3XF>F^NV6BUB!z z6Ysshqahp9eU^&!Yx%fq%;cg)!x6Zy7ARbZxul#TkkA?MU1~=**KHbkcq<|l?LZ)l z@U&)#nX+9|p~6(PW%b6NKLqGiS}-kGJhc(=qx!}Q48Gu;Pw{iKpByL$7RG+xh`8sM z?M+8~+`R#VtCri{>x3wP-FN`ckH9=R3?f#nw?LxUifmZ5CpN2APscB+JqOqYQ2eBv zgd|b05)A89hH%w?3ucVf6G7hCpH0GUa;8Kh8DMfN1y>K}UDRiN=egn2TaVGC!l&ei zQbfdTzuTHp0)N` zGsc)Beq+orxmgNBCU?!oDCt%&$=cRUyj!{6^DZlPkAHQ=1HWzi4)Sl64?Vivg_d0rbdwqoN#A8>ltnG$OGN--W*G)G3SF$+5NOIp0nyvZ(4LY*NJ{sgM;pV#TFG7Y{a+D2SnvCI^jNsp%1f-uO1pYz6Wj)eY_ zM@``znn6}((iezc!KqK1A~le%vRokAeU`_=c6Fu1vF!GiSn!7s7Wa7Zsl>aFHxD{J z@NGu^d|opu^>dxqO@s|>;doeU9WqD8Ha(`3xeC@o;zu}EbCk_y-br;yF_cmqf81uU zFuN1yW>zG*YcL%{Yr2%nme7JZNOFI8;&W=UZ(u=Ait{ ztfmxA?fY1m)rg~XAI=b|p}?V6UAm1Jx2e^IMRe;n-kxk(R*hz2a~{!@La^w8G0kU2 z&e4`^t~ZWg-KY8LXwu_2f8j;jLf_0vk8rmq>Cd^nEgQFc-u`U~2+y&vO`n-_YR8oD z*d8zLDZWiV1MMtc)-{4WLi4K8@2@Sqn6&Z!KG(K76>2Q2<&oc7TS=>5U2zx>KC`_S zlbF}zp(tB0-*jWRFu9GxXtPUnCH1kx^SgaS@%`paDd;KS>$fqVqt-7Jk-R&wyt_}4 zTx)84gv*lzPV=SPbQx>DqDe}dsw(6q6;WGHooUSK7>v_le^!ix=y0l>;WENK&Y+^o zhO03=5b&x}gS&gzj%OO(c{^-1rPGurwyFrK7HL8oLHO6Xd(E}{{td<=oGpCyR&iui zC^6TrF{wJa)rHY3NfGIs&}B_s4Q-76P?_I0d~YC*GbK%D(U-yFu;iAxb>K75h*%yvo!6l)NW2cS}T<_)% zE4@q&rmb4gNySu7qyNq=u!c@C}#0P*u}|B6Z>sg2uA8N0FadW7=TG}Qn) zzYk1HKGCPNA|>Vs-J1~zavXA7h%`tdvn1eXs)3o|zT_9R-7`NwIt?P~J$r)E#(&yd z_K(RUT#d!oT2w)fX;I5z zjU~8dtS{A5`FtiCX(T4DvxrK$xvOf9Er*Wbd^7mn#{?&z{oqo;Wouq7Re@nqIF(E|?3=UmoW-mheT1-WABFWYHwzBiG`wNWL5$?Sj@qCMkp3h0hgNCxeg_X5S8u1ZK+(Yc1} z>yNyyLQ_jhb2^Hr7^U6FQG&cz{69FtM+dnH z#ygUrv^_7?N5su?X=UZq!P`t#PP4$aJ!&AA9YfDjF28VOYqL};NfJY7)4S2d^VGTF zTjeCC7$$6tD^vPm8x%@@t>cjtMml~`Uq z#oApg+iE6BMs4g4}4-Q5AhroT)Hp}+{)lR`p zEW8o`=tD=hp}Ej_6;W4fW7~x)-Rezn($v3Sq8=~S>HrEfK&eXp@nj?yj5(dy^xB`- z>BifAYRqQhx&QO;{fK7~fJ1HNC_0O%=gC{!#txt;NMa(eKO#=R-Q9dI4=0S!tT_?t z=m>`k46VgF0MZ2h(6Gp{MaWc9LTY*zCcj5t+Q0huJ?t#N`((ibMz;uzOh+{)66GL; zw(R4-<%^&!yzK{(NDILt_Bf`QpX-QOTy7#|Mods9 zvVXmCfxre%t*3Ix%-T9mhyL%S1lWipN&L{y@Lwf#|1I-PH7h&t?E25J2D3r`GrIfu zc;=rfk*Vn7l%cd$Lq%f#)7-EEXztS>D+Y>J@$`OK3VP7LfpMn6zNmLtrH=)#>qN(W zXM0rnEcs3Ne;&sDdkBwcUyra!()DEQt|M^eKjI-uHHFZ-jPKD_#gijR6{~+A(a4dp z0ceH*+G^W})&X}>dPux+*k{(8m(O>P_Txn~e9>P##djp7;+}Q9%1*O=%Edzho40c2 zWWwhA$)csDA2Pw}Zj7@D$2o8=|31Ww5+&u}gn_%#apDn^Oc)kG%Y$srj`Cn#75}i- zb-Q5iC#vtiD9=h%ni>3m(5a&E^0A281!N@Ac~X{$ zx?#N08lL$bP#qmGN3l{R?c=-usQCt|pXKG6e=df5756(ibFf52SUETd&Gz>%6Gpd@jEJC6esYk^i`&+t zKcZ1{YE|0e{l%~6STd^g$X+i1*4LiXVG8V<3uKya9r0gQFk^Z zu!ZJis{-qFv+z%i?Ii)Ss+V@~#{pK3$U~QxlZ;?}xlpCt)B16-SYhBA>fv?b5r>-} zjQfI-h!8!czsFnz$F*7|nb2>fwF{+TN2|O-kMLJ6SyghqyzE?PYi1niY9$rQ`nGe* z@B+Njeo=ECRXuQw>;EBzZV{2s&1EfVA(KH|L}Wen#T&;zU1O@&cJOV>JB|0kOSX>E zu%6N1vMyQ)%6mzta-eKawk6HGXj}pcihRm&mZY_qb%WE<@qB3Y9`2My1u&loGgq8D ziPy@Hqz>t^E5u(e9~pwM6i>v!=3x!f@$+9&Y)%l`EDzJihd7vl1t!fvi#|bvu^<*n za8j>h!MVH<`0;=_ZGtKf>R0`q#+I(}wf4Z4qXfmKZO;#E#HUqE(rR_;zlF2uaA@?& zNk|f+{j)#ZhLyCDN{39rxNc?rC+G=Tlx@knkBQC}srRe{Uw3Z8I}x%J3k~At6Mg?= zLO+Sp7<&E8&nC3q&-|GFr-co$E8pYxSylV5uvllv6@Q*IF@}cH#kx}h$K9-max(L=~36>1pJVapFJ?E7J(j27b`BolN7-=w2x*wb<=`>%e}Q)r%>hf4@&9L>hl77+$^a7 zq1dYA{q>gB4V>sp_|px$-|hj=m*Yq&+03T4)&ohC@Aoi8gJUwJXI?-IPA(;j@0X^E zY_Ep-YxzdL9~aA)8l7bdWtz6Pw=+5mI^D(Hqk0z=5rgLbz0Bp|Gk~vrCf;m$y2XHb zS+S1m~?QJSo-kE6MXO4=$ zbkOlwO)qsUu$qq2EOOCna}P~v*LQul(T0;R{meC(!YAYXC=p3(^@{t3TgM;INHkVyW z#;r^9thVCeeE0s-YbS+vw)H;f&`_}MXB$g0AVwl*s@tP#JI1J9;>?t)u69NRRfmYl zs4*FKzMgg1e<7IUD8~%0v-(w9r-_Xzr}@dq0;aSMB{O^3rc3weo`oZv3~Z+rs}-m$ zEbN@Ok3G%5jk*`NDz@JsawL47Ay(k3(d76;a;-_yx;`La4iwCct_^25y67a`=YG|l zDm<0*r4EjXi3#ikW?sAu#=h%qfv+?=&OYvHej?Cg|;L;T(+9TuteGH^{yi*POoFu*L>bH z@xYCN*=Yq4)Dq~+PoreipRE>|WBoTf@SvDVETFeqrBVZejm6_688fnzM_d2uxDHHM zDcXdtKUs%#r|(dbM+JZypuOiEm{clHA)w+bL@Ye12P)8?ZeA0FBep*z%)Ydb;<3d7 z5ohpzbZtDM4Y{^q2ORTE&yBDCVe&cT@^BH$Y7V0d4Su*-0XhHhFl?jaW3*9su^hqH zebLC8(dp(Kant|@4@Aky$*=JY*MSHem}-wXMiK64?sne&Axn-hI4KEVU_e8*<}`+2 zZ+G0H{i>%4?s!0%*!q4p2D3uJ^yw7?36Maai>|-^4S#p~_V}E&`0gxefs&cG&JJd~ zM)bFnM2pWfVF84SegxabK#g><#KEN=wIXC>%(Y*D63{=@_o7)cnAES{3gItYV<= z8f^lbG7cQuUg)Td+kIg6KYeq^uW>UwA!u~Z`;7w;3aWKUjpwOi1{4s!!orKpw=;kS zzqkBaHUns5O?Nb1^DH#6e6@&$(qyFi$^9*FORA>2q6@ivh6ECXKKZe)WdCYJ050cq zjacMuM?vu1Wz*q=SdYVTv*iu(qIP~vicD+{dkXK@nr0TTJ@Rjn?2 z7yP_xe;7#@y+VKfd^=n27RewkBYRR3iMI$~v;=-LE`7@W-$0-M60G-xUAR=beRMpU zk6b;wx-vk}+aqh|ayn#%T%Te~+cs6VMjJnJ$E@0AwM#vUI=dMD)?LHGbp^^ssF_me ze{ul;wfqx+8B9;Y%Nw#NSG7{bCBS5T%;-o|-5-tn(fRBk++=Dc*Pbt~Ll0xfl|#>e#3C+(UT(A{!U62({pYc?ZV%p)h(oMpVBX+!aop> z9%~2kwfp-$W!Z}hgs@sojFi;(=vx%4(9vmgrv+mVQcPPtE)wofSHf9Y5${f#MXs88 zuU9~O-voo-uv*Q$`?B@V+lo*DN^qVMEIve?wpxhzNJsR!UUv!)1B5n~Awb#Nrmgmk z{Z~zvg;j!KD3agH9dY^LQ0Kl!6<%x!-Ws^$n_tE=jSdZKw;*YEY;R}syWdmAGDi4luS=t(_2qZE-srpM#s98$jn}*(>fERO0V06p&I8E zx;*8F4`VlmRGORU>FFedfqs^9&`i0esZSp7p{7T_e&K_n$bs+L_+>8sLsGI>SJ>m+U1m&5(;LCiVhFR8;N`XV7Q16{vJ$a5c#!XCG%Z(cTzuWYuZ*zfV zG*yULYCfSw^ErQW-#YRj(^Jmoyc=%PQdZpxE#lb#hxQL!+WB@pH5!@3=!2jCwc9@M z0heEM+MM0k|EIZvLWH?Wl}~KJgc;0V5Fz7(`PvDCo1u9~XlQ>#&Hkq!M9~1DkTwP+ zTN=;b{iBQzm3^( zrVZ*f$;DA)YL1-e>VpjVy5s}zYqi;`I)38Sfy~8M;mF;YM0#W~omRdv1iI?#QT zwQym_=dr6&sQEHO(?45-q~>R=bJQHgAlTdhJwF{?)+OMx^&a`dpCwf>3q)DV%sW^u z!fF(9Y-ahd5{%kj{K;TSnD-kPBIgfBJM5n3tB7?6!Sdc z2MZ@lrR0-zDr3;_uudZm*G{VbXx@~2G=i6T!ybEJ0+R*7BR)T8$F1b)Q?15kKL=Xx z=^+{0DCEMo|EG85Hept&Yuy%q< zq`if4G?6W!)UT310&9TD+<7jy>i9Fli7uD`i`QR`zS?)hXEDBu-*yB7Dq{PT=8GA* z1if?`cP!A(UC=1X5Zk*t=fF{52~mMJ+;2u+c_uRZDOnJ0`3KsS)Yf4T5_SN#Lwbp1 zwC*(4ojAZj9`_#2+vi6Rv%xea%s~KKnLB4zGV)?*u@uUbJZv|6*Y@R!a1t71U^3T< zRDAd`vuZS1qaykj*)v6$uCk)VUPpU>d)9rntx#s8&SB$K$9~9}T=5NL5#nrz- zkb=b$@L0OUgyI`p5apS~X*`M9RehE5V#{K#6!qcQ4nxL>hZml4>vF}NElzG}hawTKc+pW65B zz78<|FR2N&^X(aio&=88wzhc2Ls}(7y3Kj*Y%2NU=P`NX8CH)|xf+>9>9@|Vt~Tq# zo?_uQ@nH3?9j<(+*mc;sxa$jaJdw4$tH{Gz29`5Wi80IbK(VJ~xyyM!l05KM`|E*( z-eMbMEsr($`Ua0`2U^|{6$UNNJ{YwMQ7t`aLpJ>!yYTeVfBzu8mkEYRlNKH#xm)L0tEu+yvI@^-n8uA6FiM;%6dI8QC zp66>}J6fuWnrV{O+l#FL@`-NO!sz`IC&x&H~J^_2RK>g}gT8sFcE+x&~54cO6>T!v;iK%JODS!9wXGf^E zC~!%fo@5wZ;ZK{Vro!R7Rw)T1-Hsq?Rc<~^OCCXh@-Z4P15gqXFhci=@GX^Ax_u-X zy0I)Vj418umtE;xC`8kwBL_(wuIq(cv|8z`dzHxnRpz&qFF)rh;iu)#OgEe@RXxCK z5%r!i)ca&-Pw}}a%>zw~N%i?V=t%so{(N5@v3tiAllHMh$KUe3X|{#1Tj)g|0e^Y8 zr5x)!Ub<*t%H_)EChB`QJL6cP9Et+}9s;(Ht>C1W=v_Za1#4XH!AhfE4R2&b;oP~D z8kmq-f-NMldek4Whe`}a%(33bcbu#CnT%(A@lnZ=zKdPjlzldqVUTN^%;S~_dHj*D zRr4K90C&L712L2Hp$;bHj+=?{wfE2jf46o#e^J}=owyN{l;D~+uM~mu6nkMWqlPfCEYpBEFkNrMwl$Q}vasMwS$EmWh!PY>wKlyqSzx`0Gavk>4|_R^4gU@87fP}# z3eYD*hvwIvvhh*-B;P@w^VRp{iWV6b+}7$O1{!`$|CVj@N7Z?##|70eHprY(-C-7x zgITQIWsze2^<~%p0<*O9ugB78rbbbQS#O>rv0G^j5X04t86pO+S4tLezdSzeZ3SLL zwfhb)!V4^$d>+sJSAa+0bow^gkRp+JDlMM=^7I7->ql+1$Ii|U|6dTF8dBY)c{itQ zsTi~6wWydjufsaCIap9af)2(SG0h((WsyK33WposxzypGI*I&~v7o>3C1@^k_AZkp z3m1FJ)775jhJ9Z$fa&SPx8&6|0ftfR(nfRBbgq;>m&WfB(&qpAVML=*D8_kfw!sdW zxSl5<&4`}Vs`1H5WoTHK>e5G(cNSTCJeKF|1F8?&J_h^QG>O_o;f5Ll{(wl}|iw8g{3R zPkgkwoOL)};;|Xu^lyaWVF6Rx9eH3Ni7&LJsG%zu-X9##!2r{Yw*${RLm!gm z_}!x>Ez%@@X4ER*Ih_Gaq1dxP0Jy6Lt0vwwzpXUD*P^1aLAVy3cP`0=^+;l0`xDL%Z|mg9|dSlUtJuH zI=pTGjBQ;ds}(2#^u*qE)dVO-BH$u5GolCy`|K=~y?1l8FdV(Ix?29DfTZ^^cfB&u z-=DH#k6mx=22yFENqqE}0FXPFj0em#Q)(jP&^ z!Ua$h%+X(Yyyb4;igG)8ptWlBd5Wl2|G#jHAb@B%tTg0~8HtR+SnnJ*$p96kfDR$- z`LWAJ9LgcO-hM(l1K35_&qHXhQZ9qa?|qVz-Pz_+xr83U<%`E3?i{uvJ{v{#5By4b zJeoWlt5AVTplW-8j0y+~lRD?-<6VtJ9tk0iI=4@ftWmaoM=B~N#%Oq>=U!?AZ_WFh zkk9t#j$+s9=4PiHOPpf*dXv@a-hbdB7!2!vHt`Gy;)RH5n3o56iCD z8X0cawhhb9a82EsTV5TH@X~ci${a_D{v2YK4AB73kypb0)zKz!EAN;+c>Q!4U2ayL z+sJ`97#J#pr_yZaYw^sMCE5T#0L7nrB2*x-HHvQ)`b#{{DF0j=lcC0X^-9+>@stl} zM%{BXmXp)oRTbqbEsUE>x6q@e`|lmdo*TJ`LrW}|kVjFVV<;DgB&0Go7CRlP(s(^= z>Q}g2PB*vm6ckrejL)kRkX-o<&IVxq$J$Od5r8OkYss)bjZ(bCuhIczb~Imj#ZXgh zc!t*-QzSiRWHAj`l2VS~w|&BN>L(6Mjji#XKbZ8Nk^yoGF=;A-o}Ce4TS*-*q?~Rr zbf5h3qS*TvB~4iH)od6LoiS&Z9&6u?bw128gYpB-91i8b+brx--Dv{nJ`IsAIWG-B z|L74Lsk7%woIF7UHc<%i8A1m#4&5D}<2*QbzGiKJ1RZ)gVp)d%`nCNOF3D=-OB+6H zpm`&c2Uab$2hV%=j(CVZAC@~_Fq^aE7tHb!9j&m)rqqV!DC808{(fivxK%(e2ogB{ zFZj&{lU9Kn2EPS*$S>6yy9KQ_B>_7X0|P@i%E;n%0bIoAl7{NN8oO@cyX*m2s)MOWm>u~8yLo)TS_XkM5J4R^wVSm06eT)@Aw>A2z zYDatbclE-(M++(WO0*+PBh>uaFxhI9k&2Al+((Pt*yyRz;L0^}p_CWHASPS8#{~Qy z18CuU05JIu2@oZ&JEo?lqIe!OJUyEem`vvbQi!ZhTNW6DK+ZX52z~hre2>@Uy~n_Z z1J%jF&P?|%{=f@a38~=$e8K%`L**|(?25#7HRR_11N)TzYWVWiRodQdribDWs zd3&Z2}20KL~VsC1I-6PYvhm38ys=0pm%7 zMBd3xCKTO+#0?;aPd_zfe|n>>0awlIat1KDXEQZ+B@^dIN+SY=I7=yL@_=kRWwB-Z z;Y&DY2b2KsBm+1hSboc)Rs88j2RGC~q2LiKxD`OaE9xA zON%{U9p>wOo90*&a3{V^FrCepsrML64Yf$BCUKRN98`w_ZyN{_p|;NVpD>|bnU{;5 znjc#XD(4E+SMIE?bg=F&Su8D;V9+H9QhX}4=K_}DegoQ16lx2MHpZN#94;@q#I{Qd z{O#e~q(5;&1G6u?BRi6UV$6yy`ndNUc|gc7WllesYmrM`#&vOu$FO~i@RR4!p*;u? z#^h1(_XPu<4QS8SMaRwfymJbO<1_qo`SZb2-O)0c|7!w1yFP{QM|}!WxtiI!e+MJR ziv!lHJ@1#+TjSE2KAt=g``fhWve)ssBTWIA`l!tnUazSF1NUNHS6L7QcL@2*E*xvy zc}oI44zNRZ3l&i)y$s=45XDr7*9+QM2?Hu2(~b+)niFUR<`IwQC@+u~&MP&t?#G93 zJHziIv$;782L5KkyeLBAaDg#NVAesd9{g3E2=UI=qOSu}=u?3}7|D_RSZZ)0Ycua8 zb@Zz`xp9 zW!MX}dm8!XTx^zqx*ggvYz;n~A#!Se0Ky!zs8Ix1Mq}Aw9eO3hR?SJk16sIHL7Po# zvE9(?>Y!v#5=(CLzwENS9m2VJWCm`Y1@~B~j4}W9u?);eHi>H58o7~)BJBF@>*hTz z^2L=}sU8yol!N3+BN$VORTDmcXP5JW*={BS6cYSj7XMHNYZ=5qf`r%8(dDW zLlp6Ff{cFvXb5L+LzN8zDjLzW$B-RZ2L@YVGWKq@48lv2oaS%&GAdTpcaAQ zw9TUD8G=-t%XgSW(L4Bnn(|zCq!b4A%BO2CC-F~W1$VRp_t*AXpo2(JZzD)lg$^sR zYX9f1e!JvBgT?6(<4<4{69_nN7j8`hGaK@M#t+n5og^%hFlj(GE4h>%aFxS|_yxa8 zSl7QptJr02H?1f&>!P?kSb%FUs)AVp06_@fi@?B3EY4Pe*#j`aIa9VJp&)ep0i6(c zuG7&?IzCDmCS#>xK{N_6MnRG9dt}(9#zP?0K~oiE0Ej=hU?hG5z>2+FIuoJVgu!U| z5~E#bxb)SEndyXOGshnm9M~ zZ6Sg1ShQyY4?00IKbjHr)BhtJQ3Cy6!x3vT9NfQ-Fi`ME!vT(e5(e#Btr1*BOM(b% zAGHA>fN)8>4o?CwJ`!r9=>qzp1f4;T-%qf{2V_)skIyaDW;ZvII!Am`Za7h;2wf=fLEcf*1aB- z^p}yBZoGu90$S-hAigtt!36}gN2sEe1Qw=wb-T&K%-mYC z0AR-LVeTC*?!b%*@%nfl_;xfCre*rKVyOFbfdCk?yr($1 zl-u(d2pDpkFAjUT_XNNca1Szi8S9H3Gu8aw4Ruu`2Rn@_1@t2=LoG(W+c;fKiX8NE zFr@yC#%d8oovxM*%R9R9cGh=+#=Fl3=Z-wX9!Hn1_t&bf3{%bq9B0PbqGBx`3RtZwYT-*9FtxnQ zp4QlHksqJ_c-n>f5^1x&M0nh6m`=-w6j%fAiumL}oGv7Zk+Q}5xswxG&b>%a;E84sp15oAHfP4Ujv=@ z_Sn95f>^Wao8s@iUrZ^~g2^kOY=8KV2Gs3;34UwUsC5oAdfBItWsU%3P_v&!HVki` zfuicUz4f>}kq2F2zPf;ZdK1n92!HvDnb8WH;ShUu-5SPs7&d{a$Q{U6sZUz}q)S`Q zOVsaO`{u~6=+!eWzXX;;2M~{0yEtF+CX8l!8^WkhT3dy<>}Xbed%Pxc=ei?QBy#6n zPEP=8htV&HZVYgk68#eVJ02_^V2FOWud@TQB^H{1ZZ%sC0^K#QodZFVg1(M?o+CVa zA)16zQuMv?e4Q>%nAZ{-ARR0tr18O#CN!yX6Cm>!$5Mb(=9K?rEpKjaPVc_-MFArO-KO zA;Un1m?^y{r=S3lflvGh8^tS+;xo2yYFsyL>kp5qfqJ*y>>uc_$-CO`J7G1M^aEKu zg__72x@)l^cb0e{7Xwi+BAq}9zdDn(J^$2ay91fyV&H%^GrBa+0WF@rjUz=e_i(mZ*@bm>%Xy=~j&v+yO_W|rQ z)HdKDlwcTT=O}&HAvpxW7WJ@Ps;1jbQX^@?K9IG;D1(tSAQofmd5(WfVbyqFqEAx` zbK(IyuDh3d1`I|VZ>&(-<0);*nabPxKkAoba(tbW17;_em$23T90`epj57cPYCt~E z>(HJd1^}@WlL8k=vXcjbqav99oxmY8@>(@;8v--UPP+s7qW=7Q7TlCe&i1yD{NB?Nnxby z`E86=%9ckuQ~&OD8*8?7>iX2se=`MFUbm4zP(mgsj25CO^)zT|bR3+vx~R`~?(Olz zh`4^ft@-&HpuSQqKBCTRFzS^vFU>F;fRjw2#%S7TKWU@WtfAD>gM0fC%L;^C#NrBv zPS&c6s*}6r6UPIQqQU}f z%(zpM^zn3TFa6SZiG-z5sy~Ik2bmajYK^2L4u*Zos~1&b@_(gIv$XfFoYBCjwsLOL zH0}}Wcc|8%4_8al+TOG5ugokmDEzf|0f&5TP#s=g9uJu;mC(-I+sfC;3X5}`0F20pqMph?%3`rP z5ts~W<%jl1p)5!hlAUPDgCd2I71)pilf-s53F}V;n;t$KCE%oC z(X5Isd75}RqerZRkO?aQ&|5e9uwf}vKr~V)AJ)Sl%gcg2n8({2UC;~wdqqu&&mx}~ zxDgwP-}Dw(15cGGjkm=qqD&uT@`DV|ib&-9*3$&6Ig{qw9peUS&CVa4Vgc3FdsBss zXIn^5>9o5yK5oH6qSgpy7GX>p-s;w5b3K35w|+KcnSC0C(F_iK8=RrA&jq! zGGNELMScJr4LKHi{Ke4X%;V+Bp@p;Tn`#J##1b69hzr%ksj^IpM{LNJ?dETrVFIyb zkUMJ|zBzIjTjc$#SrDJ6N}P*_0Em`swF(j+^(q;Ze&635?#BO3kWJR0uVK_rK3dX2 zK~Slqh)m+xAB1Hdw^Pfe1?es=eudQOsv!<08BZtwC%;&hK28G?&&)K3ImS3CBA(En zWS8_clpK41pN+^K1jc@xwi^QL4XIzDhQa!e)nL|o zP}22v9+K6+`zJu$e9LZ7?f#`mN7mGQwS_af+x8e~e_(xOr{l$HrXpcq%R z`M6YpV%CV(ZT2i_iO>R@xfz>(quwm-LGaGSu*+wYSrcuZFQpE8X-1kt7zt*?uVhXK zbd1Z96%qcdGH}FY*STqIFbrgoi?nZKrAtdUJ-ic^?vH>}?70rt7GQS@+ZckmFG)|A z-W+Ne=)quujNpjHQDeBraq`fExkPuChy;1~^*DFTi29I^C$E)i?FW3ShJ@{00LcOu zuI2{oYpQDSzQRA}kg1hTiT0=|TZ3)+2t1SL^Jp3t$f3m%fLolor?YpR4_+k)2^+AK z_6gMe@J=t2r(jP&vmp;JlZD>p!GF5BE{pcX7H;|aK{sZhc;Hzt4&vvcl@B0sdbb1Y zc%k1oYC6bQ!D`8kdhD+;ZvS>)J)DE8v9~R(Axk4Z!9tVs z72k7_zg0l1FXN4lqXU8Bz~#3*N$g@JR&tn*JP>rmP4OSdM#Nlt-_h6BkHJKVyuOeZ zFo7NrkG|?xKpaObR{Jd^>O`aLkgZ}vYiT(cCNCoLG@V37HYG^+aq+8cYCsFCeq0iw zSM^NjyC{llA}7o5Fl50ADo=^6mms!;ElkXTV#g^@YnclY&P((x*9VToQhBlJ8(#AA zy(A|9^C2wSC1~(n(mB4TB9EP3yV7pO9fdtf9|J=ru;a?PH@q<^1v?i8P@Wq4thY94 zxieK)E--3A%1GshfqbcEZEGK6d4Umd^LTm4!~2)!aqViWKLaZCqTwlKM$TIjs(|!7 z33R$?s!J`$0}s`yrQO}yLW>wpp6Ac0R9=aQM2Ec{rISGV9x3E1XLJba1w@j+e*3EX z>8U5*5U;@@7j*uJkm8K@tfyfs`sqzA-m-J?he_!;{U!Ikg6_~pdPE3hS)TQ;^@>-1 z&B|o5;(Gj8C|kA)wIqE8dq3fq1L!raM2!I*gGxS4IGoI z4DEb2vk-mk*HjRQw?3|R1{^``)KbDk=bmw;1Wm2o_>bR>h0h@1;Ze1_CioBjI4jZV z|6O=KAr1@NmEqRKk&tGV=GzxLsYapfX=iU{Ck@=Wf zKpPwKOP3aD@bUdG*3~DH=(jtom6KXpZI{GISl}1lo!{n2AOyn=q+*!rCY`?zjSDCU zLtSB`XXOQqf!doXjQS!!ZAzAlrUQ2Ot2N*fg3n^X%{7surgKXY$X0chI;SIf&%r}M zVZd<9zdBRx!oLlQT4TLI zV9b5FRuGJ}hjn00RLpguXy|7;+?sJHrOor5rq|!(Bf;J|0&9blwR0aj`P}W879ZS# z5ctDJ@>-u4{H8buz~laOrgx08phsx);L`B4IzjdbRSzTi9`K8+nxc z%MYZ^-8QgC-PuFsHS8fVi3Y@4CI3$D_oHZ+p z?-$e0d;EGWd<`@sm}LVq7>9`isx&^?#wZ2U^GyD%5UAqaj}sLS8^kq1{y4N(f4o?Gi4L)U0CUVQe+b zG`2m|HU!FHeTKmsY~)%3hWH8fkZ`ZbFs3n<@Xn7g5-%Tvd4Bo1EKoSnnb1nH>u*gT zRcAyX+hiw>nSOyyhXlKl5)*S!%(%!W1CJ@^8boU0gv6rE3y-g~)QX>CLk3tU4OFSe z)RKT{P);9ZxNgv^;AcZ!Aw8SQW~-#-fej-UE7o^pK|Zs`)8=L$>$&k0j)Bl|p-+lt zG9M*nzMRx2WNb$Ipc>!hG&BCV^`{vZkoa~8`Ei3lJx!P)Sk=@DFNC>{D*uy5a0Tx7 z3;SnO-3LVh-i)}UqF4S(__WO%MQ@wDP3MH}eKxlKR{x>VSN1doZQFp}MW@^@PC zQ-PO)OCay1*BBG0ZM3!$T^BbE6~m{J?9b$j%x|KxbK4dFzAh5K-aw!^iOw6RR`hg< zkg7MNxs0aCX4OX6=x}Oi z@e450=EdjO-hX~|L?i3*8dCIdACmP{R?(C9&`6d_Mw%$i+35L_OptL(jliu0A)zRL z)~P0eZ^b0UDFkB|Mf9WY70`(I_GS*;TxY(3njNU97j;Et*mnJyFBRH|-lVN67vwU$ z?xm)cmo@ojD|Ht0C9!CpZ>|(vX+`JOm!{969>pktL}fCrB!db`TZ?F>By*jXtPu1w ze*mCHiX+>CO?2y?Z-lRv~U60wJ*^`Q_kfi@ZCMl^D#6R2!CZGJf2rWOST3|~_ z5OSDO*|16nLKoFLPO-q25!emyxu(>?%`2r<>WRX1IzvojGi94B=Ag0|rRdyoa3k8l z%vd*;`J?UULxS`4AFPr|2IZ2>_@Na|sx_WL$S4mk_W`+v7uGbOJA%F<)ewUIYNCh0 zZ!)sQo`v*b4h}4njkMeMJF@X_i@IiXzCYH1CsO&gX|o%5LY(G3o1J@28%60>jr z#B?V9=-g9{PMp6rF0=+sHJ5}~!-b07wf{z%Tstt%&|uY! z7-sX{DWuFhlLau6HQhUnW?(Q%Ju)iWY~$K;ZAotO(mG5XEuMj)a5MdlsckP;gjUOP z^EF24)!@O2E4bQJP8wT`*5>Jp&DSERjfQo! zZ17$Vf8fG+(GImK-fX+mk0;_1QXdkrvI{3x$JxNAWw|mZl(LVd)J!vSH#$9|*=#2r ziDQ8H^cDlOe3=#_iQ_f06X~b=XC+cL-`p1WkK&r_`1O0&-DIQeTniD!r3s{Q_%5~k zt5U-oVuFJ{X1KIjg`b5*PWi+2gtQgB2b3=66}dQ`rOZdS zb(&TO^*3b;jepU>$h!nu5m>1Xw25%y@i*{HLmFg9iOY?JIvF2zF03Xsw<>rwjXC(f z{C@cGjOh8-??5_ST!bC5aTxx<$}CiqCIoQLE+TM(tvEShRFL87}Tc$WcaLjf1#ycF*z zk^UI;)URz-FD`K1lv(i8zJhh}O)J7xSd65z*D$1O+AU<{?SYFx=QD9Ll{wE^UK4LM zf6AL`iRI}kCQ4D3$fmJznX_okMD3cH5+!XM{ZvU}HoFprJz(iHmxNoxU07+LJ$_$q zADNtfR&H!7<`tJVXl9d0FUc)#OrAJZ>ywzFG>`XK!CzpIv#XoO9DzBN! zD)`P*dhq#`l$cG};;lm#h56Md(bUM^fsf>N#8n@`%fBk0V_SbFm#r?&*4Jc^;#U0o z{Ea35j-KYo11~}?yt@XWx71ALwd?KH5UXd~e(%o~ygw#c^Dc}RwKF~Z^l0D{F9`tG z2N|vTeRme-{XZ)C?x3c&u6=#I;HzFlR7AR0KokTnQl+Y>5UTVNASzO0KuRb95)>;% zB$Oyf5h9_5NC_pBctx6sv=r$egpyFCgpvUHo!s9y^ZorXlVLKMv-jF(uk);Dt!JHW zlrUd7M_Y!H%x|DeLa( z8^$C;DvT#z119*T1FM*c8Jti}eq zQ5r>0>2J&44U*L%w~Y#jh4?P~mn@-K3Mw4Sc%pu>OOPS8V zehlFXS&%t{nh-jWuO66KUxy#mt#vFH{BRXrfIgq)KRT3B>=^lk(0$`j@X8X3GBQ0r z8pNI5fF-<29`cR0wXMuW{wmp9b{1FNT^sB@ycoeXjF57wzvJYl1UmZX{k4Ac+qdcA zQrI8rOJN;P<}&Qv9zAawe^$97w48T!c3rxqg6|D2j5T25aIGj_!Jrf?OYTysZ;YbV zek)IlbayJtqQWj+zkFD(-S~zyxQ2eUnX@p2+b^o29OB)JXs=lyt^E>V{;Z1s74Jo< z^gisNwRp7Wt#-4MmJ5_UbvO;ax)V<=p&kj4kRc4}N6-tm49h7wjRB*$Px*J$4Az@| z7_k4;O0nz%;cY11VnXy?0{Q9;Jzrb3CG2fQ{p`0=(-CYfad>5wY*Zo@be%fQRF{J; z>V%Fjy0OdfO-(T%yU3f7Iqg?v_!v`y+Rk=+u3%QRG^Xn7nFcb+-XZa`>FqPls*|5I z#ZlMoF&ib=VwqKG_QZb5#jk|b{fxTSSJITGTgO0l{;nlg1_p>e)BwEOYP{!uhXrS? zkw9Um2U0h?x_|S8rS)!39-Q$>#C*KzmesGU zd5X*!(a^hb7P*tb|Hz1Q-^w6#;K*l9{4`#|ClH@L_>DZu8pExl& zZ7xwnn|hV?Xgp7g$sz-sVEBk?imy*~u*r|nsBYg|EfqriUH&KInC=5U-uun$-?SV2 zp$=n*^Gqe-pU#1FCZe)^zVBFS%*GVCuFv_k^%@8C6tN`?9u_AYJ;pb2Ez-bo4^Na( zpRrTf4S#(8yqxCpo(@r6tAV-N-pI3!yjc%)Pgu)6cUOW!bKmkRxiU1gKq9p=RIvxl z!Pm}V11xSQ&=h$EWb%e^l!1&@OvDqZj#T-puM43BsgE-~;Ej2R4Bci#dwxBtdSf~W z%HF34&&nL44qpT>H#wR-7Xt3n zQ}>DlseVSt!dTZdF}%mk14(@u;3d>NbKk?z2Rn{pc>s@3Y@V{prnWB)!~NsLC2-wB zD}JOjm%PofAjXz#LZ@~GsJX!FX|?UhG)2n+^sPkUVL~lh$JyPn64^u2OAc*qPtoIOV;FPdY5!ry_Ds4A!Oa*S1%)j60MIoT1L+o;RM# z*@6S_u*mjP&nxxtIN1_&k2}A8FAdLb+pe3?zu)n_>qiN~1Ld;XDk8ZRNi|%)Io8jx zYp(H0NUBt8vpl=)P3v@xYN_BC(AbG1=O39h-A(}=J=@oW10p+#(0%p711!$Mj@oqO z*enW{jvqNKeF_f^A}z;pJyG4s2``iGe51kKHpadLz<{Pp^M_d%K?qjz9-;Hm#Uxgr z@kt+)5%;B3bdY+bI`x9zD0lU{F|Q(g6~EE0On}ZSh$(k~^AF2Jo5gZ+N`#Ga9q>VB ze)E6HMh~GsIvVqS@0l}J-i?ZIBi2E!>8JZG(OD!sOB=#3zQl1%>*L%KV}l-rcD;Us zjV2}Qxmaxz}s9$PhdSb6Sj*-2kybJ6(o+7!z+SBStrSg3u#N z74{w(;n;`tK^vownRxKRup|LLj2v>5qmZm50SQ$?Nc>Uiz~F!|_iRR**QV~CCh)wf zyyd|>x{a218h67i<}Bnvw>PbmS;6xx6hKcW-?qaO3Q7cS@-;h^?i(Ce*4t|%SJ6`C#I?-hR5pz#be*pY z1kprI)CT7us1K8GPJl$G;^hTiNa@}j8j&P4Rn&NJbIauU$aVDCQFzFf{l*sA2#i;$ z^e^CSuHYCJ7*>XuCpr(()&XwZT+(E6)QLv>O@x+A_1kJG(T;o8`u6sU*8R6k4$~35 z_JBJqBSauIa!IudCL)&Er*Fdf@h`UI^Vu&gsXq!$A-hIEFawhYkLb;a$%^Q$p}?em zqK*f_*E6Of0DK+U=DoRkgqTe@A814Vwudy{pGL*nwW^VC@?th+IUCb9H4px|uj2a2 z1wULvvqOlsJZXBLHObl`^iye<%mlkRadwap=Eml=Ag38fig$))< zY9WUYUuiF{uXhQonVT>r@fYUyW{!3}3_DnZg9v$ud|M7`|RCx$$!^MQY=$hA|M9imaS1A128l z*81J3#E_k%DjqcsUV-Irb$)$)t;k$J85H*V#*`Wx?>@yQbcwXeeSvCueiZmrk_gn_ zPhSV!yzZbZ=A5l_&onSgwSN8vXaKe~TG=c0CqyL+_p_@-zzd`%@*s?7)H`zv3-&Clyvjv#Y?fVJ)C(i|m@{gT}(Zb+mv*e3$56mNp^k*2`uEZ7WJE z3hBVA-7N>Nl)iOdA&Nn+PC|C*z-DBdPF%!vf8<$5g8S8pTwbitBCiC1QsxjBbt-y1 za>Pn+<;gmG>M2O+k}7T1Dw|pnYlm+y>-w>zA9kI*zQGWyhVwyMgSX2yibdoGlZ@W2 zHyAJ&r2L5;#KMl4qo*gbv*VjtZy$E`l&H9HRsYM}iJIgP6mtEC>?aMJ2#wgM<+eCtg)kvbgBzG`CEhT%Nf zeRAk^1|}PqN5d0pAu!-{_xT}|A)#fD3qrSK8RFWJeD?IUuiL@pPO)O{ezoB4RFhjW z`R;y17MI~nTAr1BkW@n3BH;~N_ytX z7(yQfWM4{TFGLri_2l^^EGR|)_enHwZ%bG7%<0m-BTH1=%DWftrdzp&^8R^_QrLva z`@lkLLb=C15N+O^L6a?N!1sbLH^$(D(&i6ofG7WqP>nx>cVjMwa#=Vc%Ny@FaAZkX zuc%}CUxMVHhAVZ($dK5>*%<(G3hW4wHWO?ULS8r;D#aT522yLWPia|uJ7Q>TvZOXM zHComVmnj|AN~J5Q>8!+cKuC{XT}a6*e*5$6SooGSb+|%C!w!}<%w%Ic2kD`+G8^s^ z)#d1K^dYJJ5NW`*buWkjFjl_k1L7zs4gp*$#0=J}HoknoQIrDa^lY#i~(JD1?jr4{6Wq*{d)-e$rI#_@7=6w@E) zG~x;DdbgkuJ3_6~uSke_~B_+MXl z(-<%Sxz$671dFQ|MkBdB0ofEMeY;u~quELA4fe0r7tHc%u2N`3(SBbwWx>t$qu(RSq8U-;QRfJ--PE{i5N1#TSVS^<$S zOFO8@(^qCU{B@6{4s?mBm5`V+xG|I$Fje{yjWvqx9q_U-v}MnShtx@Ik?&I+shpe` z<|Q;XgIrE43u35ytcl^ z?latSUYw|kxAvo1D{4Y<5j<|~{p_zyfG~@S$vP7!v@q@;pG~N~k$?8=#lo=i?FC_9 zp*|onb&PJ>{H4q_tn1ftkNzCW^I0~4S&Wz7!w60IT0hS^EhgrSFYaHWq?J*@Vn}45 zfS@4lXD!^K>N!N$K~m=-!QSqe&O~L`Y#^%k&wApz(rZ+#FONNfug70(__WNBxuXF?LY*>Gi7{-5emVCTAoF8wdqZlk1Hu%rr4(><7sDP zL@E{_$kNrp3jvdFuA7@#-f{BDJemFPHwj}{fl=;W7ZMYa^fIY$+y4}s@q_?hvZ9v( z@cOO0Cv94nIaZgljM})4AO7Uj%-t-hofuyiyvzyzz)ur1)m)1lm664pKXa@0KK*C_ z@@l?E?Qpr!??QCi&yJ;vx7&pB8V3f0p>+A8gP)5_a%_qw6u-YdtjjiP!0=;#b?*{E zN3lr-!XWwZ7^}BE72|hbefXqy*^8_aL&7THv$3XeK>ap2nq)} zq{;CS2lqS7d(W0kM3a9ED8RU7CJq)5%#6KkjKM;FtPhGyJvj2>PubGHTDO{)KGY)E z3m-jW5mXZD_10%)2MRS!*sur>MzepXrIh}gFT8q{oGQ-;$Sdug!BWJ4v4A|J#TgY^ zwap%$xkDnd0;Gb5zg}`9{(4b52X$WIl|E6IyUiR-F-}##&F4nB`yCqG@4l7BNF)+5 z-OOXA50apZayHBf9>Vb$*&~0aTa2RgUe~k7+O_mqNtr?kWcf8tbaZvr^1BGK9+a&n|?BdbY`3eCZ1*kjA4_Qu!TwK0(b0DTgu4u=8K?7Ixh-&U z-%AmoY9ngRgE{_GkPbJKXbRX8tiBY{_yaa>f>a%M`n;;ts0qf4+eJ^<_7nOk0BHlv|k`EA-(pS;N4UeSe=&oqP$3nfs#sn*8n}6k##Y2IDYOm%UD( zIz7c20IHjS*1?`xGEms04HwBx$&=@^3XU?)i(LXkDdW zM)mWjuX0jS5$1N7x8aCwO23}rlsr(Lojg!p?h^zqLhWupwmUiia71+cK`}PqAho2_UszUeLb5Pw?t>50LBrh7&#AG;Wne@GgL= zoGx7<8GBL0>|_4_8gzj*z+VLkCx!0zxPWvl)-i zj)yx927d24Kq&;|)^}hr))>T>i7Qvh$WD}RzE?iC;$Jw2<$mI?M9OPh`vpQwKiJH) zjvnP+u*eMXSk)8EPz|6UngcS;xCqkgnqyVhj0xs zNZ<%Zqs#nKX~#uCk+4i#71M-w#U`dM*V@&{3)COd%#Vr?f{xhiWx+8cKu6GpN8gnr z4BYBjraeE`a$3xHJY`t;hvsP;&&j8@zwzb#SeNs_aIb@(9{b(!_Q)Z*(bo;Mvt#E< zz>Li^_f|<*50uAnEjjDC_NvF@&+1J2B0h_>BgvObfWmx6Ha@kby1hwU?8FsszBJoX z{;#5Qn6Kzqjg#j!G5v6KMdoP*kg69&|3jHzt&z!`_Dacx>!~d&C;dJHYUo1QQnywp z>pZ@DxG7>V9J&;J8})1YduQ zkpo7=Z2x!$Yuc|+PA;6f!YpYXuT#8TN89O%X6|O>B5gb45rlXgN@T9sueAY#3K z5^zjE-6BhEXkp*F@ARx4VB59WbMBwj6*imy=@|=*u6*#KoB0s5p8>PeyXKyh`^Y>@!saQd9?+2bVXCG7O$yC=cPxhJh)X*3)^#;ON ze(HH(Q02Aw>-`5kb9~*yUzR@y9i3=w_I7!~kq>vw`x?j}-1hN3qtes5Vd8C9-BMQ* z0o3V#;!#Gd{<{!yxa@L+=AhoJhJfggZ)erX`Ln5jv2lff^wja}jJ>L`s#^DoT!WxX zS*Suzk>R?gnP{k1SW;l2=V}hWw#8R@2TwTVUu(VVO;iZN&>jR*O2;H?G-3GKnI%qa z-Tki1+99)DS%kqSH=O;xIs{ow1NZd+I~kbZ%H&CWzHDa5YWVP z3P(yT9_qti`IjvRON)wsrV8nD*18jGv_sRJe0B@q@3Prmv(K9`3FR|d11abnK zpO@U<0y6kX2!#*eQh!eUN8;J`E0a@6^w5fo1?3?Pd3eT5U4$$~-8|vphcqv}VsM&# z&gn5HKn%b5RjpI!)^zRU+ zG4XrB=oP;esqX}kaRf`yTgf1o`#N=Ay;ZjjA5cx~6sf=UMP#M$o2e?4zp9?;b=25% zJ2ROlhHgAwj^hv{g`66u`)N%c9F?FK+OtJ|91X`F*Gp?fvHlk0*09c@B1mYh(#LZio-9gDVn+JqGOhNX zj4R=;4d1t1<$M{`&;?+!&$cE&%fvN`erv(*n1Y9Udn`I>>EXw;2kB{xB_beN+zoyQF=wD)&dhYH&Gq87=iu)caDQJh(BTD&6)y-s4$g zhVA=83i|TL8C(ek9G-u#oH(rNr6bPb^qg(L z&ZYC`EUyO|12U`VvTVV*h`1-a|2e%ZtK^k*V=sQjchrjX(0X>iEyf5$U%@BKA=D4xjMH4Fe6X zukQuhb6)IZy!e`_zwClp)l{c_)pJr0xisnxOHVpdOA{3LDv&Na=XLsFc|rMU`Aave zQqf1yrGG>bU`5JAeQ8C~qX(l-+V@vtlO7f~z4jVg z4uC9=*%1{)n_lDUM;Xp=kjb06rpvI6_g~~t{I<*g!(ZH?Z4?_>nO6T)CzyX3=$7@( JiW_&I{69Is5fcCa literal 0 HcmV?d00001 diff --git a/learn/.build/img/03-2.png b/learn/.build/img/03-2.png new file mode 100644 index 0000000000000000000000000000000000000000..6a38f687001f01f70c1f70197f201bec28d8d4c4 GIT binary patch literal 89804 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

rGuQOh13#!>yKQ24QuG5_v_9sc&bs*g^rk@72 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 本章目标:在不碰任何代码的前提下,让你彻底搞懂 **RosClaw 是什么、解决什么问题、整体怎么运作**。读完你会对整个项目有一张"心理地图",后面每一章都是往这张地图上填细节。 +> +> 面向**完全的新手**——不要求你懂 ROS2、不要求你会 TypeScript。遇到术语都会用大白话和类比解释。 + +--- + +## 一、一句话:RosClaw 是什么? + +**RosClaw 是一座"桥"——它让你能用聊天软件里的大白话,去指挥一个机器人。** + +```dot +digraph bridge { + rankdir=TB; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=12, margin="0.2,0.12"]; + edge [fontname="sans-serif", fontsize=10, color="#64748b"]; + + u [label="你在微信 / Telegram 里打字\n「向前走 1 米」", fillcolor="#dbeafe", color="#3b82f6"]; + ai [label="AI 读懂你的意思", fillcolor="#fef9c3", color="#eab308"]; + tr [label="自动翻译成机器人能听懂的指令", fillcolor="#fae8ff", color="#c026d3"]; + r [label="机器人真的向前走了 1 米 🚗", fillcolor="#dcfce7", color="#16a34a"]; + + u -> ai -> tr -> r; +} +``` + +不用学机器人编程、不用记复杂命令,**像聊天一样指挥机器人**——这就是 RosClaw 想做的事。 + +### 它到底解决了什么痛点? + +传统上要控制一个 ROS2 机器人,你得: + +- 在和机器人**同一个网络/同一台电脑**上; +- 安装一整套 ROS2 开发环境; +- 用命令行敲 `ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}"` 这种又长又容易写错的指令; +- 自己换算"向前 1 米"等于"速度 0.5 持续 2 秒"。 + +RosClaw 把这些**全部交给 AI 和插件代劳**。你只要会发微信消息,就能控制机器人——哪怕机器人在地球另一端。 + +--- + +## 二、三个必须先搞懂的概念 + +整个项目反复出现三个名词。先用大白话讲清楚,后面就不会懵。 + +### 概念 1:OpenClaw —— "AI 版的客服总机" + +**OpenClaw 是一个 AI 网关平台**。你可以把它想成一个**智能总机**: + +- 一头连着各种聊天软件(WhatsApp、Telegram、Discord、Slack,还有网页聊天框); +- 中间坐着一个 **AI 代理**(大脑是 Claude 这样的大模型),负责"听懂人话、决定该干什么"; +- 另一头连着各种**插件**,每个插件提供一组"它会干的活儿"(叫做"工具")。 + +> 类比:OpenClaw 像一家公司的**前台 + 调度中心**。顾客(你)打电话进来说需求,前台(AI)听懂后,派给对应的部门(插件)去办。 + +**RosClaw 就是 OpenClaw 的一个插件**——专门负责"和机器人打交道"这摊活儿。OpenClaw 本身不在这个仓库里,它是运行 RosClaw 的"宿主平台"。 + +### 概念 2:ROS2 —— "机器人世界的操作系统" + +**ROS2(Robot Operating System 2)是机器人软件的通用框架**。几乎所有研究和工业机器人都用它。你不需要会写 ROS2 程序,但要听懂下面 4 个词,因为 RosClaw 干的事全是围绕它们: + +| 词 | 大白话 | 例子 | +|---|---|---| +| **节点(Node)** | 一个独立运行的小程序 | "摄像头节点"、"导航节点"、"电机节点" | +| **话题(Topic)** | 一条持续广播的"频道",发布者往里发、订阅者从里收 | `/cmd_vel`(速度指令频道)、`/odom`(位置反馈频道) | +| **服务(Service)** | 一问一答的调用,发一个请求等一个回复 | "告诉我当前电量是多少?" | +| **动作(Action)** | 一个耗时任务,过程中能持续汇报进度 | "导航到门口"(要走一会儿,途中不断报"还剩 3 米") | + +再加一个底层名词: + +- **DDS**:ROS2 内部传递消息用的"快递系统"(一种高效的二进制通信协议)。机器人内部节点之间靠它互相发消息。 + +> 类比:把机器人想成一家工厂。**节点**是各个车间;**话题**是厂区广播("所有人注意,开始干活");**服务**是打电话问某车间"现在产量多少";**动作**是派一个长期任务并要求定时汇报;**DDS** 是厂内的内部邮路。 + +### 概念 3:rosbridge —— "给机器人装的一扇翻译窗口" + +这里有个现实难题:ROS2 内部用 **DDS**(二进制)通信,而我们的插件是用 **TypeScript/Node.js** 写的,**两者语言不通**——Node.js 没法直接说 DDS。 + +**rosbridge** 就是解决这个的官方组件。它给 ROS2 开了一扇"翻译窗口":把 ROS2 的 DDS 通信**封装成 WebSocket + JSON**——而 JSON 和 WebSocket 是任何编程语言(包括 Node.js)都会的"普通话"。 + +```dot +digraph rosbridge { + rankdir=LR; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=12, margin="0.25,0.15"]; + edge [fontname="sans-serif", fontsize=10, color="#475569"]; + + ts [label="TypeScript 插件\n(会说 JSON)", fillcolor="#dbeafe", color="#3b82f6"]; + rb [label="rosbridge_server\n(双语翻译官)", fillcolor="#fae8ff", color="#c026d3"]; + ros [label="ROS2 节点\n(只懂 DDS)", fillcolor="#dcfce7", color="#16a34a"]; + + ts -> rb [label="发 JSON 文本\n(WebSocket)"]; + rb -> ros [label="翻译成 DDS"]; +} +``` + +> 类比:你(只会中文)要和一位只会法语的工程师沟通,中间请了个**翻译**(rosbridge)。你说中文,翻译转成法语;对方回法语,翻译转回中文。rosbridge 就是 ROS2 和外部世界之间的这位翻译。 + +一条典型的"rosbridge 普通话"长这样(让机器人前进): + +```json +{ + "op": "publish", + "topic": "/cmd_vel", + "msg": { "linear": { "x": 0.5, "y": 0, "z": 0 }, "angular": { "x": 0, "y": 0, "z": 0 } } +} +``` + +`op` 字段表明"这是一个发布操作",后面是发到哪个话题、发什么内容。记住这个 `op` 格式,它会贯穿整个项目。 + +--- + +## 三、整体架构:一句话 + 一张图 + +把上面三个概念串起来,就是 RosClaw 的全貌: + +```dot +digraph overview { + rankdir=LR; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=12, margin="0.22,0.14"]; + edge [fontname="sans-serif", fontsize=9, color="#475569"]; + + msg [label="消息应用", fillcolor="#dbeafe", color="#3b82f6"]; + oc [label="OpenClaw\n(AI 代理)", fillcolor="#fef9c3", color="#eab308"]; + plg [label="RosClaw 插件\n★ 本仓库主角", fillcolor="#fae8ff", color="#c026d3", penwidth=2]; + rb [label="rosbridge", fillcolor="#ffedd5", color="#ea580c"]; + ros [label="ROS2 机器人", fillcolor="#dcfce7", color="#16a34a"]; + + msg -> oc [label="①协议转换"]; + oc -> plg [label="②人话→工具调用"]; + plg -> rb [label="③工具→JSON"]; + rb -> ros [label="④JSON→DDS"]; +} +``` + +**每一个箭头,都是一次"翻译"**(信息换了一种形态,但意思不变): + +| # | 这一步 | 把"什么"翻译成"什么" | 谁干的 | +|---|---|---|---| +| 1 | 消息应用 → OpenClaw | 微信/Telegram 协议 → OpenClaw 内部消息 | OpenClaw | +| 2 | OpenClaw → 插件 | 自然语言「向前走」→ 一次"工具调用" | AI 代理 | +| 3 | 插件 → rosbridge | 工具调用 → rosbridge 的 JSON | RosClaw 插件 | +| 4 | rosbridge → ROS2 | JSON → DDS 二进制消息 | rosbridge_server | + +> 整个项目的"主角"是第 3 步——**RosClaw 插件**。它是这个仓库里你将要读的几乎所有代码。第 1、4 步是别人(OpenClaw、rosbridge)干的,第 2 步是 AI 干的。 + +如果你想**立刻看到这条链路在代码里逐行是怎么走的**(精确到哪个文件、哪个函数、第几行),有一篇专门的"流程主线"文档:[code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)。建议读完本系列前几章后去看,它会把这张抽象的图变成真实的代码地图。 + +--- + +## 四、三种部署模式:机器人离你多远? + +RosClaw 支持三种连接机器人的方式,对应"机器人离你有多远"的三种情况: + +| 模式 | 场景 | 怎么连 | 什么时候用 | +|---|---|---|---| +| **模式 A:同机** | OpenClaw 和机器人在**同一台电脑** | 直接走 ROS2 DDS,不经过网络 | 延迟最低;机器人自带的板载电脑 | +| **模式 B:局域网** | OpenClaw 在你笔记本,机器人在**同一个 WiFi** | 通过 rosbridge 的 WebSocket | **新手首选**,开发调试最方便 | +| **模式 C:云端** | OpenClaw 在云服务器,机器人在**远端**(家里/野外) | 通过 WebRTC 穿透网络 | 远程遥控,跨地域 | + +> 💡 **新手建议先用模式 B**:在自己电脑上用 Docker 跑一个 ROS2 仿真机器人,再让插件通过 rosbridge 连上它。不需要真机器人,零成本就能体验完整流程。具体步骤见 [第五章:完整实战教程](05-完整实战教程.md)。 + +这三种模式在代码里对应三套"传输层"实现,但它们**对上层提供完全一样的接口**——这是 RosClaw 一个很漂亮的设计,后面 [第三章](03-架构深度解析.md) 会细讲。 + +> 📌 **关于实现状态(诚实说明)**:项目文档曾把模式 A(local)和模式 C(webrtc)标为"存根/未实现"。但实际逐行读代码会发现,**模式 C 其实是相当完整的实现**(真的用了 WebRTC 库)。这种"文档和代码不一致"的情况,我们在逐行详解里会如实指出(见 [code/30](code/30-webrtc-transport.ts.md) 的勘误)。**读代码时以代码为准**——这本身就是一项重要的读码功夫。目前可以确定:**模式 B(rosbridge)是默认且最完整的**,新手就用它。 + +--- + +## 五、仓库长什么样?(目录巡览) + +这是一个 **monorepo**(多个相关项目放在同一个仓库里)。主要目录: + +``` +rosclaw/ +├── extensions/ ← 【主角】OpenClaw 插件(TypeScript) +│ ├── openclaw-plugin/ ← 核心插件:控制机器人 +│ │ ├── src/ ← 你要读的源代码几乎都在这 +│ │ │ ├── index.ts ← 插件入口 +│ │ │ ├── config.ts ← 配置定义 +│ │ │ ├── tools/ ← AI 能调用的 8 个工具 +│ │ │ ├── transport/ ← 三种传输层(A/B/C 模式) +│ │ │ ├── safety/ ← 安全校验 +│ │ │ ├── context/ ← 把机器人能力告诉 AI +│ │ │ └── commands/ ← 直接命令(如 /estop 急停) +│ │ └── skills/ ← 多步"技能"(导航、拍照……) +│ └── openclaw-canvas/ ← 第二个插件:实时仪表盘(占位中) +├── ros2_ws/src/ ← ROS2 这一侧的 Python 包 +│ ├── rosclaw_discovery/ ← "能力自动发现"节点 +│ ├── rosclaw_msgs/ ← 自定义消息/服务类型 +│ └── rosclaw_agent/ ← 模式 C 机器人侧节点 +├── docker/ ← 一键启动仿真环境 +├── examples/ ← 三个场景示例 +├── docs/ ← 架构文档 +└── learn/ ← 【你在这里】新手学习资料 +``` + +**90% 的代码阅读都集中在 `extensions/openclaw-plugin/src/`**。其余目录(ROS2 包、Docker、示例)是配套。 + +--- + +## 六、三个现成示例 + +仓库自带三个示例场景,展示 RosClaw 能干什么: + +| 示例 | 路径 | 说明 | +|---|---|---| +| 小车聊天控制 | `examples/turtlebot-chat/` | 用聊天消息控制 Gazebo 仿真里的 TurtleBot3 小车 | +| 机械臂控制 | `examples/arm-control/` | 用自然语言让机械臂做动作 | +| 多机巡逻 | `examples/fleet-patrol/` | 指挥多个机器人执行巡逻任务 | + +--- + +## 七、新手术语小词典 + +读后面章节前,把这张表存着,遇到生词回来查: + +| 术语 | 一句话解释 | +|---|---| +| OpenClaw | AI 网关平台,RosClaw 运行在它上面(宿主) | +| 插件(plugin) | 给 OpenClaw 增加能力的扩展,RosClaw 就是一个插件 | +| 工具(tool) | 插件提供给 AI 调用的一个具体动作(如"发布话题") | +| ROS2 | 机器人软件框架 | +| 话题 / 服务 / 动作 | ROS2 的三种通信方式:广播频道 / 一问一答 / 耗时任务带进度 | +| DDS | ROS2 内部的二进制通信协议 | +| rosbridge | 把 ROS2 的 DDS 封装成 WebSocket+JSON 的翻译组件 | +| WebSocket | 一种保持长连接、可双向实时收发的网络协议 | +| 传输层(transport) | 插件里负责"实际和机器人通信"的那一层,有 A/B/C 三种实现 | +| 仿真(simulation) | 用软件(Gazebo)模拟一个机器人,不用真硬件 | + +--- + +## 八、接下来读什么 + +你已经有了全局地图。根据你的目的选择下一步: + +- **想继续打基础**(推荐按顺序)→ [第二章:技术前置知识](02-技术前置知识.md):学这个项目会碰到哪些技术、每项最少要懂多少。 +- **想理解它内部怎么搭起来的** → [第三章:架构深度解析](03-架构深度解析.md)。 +- **想立刻动手跑起来** → [第五章:完整实战教程](05-完整实战教程.md):手把手让仿真机器人动起来。 +- **想钻进源码** → [第四章:核心代码导读](04-核心代码导读.md),及配套的 [code/ 逐行详解系列](code/README.md)。 + +**下一步** → [第二章:技术前置知识](02-技术前置知识.md) diff --git "a/learn/02-\346\212\200\346\234\257\345\211\215\347\275\256\347\237\245\350\257\206.md" "b/learn/02-\346\212\200\346\234\257\345\211\215\347\275\256\347\237\245\350\257\206.md" new file mode 100644 index 0000000..baa82f5 --- /dev/null +++ "b/learn/02-\346\212\200\346\234\257\345\211\215\347\275\256\347\237\245\350\257\206.md" @@ -0,0 +1,218 @@ +# 第二章:技术前置知识 + +> 本章把"读懂 RosClaw 会用到的技术"逐项过一遍。每一项都按同一个套路讲:**是什么(类比)→ 项目为什么用它 → 在项目哪个位置 → 你最少要懂多少 → 想深入看哪篇**。 +> +> **重要心态**:你**不需要精通**这些技术。本章的目的是让你"认得出、不害怕",而不是"全学会"。真正想钻语法细节时,[code/ 逐行详解系列](code/README.md) 会用「语法小课堂」一点点补课。 + +--- + +## 怎么用这一章 + +下面把技术分成两档: + +- **必须有个印象**(TypeScript、ROS2 概念、rosbridge、Docker)——不懂会卡。 +- **知道有这回事就行**(Zod、TypeBox、pnpm、WebSocket、WebRTC)——用到时回来查。 + +每项末尾的"深入看哪篇"指向 code/ 系列,等你读源码时按图索骥即可。 + +--- + +## 第一档:必须有个印象 + +### 1. TypeScript / Node.js / ESM + +**是什么**: + +- **Node.js** 是"让 JavaScript 能在电脑上(而非只在浏览器里)运行"的运行环境。 +- **TypeScript(简称 TS)** 是给 JavaScript "加了类型标注"的升级版。所谓"类型",就是给每个数据贴个标签说明"它长什么样"(是文字 `string`、数字 `number`,还是某种对象)。加了类型,**很多错误在写代码时就能被发现**,而不是等运行才崩。 +- **ESM**(ES Modules)是 JS 现代的"模块化"方式——用 `import`/`export` 在文件之间互相借用代码。 + +> 类比:JavaScript 像随手写的便条,写错了没人管;TypeScript 像填表格,每个格子规定了"只能填数字"或"只能填日期",填错当场提示。 + +**项目为什么用**:整个插件层要求类型安全、现代化,所以全用 TS + ESM(`"type": "module"`,严格模式)。 + +**在项目哪里**:`extensions/` 下所有 `.ts` 文件。 + +**最少要懂**:认识三样就够起步—— +- `interface` / `type`:定义"一个对象长什么样"。 +- 泛型 ``:给类型留个"待填的空"(如"一个装 T 的数组")。 +- `import` / `export`:文件间借用代码。 + +> 💡 一个会让新手困惑的小细节:项目里 `import` 别的文件时写的是 `.js` 后缀(如 `from "./config.js"`),但文件明明是 `.ts`。这是 ESM 的规矩,[code/01](code/01-plugin-api.ts.md) 会解释。 + +**深入看哪篇**:[code/01 · plugin-api.ts](code/01-plugin-api.ts.md) 从最基础的"什么是类型/接口"讲起,是整个语法学习的起点。 + +### 2. ROS2 的四个核心概念 + +**是什么**:机器人软件框架。详见 [第一章](01-项目概览.md) 的讲解,这里只复述要记的四个词 + 一个底层词: + +| 概念 | 大白话 | 典型例子 | +|---|---|---| +| 节点 Node | 一个独立运行的小程序 | 摄像头节点、导航节点 | +| 话题 Topic | 持续广播的频道(发布/订阅) | `/cmd_vel` 速度、`/odom` 里程计 | +| 服务 Service | 一问一答 | "当前电量?" | +| 动作 Action | 耗时任务 + 进度反馈 | "导航到目标点" | +| DDS | ROS2 内部的二进制通信协议 | (底层,看不见) | + +**项目为什么用**:RosClaw 做的所有事,本质都是"对这四种东西做操作"——发布话题、订阅话题、调服务、发动作。 + +**在项目哪里**:`src/tools/` 下的 8 个工具,正好一一对应这些操作(发布、订阅、调服务、发动作、读写参数、列话题、拍照)。 + +**最少要懂**:能区分"话题 vs 服务 vs 动作"——这决定了用哪个工具。一句话记忆: +- 持续广播的数据(速度、传感器)→ **话题**; +- 一次性问答(查个值、设个参数)→ **服务**; +- 要等一会儿、还想看进度(导航、抓取)→ **动作**。 + +还要知道**消息类型**:每个话题的数据都有固定格式,如 `geometry_msgs/msg/Twist`(速度)含 `linear.x`(前进)和 `angular.z`(转向)字段。 + +**深入看哪篇**:[code/03 · transport-types.ts](code/03-transport-types.ts.md)(这些概念在代码里如何用类型表达)。 + +### 3. rosbridge 协议(WebSocket + JSON) + +**是什么**:把 ROS2 的 DDS 通信"翻译"成 WebSocket + JSON 的官方组件(详见 [第一章](01-项目概览.md) 的"翻译窗口"类比)。 + +**项目为什么用**:因为 Node.js 不会说 DDS,但人人都会 JSON。rosbridge 让我们的 TS 插件能通过发 JSON 文本来操作 ROS2。这是**模式 B(默认模式)**的基础。 + +**在项目哪里**:`src/transport/rosbridge/` 整个目录。 + +**最少要懂**:rosbridge 消息就是带 `op` 字段的 JSON。认识这几个 `op`: + +```json +// 发布(让机器人动) +{"op": "publish", "topic": "/cmd_vel", "msg": { ... }} + +// 订阅(持续接收某话题) +{"op": "subscribe", "topic": "/odom"} + +// 调用服务(一问一答) +{"op": "call_service", "service": "/rosapi/topics"} +``` + +了解它对**调试**很有用——出问题时你能看懂插件到底发了什么。[第五章](05-完整实战教程.md) 还会教你亲手发这些 JSON。 + +**深入看哪篇**:[code/05 · rosbridge-types.ts](code/05-rosbridge-types.ts.md)(协议全部消息的类型定义)+ [code/06 · client.ts](code/06-rosbridge-client.ts.md)(怎么收发,最核心)。 + +### 4. Docker / Docker Compose + +**是什么**: + +- **Docker** 把一个软件 + 它需要的所有环境**打包成一个"集装箱"(容器)**,在任何电脑上都能一致地跑起来,不用自己装一堆依赖。 +- **Docker Compose** 用一个 `.yml` 文件**一次性启动多个容器**并把它们连起来。 + +> 类比:Docker 像"即食餐盒"——菜、米、调料都配好封装,加热即食,不用自己买菜。Docker Compose 则是"一桌套餐",一道命令上齐好几个餐盒。 + +**项目为什么用**:让你**不装 ROS2** 就能跑起一个完整的仿真机器人环境——这是新手能零成本体验的关键。 + +**在项目哪里**:`docker/` 目录。注意有好几个 compose 文件,对应不同部署模式: + +``` +docker/ +├── docker-compose.yml # 默认(模式 B):ROS2 仿真 + rosbridge +├── docker-compose.dev.yml # 开发模式(挂载源码) +├── docker-compose.local.yml # 模式 A:同机 +├── docker-compose.robot.yml # 模式 C:机器人侧 +├── docker-compose.cloud.yml # 模式 C:云侧 +└── docker-compose.sim.yml # 纯仿真 +``` + +**最少要懂**:会用两条命令——`docker compose up`(启动)和 `docker compose down`(停止)。注意是 `docker compose`(带空格,新语法),不是老的 `docker-compose`。 + +**深入看哪篇**:[第五章:完整实战教程](05-完整实战教程.md) 会手把手带你用 Docker 跑起仿真。 + +--- + +## 第二档:知道有这回事就行 + +### 5. pnpm 与 Monorepo(工作空间) + +**是什么**:`pnpm` 是个更快、更省磁盘的包管理器(类似 `npm`)。**workspace(工作空间)**让一个仓库里管多个包。 + +**项目为什么用**:仓库里有两个包(`openclaw-plugin` 和 `openclaw-canvas`),用 pnpm workspace 统一管理。 + +**最少要懂**两条命令: + +```bash +pnpm install # 一次性装好所有包的依赖 +pnpm typecheck # 对所有包做类型检查(验证代码类型没写错) +``` + +### 6. Zod(配置校验) + +**是什么**:一个"运行时检查数据形状对不对"的库。你定义一个 schema(数据该长什么样),它就能在程序运行时验证真实数据、填默认值。 + +**项目为什么用**:用户写的插件配置(连哪个机器人、速度上限多少)可能写错或缺项。Zod 在插件启动时**校验一次**,把缺的填上默认值,把错的报出来。 + +**在项目哪里**:`src/config.ts`(开头就是 `import { z } from "zod"`)。 + +**深入看哪篇**:[code/02 · config.ts](code/02-config.ts.md)。 + +### 7. TypeBox(工具参数定义)—— 注意别和 Zod 搞混 + +**是什么**:另一个定义"数据形状"的库,但用途不同——它生成的是 **AI 能读懂的参数说明(JSON Schema)**。 + +**项目为什么用 + 和 Zod 的区别**(新手很容易混淆,记住这条): + +| | Zod | TypeBox | +|---|---|---| +| 用来定义 | **插件配置**的形状 | **工具参数**的形状 | +| 给谁看 | 给程序校验用户配置 | 给 **AI** 看"这个工具要传什么参数" | +| 在哪 | `src/config.ts` | `src/tools/*.ts` 每个工具里 | + +**深入看哪篇**:[code/14 · 工具通用结构](code/14-tools-index.ts.md)、[code/15 · ros2-publish.ts](code/15-ros2-publish.ts.md)。 + +### 8. WebSocket + +**是什么**:一种网络协议,特点是**建立一次连接后,双方可以持续、双向、实时地互发消息**(不像普通网页那样"问一次答一次就断")。 + +**项目为什么用**:rosbridge 用 WebSocket 通信。机器人的传感器数据需要持续推送,正适合 WebSocket 这种长连接。 + +**在项目哪里**:`src/transport/rosbridge/client.ts` 用 `ws` 库连 WebSocket。 + +**深入看哪篇**:[code/06 · client.ts](code/06-rosbridge-client.ts.md)。 + +### 9. WebRTC 与 DDS(仅模式 A / C,可跳过) + +**是什么**: + +- **WebRTC**:浏览器/设备之间**点对点直连**传输数据的技术,能穿透防火墙/NAT。模式 C 用它来连远端机器人。 +- **直连 DDS**:模式 A 在同一台机器上时,跳过 rosbridge,直接说 ROS2 的"母语"DDS(通过 `rclnodejs` 库)。 + +**项目为什么用**:覆盖"机器人在远端"(C)和"在同机"(A)两种场景。 + +**在项目哪里**:`src/transport/webrtc/` 和 `src/transport/local/`。 + +> 新手可以**完全跳过**这两块,先掌握模式 B。等熟练了再回来。 + +**深入看哪篇**:[code/26 · local-transport.ts](code/26-local-transport.ts.md)(模式 A)、[code/29–31](code/29-webrtc-signaling-types.ts.md)(模式 C)。 + +--- + +## 技术 → 深入篇目 速查表 + +| 技术 | 在项目哪里 | 深入看哪篇 code/ | +|---|---|---| +| TypeScript 基础 | 所有 `.ts` | [01](code/01-plugin-api.ts.md) | +| Zod 配置校验 | `config.ts` | [02](code/02-config.ts.md) | +| ROS2 概念建模 | `transport/types.ts` | [03](code/03-transport-types.ts.md) | +| 传输层抽象接口 | `transport/transport.ts` | [04](code/04-transport.ts.md) | +| rosbridge 协议 | `transport/rosbridge/types.ts` | [05](code/05-rosbridge-types.ts.md) | +| WebSocket 收发 | `transport/rosbridge/client.ts` | [06](code/06-rosbridge-client.ts.md) 🔥 | +| TypeBox 工具参数 | `tools/*.ts` | [14](code/14-tools-index.ts.md)、[15](code/15-ros2-publish.ts.md) | +| 模式 A(DDS 直连) | `transport/local/` | [26–28](code/26-local-transport.ts.md) | +| 模式 C(WebRTC) | `transport/webrtc/` | [29–31](code/29-webrtc-signaling-types.ts.md) | + +--- + +## 快速自测 + +读完本章,能用大白话回答这几个问题就可以继续了(答不上就回看对应小节): + +1. ROS2 的"话题"和"服务"有什么区别?什么时候用哪个? +2. 为什么需要 rosbridge?它解决了 Node.js 的什么困难? +3. Zod 和 TypeBox 都在定义"数据形状",它们的用途有什么不同? +4. `docker compose up` 是用来干什么的? +5. 三种部署模式里,新手该先用哪个?为什么? + +--- + +**下一步** → [第三章:架构深度解析](03-架构深度解析.md):把这些技术拼起来,看 RosClaw 内部是怎么搭的。 diff --git "a/learn/03-\346\236\266\346\236\204\346\267\261\345\272\246\350\247\243\346\236\220.md" "b/learn/03-\346\236\266\346\236\204\346\267\261\345\272\246\350\247\243\346\236\220.md" new file mode 100644 index 0000000..de9be88 --- /dev/null +++ "b/learn/03-\346\236\266\346\236\204\346\267\261\345\272\246\350\247\243\346\236\220.md" @@ -0,0 +1,329 @@ +# 第三章:架构深度解析 + +> 本章目标:把 RosClaw 插件**内部是怎么搭起来的**讲透——分几层、每层干什么、谁先谁后、为什么这么设计。 +> +> [第一章](01-项目概览.md) 给了全局地图,本章把镜头推近到"插件内部"。读完你会理解四种核心机制(工具/钩子/命令/传输层),这是看懂源码的骨架。 +> +> 想看一条命令在代码里**逐行、逐函数**怎么流动?那是配套的 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)——本章讲"结构",那篇讲"流动",互为表里。 + +--- + +## 一、三层架构:全局视角 + +整个系统从上到下分三层,职责清晰: + +

+ +
RosClaw 三层架构
+
+
+
① 消息层(用户接口)
+
WhatsApp
Telegram
Discord
网页聊天
+
+
▼   自然语言消息
+
+
② AI 网关层(OpenClaw) ← RosClaw 插件挂在这里
+
AI 代理
理解意图
RosClaw 插件
工具 / 钩子 / 命令
决定调用哪个工具
+
+
▼   rosbridge JSON / DDS / WebRTC
+
+
③ ROS2 层(机器人)
+
/cmd_vel
速度
/odom
里程计
/camera
图像
+
+
电机
摄像头
传感器
+
+
+
+ +- **消息层**:用户从哪进来。RosClaw 不管这层(OpenClaw 管)。 +- **AI 网关层**:OpenClaw + 大模型。**RosClaw 插件就挂在这一层**,给 AI 提供"和机器人打交道的能力"。 +- **ROS2 层**:真正的机器人(或仿真)。 + +**RosClaw 插件是连接 AI 层和 ROS2 层的桥。** 本章剩下的内容都在拆解这座桥。 + +--- + +## 二、插件内部:六个模块 + +RosClaw 插件的全部代码在 `extensions/openclaw-plugin/src/`,分成六块: + +``` +src/ +├── index.ts ← 入口:把下面所有模块"注册"起来 +├── config.ts ← 配置定义(Zod 校验) +├── plugin-api.ts ← OpenClaw 平台的 API 类型声明("合同") +├── service.ts ← 传输层连接的生命周期管理(单例) +├── tools/ ← AI 工具(8 个) +├── transport/ ← 传输层(3 种模式) +├── safety/ ← 安全校验钩子 +├── context/ ← 机器人能力注入钩子 +└── commands/ ← 直接命令(/estop、/transport) +``` + +把它们的关系画出来: + +```dot +digraph modules { + rankdir=TB; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=11, margin="0.18,0.1"]; + edge [color="#94a3b8", arrowsize=0.8]; + + idx [label="index.ts\n(入口,装配所有模块)", fillcolor="#fef9c3", color="#eab308", penwidth=2]; + + cfg [label="config.ts\n(配置)", fillcolor="#f1f5f9", color="#94a3b8"]; + svc [label="service.ts\n(连接)", fillcolor="#dbeafe", color="#3b82f6"]; + tls [label="tools/\n(工具)", fillcolor="#dcfce7", color="#16a34a"]; + hk [label="safety/ + context/\n(钩子)", fillcolor="#fae8ff", color="#c026d3"]; + cmd [label="commands/\n(命令)", fillcolor="#ffedd5", color="#ea580c"]; + + tp [label="transport/\n(传输层:rosbridge / local / webrtc)", fillcolor="#e0e7ff", color="#6366f1"]; + ros [label="ROS2 机器人", shape=ellipse, fillcolor="#f0fdf4", color="#16a34a"]; + + idx -> cfg; + idx -> svc; + idx -> tls; + idx -> hk; + idx -> cmd; + svc -> tp; + tp -> ros; +} +``` + +- `config.ts`、`plugin-api.ts` 是**地基**(配置和类型契约)。 +- `service.ts` + `transport/` 是**连接管道**(怎么和机器人通信)。 +- `tools/`、`safety/`、`context/`、`commands/` 是**四种对接 AI 的方式**(下一节细讲)。 + +--- + +## 三、真实启动顺序:`register()` 做了什么 + +插件被 OpenClaw 加载时,会调用入口 `index.ts` 里的 `register()` 函数。这是**真实代码**(不是伪代码),按顺序做 7 件事: + +```typescript +register(api) { + api.logger.info("RosClaw plugin loading..."); + const config = parseConfig(api.pluginConfig ?? {}); // 0. 解析+校验配置 + + registerService(api, config); // 1. 建立 ROS2 连接(传输层) + registerTools(api); // 2. 注册 8 个 AI 工具 + registerSafetyHook(api, config); // 3. 注册安全校验钩子 + registerRobotContext(api, config); // 4. 注册机器人能力注入钩子 + registerEstopCommand(api, config); // 5. 注册 /estop 命令 + registerTransportCommand(api, config); // 6. 注册 /transport 命令 + + api.logger.info("RosClaw plugin loaded successfully"); +} +``` + +**这 7 步就是插件的完整生命周期起点。** 注意两个要点: + +1. **顺序有讲究**:`registerService` 必须第一个——先把"和机器人的连接"建好,后面的工具、钩子、命令才有连接可用。 +2. **"注册" ≠ "执行"**:`register` 阶段只是**登记**("这里有个工具叫 X""门口安排个安全员"),并不真的去控制机器人。真正干活是等用户消息进来、相应的零件被触发时。 + +> 🔍 逐行详解:[code/13 · index.ts](code/13-index.ts.md)。 + +--- + +## 四、四种对接 AI 的方式(核心机制) + +这是理解 RosClaw 的**最关键一节**。插件和 OpenClaw/AI 打交道,一共四种方式,时机和用途各不相同: + +| 方式 | 谁触发 | 何时 | 返回什么 | 例子 | +|---|---|---|---|---| +| **工具 Tool** | **AI** 主动调用 | AI 决定要做某动作时 | `content` + `details` | `ros2_publish` 发速度 | +| **钩子 Hook(工具前)** | 宿主自动 | 每次工具执行**前** | `{block, blockReason}` 或放行 | 安全校验拦超速 | +| **钩子 Hook(会话前)** | 宿主自动 | 每次 AI 会话**开始前** | `{prependContext}` | 注入机器人能力清单 | +| **命令 Command** | **用户**直接打 | 用户输入 `/xxx` 时 | `{text}` | `/estop` 急停 | + +逐一说明: + +### 4.1 工具(tools/)—— AI 的"动作按钮" + +AI 不能直接碰机器人,它只能**调用插件注册的工具**。每个工具是一个"AI 能按的按钮",带: +- 名字(`ros2_publish`); +- 给 AI 看的说明(`description`,相当于提示词,决定 AI 何时用它); +- 参数定义(TypeBox schema,告诉 AI 要传什么); +- 执行逻辑(`execute`,真正干活)。 + +RosClaw 注册了 **8 个工具**: + +| 文件 | 工具名 | 功能 | 对应 ROS2 操作 | +|---|---|---|---| +| `ros2-publish.ts` | `ros2_publish` | 向话题发布消息 | 话题(发布) | +| `ros2-subscribe.ts` | `ros2_subscribe_once` | 读取话题最新一条消息 | 话题(订阅) | +| `ros2-service.ts` | `ros2_service_call` | 调用服务 | 服务 | +| `ros2-action.ts` | `ros2_action_goal` | 发送动作目标 | 动作 | +| `ros2-param.ts` | `ros2_param_get` / `ros2_param_set` | 读/写节点参数 | 服务(参数) | +| `ros2-introspect.ts` | `ros2_list_topics` | 列出可用话题 | 服务(自省) | +| `ros2-camera.ts` | `ros2_camera_snapshot` | 截取摄像头图像 | 话题(订阅图像) | + +> 🔍 逐行详解:[code/14 · 工具通用结构](code/14-tools-index.ts.md),及 [code/15–21](code/15-ros2-publish.ts.md) 各工具。 + +### 4.2 钩子(safety/ 和 context/)—— 自动触发的"拦截 / 注入" + +钩子是**宿主在特定时机自动触发**的函数(不是 AI 或用户主动调)。RosClaw 用了两个: + +- **`before_tool_call`(工具执行前)—— 安全校验** `safety/validator.ts` + 每次工具即将执行前触发。检查 `ros2_publish` 的速度是否超限,超了就返回 `{ block: true, blockReason }` **拦下**,否则放行。这保证了"AI 再怎么乱来也突破不了物理安全上限"。 + +- **`before_agent_start`(AI 会话开始前)—— 能力注入** `context/robot-context.ts` + 每次对话开始前触发。它去查机器人当前有哪些话题/服务/动作,拼成一段文字,通过 `{ prependContext }` **塞进 AI 的系统提示**——这样 AI 才知道这台机器人"能干什么、该往哪个话题发"。还带 60 秒缓存和失败降级。 + +> 🔍 逐行详解:[code/22 · validator.ts](code/22-safety-validator.ts.md)、[code/23 · robot-context.ts](code/23-robot-context.ts.md)。 + +### 4.3 命令(commands/)—— 用户直接打的 `/xxx` + +命令**绕过 AI**,由用户直接输入触发,返回的 `{ text }` 直接显示给用户。RosClaw 有两个: + +- **`/estop`(紧急停止)** `commands/estop.ts` + 直接向 `/cmd_vel` 发零速度,**完全绕过 AI 推理**——紧急情况下不能等 AI 慢慢想,必须即时响应。 +- **`/transport`(运行时切换传输模式)** `commands/transport.ts` + 在 rosbridge/local/webrtc 之间热切换,不重启插件。 + +> 🔍 逐行详解:[code/24 · estop.ts](code/24-commands-estop.ts.md)、[code/25 · transport.ts](code/25-commands-transport.ts.md)。 + +--- + +## 五、传输层:RosClaw 最漂亮的设计 + +工具调 `transport.publish(...)` 时,**根本不知道底层用的是 WebSocket 还是 WebRTC**——因为传输层用了**适配器模式**把三种通信方式的差异藏了起来。 + +```dot +digraph transport { + rankdir=TB; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=11, margin="0.2,0.11"]; + edge [color="#94a3b8", fontname="sans-serif", fontsize=9]; + + callers [label="工具 / 命令 / 上下文", fillcolor="#dcfce7", color="#16a34a"]; + gt [label="getTransport()\nservice.ts 管理的唯一连接(单例)", fillcolor="#dbeafe", color="#3b82f6"]; + iface [label="统一接口 RosTransport(13 个方法)\npublish / subscribe / callService / ...", shape=box, style="filled", fillcolor="#e0e7ff", color="#6366f1", penwidth=2]; + + rb [label="RosbridgeTransport\n(模式 B,默认)", fillcolor="#f0fdf4", color="#16a34a"]; + lo [label="LocalTransport\n(模式 A)", fillcolor="#fff7ed", color="#ea580c"]; + wr [label="WebRTCTransport\n(模式 C)", fillcolor="#fae8ff", color="#c026d3"]; + + ros [label="ROS2 机器人", shape=ellipse, fillcolor="#f1f5f9", color="#64748b"]; + + callers -> gt [label="调 publish() 等"]; + gt -> iface; + iface -> rb [label="WebSocket"]; + iface -> lo [label="DDS 直连"]; + iface -> wr [label="WebRTC"]; + rb -> ros; + lo -> ros; + wr -> ros; +} +``` + +**关键设计点**: + +1. **统一接口 `RosTransport`**(`transport/transport.ts`):定义了 13 个方法(publish/subscribe/callService/sendActionGoal/listTopics……)。这是一份"承诺清单"。 +2. **三种实现都履行这份承诺**:每种模式写一个类去 `implements RosTransport`。上层只认接口、不认具体类。 +3. **工厂按配置选实现**(`transport/factory.ts`): + +```typescript +// 伪代码示意(真实现是动态 import 懒加载) +async function createTransport(config) { + switch (config.mode) { + case "rosbridge": return new RosbridgeTransport(config.rosbridge); + case "local": return new LocalTransport(config.local); + case "webrtc": return new WebRTCTransport(config.webrtc); + } +} +``` + +**这么设计的好处**:想换通信方式,只改工厂里 `new` 哪个类,**上层工具一行都不用动**。这就是"面向接口编程、实现可替换"。 + +> 📌 **实现状态(诚实说明)**:默认的 `rosbridge`(模式 B)是最完整的实现。`local`(A)和 `webrtc`(C)在项目文档里曾被标为"存根",但逐行读代码发现 **模式 C 其实是完整实现**(真用了 WebRTC 库)。详见 [code/30 的勘误](code/30-webrtc-transport.ts.md)。**以代码为准。** + +> 🔍 逐行详解:接口 [code/04](code/04-transport.ts.md)、工厂 [code/11](code/11-transport-factory.ts.md)、单例管理 [code/12](code/12-service.ts.md)、rosbridge 实现 [code/05–10](code/05-rosbridge-types.ts.md)。 + +--- + +## 六、能力自动发现:让 AI "认识"这台机器人 + +AI 怎么知道某台机器人有哪些话题可用?靠 **`rosclaw_discovery`** 这个 ROS2 节点(在 `ros2_ws/src/rosclaw_discovery/`,Python 写的): + +1. 它扫描当前 ROS2 环境里所有活跃的话题和服务; +2. 通过一个 `GetCapabilities` 服务接口把结果暴露出来; +3. 插件的 `context/robot-context.ts` 在每次 AI 会话开始前调用它(或调用标准的 `/rosapi/topics`),把能力清单**注入到 AI 的系统提示**。 + +这样同一个插件接到不同机器人时,AI 都能自动"认识"对方的能力,而不用为每台机器人改代码。 + +--- + +## 七、一次完整交互的数据流(高层版) + +把前面所有机制串起来,一条命令的高层流动是: + +```dot +digraph dataflow { + rankdir=TB; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=11, margin="0.2,0.12"]; + edge [color="#94a3b8", fontname="sans-serif", fontsize=9]; + + u [label="用户:「向前移动 2 米」", fillcolor="#dbeafe", color="#3b82f6"]; + rcv [label="OpenClaw 接收消息", fillcolor="#eff6ff", color="#3b82f6"]; + ctx [label="before_agent_start 钩子\n注入机器人能力清单(context/)", fillcolor="#fae8ff", color="#c026d3"]; + ai [label="AI 分析意图 → 决定调用 ros2_publish\n参数 topic=/cmd_vel, message={linear:{x:0.5}}", fillcolor="#fef9c3", color="#eab308"]; + sf [label="before_tool_call 钩子 → 安全校验(safety/)\n0.5 m/s < 1.0 限制 ✓ 放行", fillcolor="#fae8ff", color="#c026d3"]; + tl [label="ros2_publish 工具执行(tools/)\n→ getTransport().publish(...)", fillcolor="#dcfce7", color="#16a34a"]; + tp [label="RosbridgeTransport(transport/)\n拼成 rosbridge JSON → WebSocket 发出", fillcolor="#e0e7ff", color="#6366f1"]; + rb [label="rosbridge_server → 翻译成 DDS → 发布到 /cmd_vel", fillcolor="#ffedd5", color="#ea580c"]; + mo [label="机器人电机启动 🚗", fillcolor="#f0fdf4", color="#16a34a"]; + rep [label="AI 回复:「好的,已向前移动。」", fillcolor="#dbeafe", color="#3b82f6"]; + + u -> rcv -> ctx -> ai -> sf -> tl -> tp -> rb -> mo -> rep; +} +``` + +> 这张图是"高层"的。**想看精确到"哪个文件、哪个函数、第几行"的版本**,请读配套的 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)——它把上面每个箭头都落实到了真实代码位置。 + +--- + +## 八、用到的设计模式(点名) + +读源码时你会反复遇到这几个经典设计模式,先认个脸: + +| 模式 | 在哪体现 | 解决什么 | +|---|---|---| +| **适配器(Adapter)** | 传输层三种实现同一个 `RosTransport` 接口 | 隐藏底层差异,可替换 | +| **单例(Singleton)** | `service.ts` 全程序共享一个 `transport` | 所有工具共用同一条连接 | +| **工厂(Factory)** | `factory.ts` 按配置造对应传输层 | 把"造哪种对象"的判断集中一处 | +| **观察者 / 回调** | `onConnection`、话题订阅回调 | 状态变化时通知关心的人 | + +这些模式不是 RosClaw 独创,而是软件工程的通用套路。认识它们后,你会发现"原来很多项目都长这样"。 + +--- + +## 九、本章小结 + +- 系统三层:消息层 / AI 网关层(插件挂这)/ ROS2 层。 +- 插件六模块:地基(config、plugin-api)+ 连接(service、transport)+ 四种对接方式(tools、safety、context、commands)。 +- **四种对接 AI 的方式**:工具(AI 调)、钩子-工具前(安全拦截)、钩子-会话前(能力注入)、命令(用户打)——这是全章最该记住的。 +- **传输层适配器模式**:统一接口 + 三种可替换实现 + 工厂选择,是项目最漂亮的设计。 + +--- + +**下一步** → [第四章:核心代码导读](04-核心代码导读.md):从架构走进源码,看每个文件的概貌;想逐行精读就去 [code/ 系列](code/README.md)。 diff --git "a/learn/04-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" "b/learn/04-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" new file mode 100644 index 0000000..53f5da2 --- /dev/null +++ "b/learn/04-\346\240\270\345\277\203\344\273\243\347\240\201\345\257\274\350\257\273.md" @@ -0,0 +1,216 @@ +# 第四章:核心代码导读(鸟瞰版) + +> 本章是源码的**鸟瞰索引**:用一张全局文件结构图 + 每个文件的一句话概述,帮你建立"哪个文件干什么、该按什么顺序读"的全貌。 +> +> **本章只做鸟瞰,不逐行。** 真正的"显微镜"——每一行、每一个符号都拆开讲、第一次出现的语法都补课——在配套的 [code/ 逐行详解系列](code/README.md)(共 33 篇)。本章的每个文件都会链到它对应的逐行详解篇。 +> +> 这样分工是为了**各取所需**:想先看大图,读本章;想吃透某个文件,点进 code/ 对应篇。 + +--- + +## 一、两种读法,先选一个 + +读源码有两条路,按你的风格选: + +| | 路线 A:先看主线 | 路线 B:按依赖层层读 | +|---|---|---| +| 适合 | 想先搞懂"一条命令怎么流过整个项目" | 想从地基开始、一块块吃透 | +| 怎么走 | 先读 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md),跟着一条命令跳过 8 个文件,对全局有感觉后再钻细节 | 按本章下面的"推荐阅读顺序",从 `plugin-api.ts` 一路读到 canvas | +| 优点 | 不迷路,始终知道"我在整体的哪一站" | 系统、扎实,后一篇都建立在前一篇之上 | + +> 💡 **强烈建议先花 10 分钟读 [code/00](code/00-一条命令的旅程.md)**。它是整个 code/ 系列的"龙头"——把一条命令从聊天框到机器人电机的全过程,精确标注到"哪个文件、哪个函数、第几行",并链到每一篇逐行详解。有了这条主线打底,无论你之后怎么读都不会迷路。 + +--- + +## 二、全局文件结构 + +``` +extensions/ +├── openclaw-plugin/ ← 核心插件(控制机器人) +│ ├── openclaw.plugin.json ← 插件元数据与配置 Schema +│ ├── src/ +│ │ ├── index.ts ← 插件入口,统一注册所有模块 +│ │ ├── plugin-api.ts ← OpenClaw API 的 TypeScript 类型声明("合同") +│ │ ├── config.ts ← 配置 Schema(Zod),所有配置项和默认值 +│ │ ├── service.ts ← transport 单例,连接生命周期管理 +│ │ │ +│ │ ├── transport/ ← 传输层抽象(三种模式) +│ │ │ ├── transport.ts ← RosTransport 统一接口 +│ │ │ ├── types.ts ← 所有 Option/Result/Config 类型 +│ │ │ ├── factory.ts ← 按模式动态加载适配器 +│ │ │ ├── rosbridge/ ← 模式 B:WebSocket(默认,最完整) +│ │ │ │ ├── client.ts ← WebSocket 连接、重连、消息路由 🔥 +│ │ │ │ ├── adapter.ts ← 把 RosTransport 接口适配到 client +│ │ │ │ ├── topics.ts ← 话题发布/订阅 +│ │ │ │ ├── services.ts ← 服务调用 +│ │ │ │ ├── actions.ts ← 动作目标发送/取消 +│ │ │ │ └── types.ts ← rosbridge 协议消息结构 +│ │ │ ├── local/ ← 模式 A:直连 DDS(需 rclnodejs) +│ │ │ │ ├── transport.ts ← rclnodejs 包装实现 +│ │ │ │ ├── conversion.ts ← JS 对象 ↔ ROS2 消息类型转换 +│ │ │ │ └── entities.ts ← 节点/发布者/订阅者缓存 +│ │ │ └── webrtc/ ← 模式 C:WebRTC P2P(实为完整实现) +│ │ │ ├── transport.ts ← WebRTC 传输主体(全项目最长) +│ │ │ ├── signaling-client.ts ← 信令客户端 +│ │ │ └── signaling-types.ts ← 信令协议类型 +│ │ │ +│ │ ├── tools/ ← 注册给 AI 的 8 个工具 +│ │ │ ├── index.ts ← 汇总注册入口 +│ │ │ ├── ros2-publish.ts ← ros2_publish +│ │ │ ├── ros2-subscribe.ts ← ros2_subscribe_once +│ │ │ ├── ros2-service.ts ← ros2_service_call +│ │ │ ├── ros2-action.ts ← ros2_action_goal +│ │ │ ├── ros2-param.ts ← ros2_param_get + ros2_param_set +│ │ │ ├── ros2-introspect.ts← ros2_list_topics +│ │ │ └── ros2-camera.ts ← ros2_camera_snapshot +│ │ │ +│ │ ├── safety/validator.ts ← before_tool_call 安全拦截钩子 +│ │ ├── context/robot-context.ts ← before_agent_start 能力注入钩子 +│ │ └── commands/ +│ │ ├── estop.ts ← /estop 紧急停止 +│ │ └── transport.ts ← /transport 运行时切换传输模式 +│ └── skills/ ← 技能描述文档(纯文本,供 AI 读取) +│ +└── openclaw-canvas/ ← 实时仪表盘扩展(Phase 3,占位) + ├── index.ts ← 仅打印一行 log + └── README.md ← 详细设计文档 +``` + +--- + +## 三、按模块速览(每个文件一句话 + 逐行详解链接) + +### 地基:契约与配置 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `plugin-api.ts` | OpenClaw 与插件之间的"合同"——类型声明,无逻辑 | [code/01](code/01-plugin-api.ts.md) | +| `config.ts` | 用 Zod 定义所有配置项、默认值、校验 | [code/02](code/02-config.ts.md) | + +简化示意(`index.ts` 入口): + +```typescript +export default { + id: "rosclaw", + name: "RosClaw", + register(api) { + const config = parseConfig(api.pluginConfig ?? {}); + registerService(api, config); // 先建连接 + registerTools(api); // 再摆工具 + registerSafetyHook(api, config); + registerRobotContext(api, config); + registerEstopCommand(api, config); + registerTransportCommand(api, config); + }, +}; +``` + +### 连接:传输层抽象 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `transport/transport.ts` | `RosTransport` 统一接口(13 个方法的承诺清单) | [code/04](code/04-transport.ts.md) | +| `transport/types.ts` | 各种 Option/Result/Config 数据结构 | [code/03](code/03-transport-types.ts.md) | +| `transport/factory.ts` | 按 `mode` 动态加载对应适配器 | [code/11](code/11-transport-factory.ts.md) | +| `service.ts` | 管理唯一的 transport 单例 + 连接生命周期 | [code/12](code/12-service.ts.md) | + +接口长这样(简化): + +```typescript +export interface RosTransport { + connect(): Promise; + disconnect(): Promise; + publish(options: PublishOptions): void; + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription; + callService(options: ServiceCallOptions): Promise; + // ……共 13 个方法,三种模式都实现它…… +} +``` + +### 模式 B:rosbridge(默认,最完整) + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `rosbridge/types.ts` | rosbridge 协议消息的类型 | [code/05](code/05-rosbridge-types.ts.md) | +| `rosbridge/client.ts` | WebSocket 连接、重连、消息路由(**最核心最难**) | [code/06](code/06-rosbridge-client.ts.md) 🔥 | +| `rosbridge/topics.ts` | 话题发布/订阅 | [code/07](code/07-rosbridge-topics.ts.md) | +| `rosbridge/services.ts` | 服务调用(按 id 配对响应) | [code/08](code/08-rosbridge-services.ts.md) | +| `rosbridge/actions.ts` | 动作目标发送/取消/进度 | [code/09](code/09-rosbridge-actions.ts.md) | +| `rosbridge/adapter.ts` | 把以上组装成 `RosTransport` 实例 | [code/10](code/10-rosbridge-adapter.ts.md) | + +### 工具层:AI 的 8 个动作按钮 + +| 文件 | 工具 | 逐行详解 | +|---|---|---| +| `tools/index.ts` | 汇总注册 + 工具通用结构 | [code/14](code/14-tools-index.ts.md) | +| `ros2-publish.ts` | `ros2_publish` | [code/15](code/15-ros2-publish.ts.md) | +| `ros2-subscribe.ts` | `ros2_subscribe_once` | [code/16](code/16-ros2-subscribe.ts.md) | +| `ros2-service.ts` | `ros2_service_call` | [code/17](code/17-ros2-service.ts.md) | +| `ros2-action.ts` | `ros2_action_goal` | [code/18](code/18-ros2-action.ts.md) | +| `ros2-param.ts` | `ros2_param_get/set` | [code/19](code/19-ros2-param.ts.md) | +| `ros2-introspect.ts` | `ros2_list_topics` | [code/20](code/20-ros2-introspect.ts.md) | +| `ros2-camera.ts` | `ros2_camera_snapshot` | [code/21](code/21-ros2-camera.ts.md) | + +### 钩子与命令 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `safety/validator.ts` | `before_tool_call` 钩子,速度超限则拦截 | [code/22](code/22-safety-validator.ts.md) | +| `context/robot-context.ts` | `before_agent_start` 钩子,注入机器人能力 | [code/23](code/23-robot-context.ts.md) | +| `commands/estop.ts` | `/estop` 急停(绕过 AI 发零速) | [code/24](code/24-commands-estop.ts.md) | +| `commands/transport.ts` | `/transport` 运行时切换传输模式 | [code/25](code/25-commands-transport.ts.md) | + +### 模式 A / C 与第二扩展 + +| 文件 | 一句话 | 逐行详解 | +|---|---|---| +| `transport/local/*` | 模式 A:rclnodejs 直连 DDS(转换/缓存) | [code/26–28](code/26-local-transport.ts.md) | +| `transport/webrtc/*` | 模式 C:WebRTC P2P(信令/握手,实为完整实现) | [code/29–31](code/29-webrtc-signaling-types.ts.md) | +| `openclaw-canvas/` | 第二个扩展:实时仪表盘(占位中) | [code/32](code/32-openclaw-canvas.md) | + +--- + +## 四、推荐阅读顺序 + +code/ 系列**按依赖关系**编排(先读被依赖的底层,再读上层),与下面顺序一致: + +``` +0. code/00 一条命令的旅程 ← 先读这条主线,建立全局观 +1. plugin-api.ts 理解 OpenClaw 契约(AgentTool、钩子) +2. config.ts 所有配置项和默认值 +3. transport/types.ts 传输层数据结构 +4. transport/transport.ts RosTransport 统一接口 +5. rosbridge/types.ts rosbridge 协议消息格式 +6. rosbridge/client.ts WebSocket 连接/重连/路由(最难)🔥 +7. rosbridge/topics.ts 话题发布/订阅 +8. rosbridge/services.ts 服务调用的 pending 模式 +9. rosbridge/actions.ts 动作目标与 feedback +10. rosbridge/adapter.ts 组装成 RosTransport +11. transport/factory.ts 工厂 +12. service.ts 单例与生命周期 +13. index.ts 启动流程总览 +14-21. tools/ 8 个工具(先 publish 学通用结构) +22. safety/validator.ts 安全拦截钩子 +23. context/robot-context.ts 能力注入、并行、缓存、降级 +24-25. commands/ /estop、/transport +26-28. transport/local/ 模式 A +29-31. transport/webrtc/ 模式 C +32. openclaw-canvas/ 第二扩展 +``` + +> 完整的进度表、语法学习地图(上百个 TypeScript 语法点 → 在哪篇首次讲)在 [code/ 系列总览](code/README.md)。 + +--- + +## 五、为什么把"逐行"单独放到 code/? + +你可能注意到本章很"薄"——这是**有意的**。本章和 code/ 系列分工明确: + +- **本章(鸟瞰)**:回答"有哪些文件、各干什么、怎么连、按什么顺序读"。看完心里有张地图。 +- **code/ 系列(显微镜)**:回答"这一行到底什么意思、这个符号是什么语法"。是给"新手中的新手"逐行精读用的,每个新语法都有「语法小课堂」。 + +如果把代码比作一座城市:本章是**地图**,code/ 是**逐街逐户的导览**。先看地图不迷路,再逐户细看才扎实。 + +--- + +**下一步** → [第五章:完整实战教程](05-完整实战教程.md):亲手把仿真机器人跑起来、用一句话让它移动。或直接进 [code/ 逐行详解系列](code/README.md)。 diff --git "a/learn/05-\345\256\214\346\225\264\345\256\236\346\210\230\346\225\231\347\250\213.md" "b/learn/05-\345\256\214\346\225\264\345\256\236\346\210\230\346\225\231\347\250\213.md" new file mode 100644 index 0000000..eba8699 --- /dev/null +++ "b/learn/05-\345\256\214\346\225\264\345\256\236\346\210\230\346\225\231\347\250\213.md" @@ -0,0 +1,613 @@ +# 第五章:完整实战教程——从零到用网页端控制机器人 + +> 本章是**唯一的动手实战章**,手把手带你走完整条链路,并附进阶练习。它合并、去重了原先三篇相互重叠的教程,按"由浅入深"重排成一条主线: +> +> **先用裸协议(无 AI)跑通机器人 → 再叠加 OpenClaw + AI → 最后做进阶练习。** +> +> 这样安排是有教学用意的:**先理解底层协议、亲手发 JSON 让机器人动**,你对"AI 背后到底发生了什么"会有切身体感;之后再加上 AI,就不会觉得它是黑魔法。 + +--- + +## 本章地图 + +| 部分 | 你会做什么 | 需要 AI 吗 | +|---|---|---| +| **第 0 部分** | 检查系统环境、准备账号 | — | +| **第 1 部分** | 用 Docker 跑起 ROS2 仿真,**亲手发裸 JSON** 让机器人移动 | ❌ 不需要 | +| **第 2 部分** | 安装并初始化 OpenClaw,打开网页聊天 | — | +| **第 3 部分** | 把 RosClaw 插件装进 OpenClaw 并配置 | — | +| **第 4 部分** | 连上机器人,**用自然语言**指挥它 | ✅ | +| **第 5 部分** | 进阶练习(改安全限制、加工具、对比模式……) | 部分 | + +> **只想最快看到效果?** 做完 **第 0 + 第 1 部分** 就能让仿真机器人动起来(不用 AI、不用 OpenClaw)。想体验"发一句话控制机器人"的完整魔法,继续第 2–4 部分。 + +--- + +# 第 0 部分:前置与系统自检 + +## 0.1 系统要求 + +| 项目 | 最低要求 | +|---|---| +| 操作系统 | Ubuntu 20.04+ / macOS 13+ / Windows 11(WSL2)| +| 内存 | 8 GB RAM | +| 磁盘 | 10 GB 可用空间 | +| 网络 | 能访问 GitHub、Docker Hub | +| 浏览器 | Chrome / Edge / Firefox 等现代浏览器 | + +## 0.2 检查工具链 + +打开终端,逐条运行,确认每条都有合理输出: + +```bash +node -v # 需要 v20 以上,如 v22.3.0 +pnpm -v # 需要 9 以上,如 9.15.4 +docker --version # 如 Docker version 24.x.x +docker compose version # 注意是 docker compose(带空格),如 v2.x.x +git --version # 如 git version 2.x.x +``` + +### 如果某项没装 + +- **Node.js 20+**:去 [nodejs.org](https://nodejs.org) 下 LTS,或用 nvm: + ```bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + # 重启终端后 + nvm install 20 && nvm use 20 + ``` +- **pnpm**:`npm install -g pnpm` +- **Docker Desktop**:去 [docker.com](https://www.docker.com/products/docker-desktop) 下载安装,装完启动 Docker Desktop 应用。 + +## 0.3 准备账号(第 4 部分才需要) + +- **Anthropic API Key**(OpenClaw 的 AI 功能需要):在 https://console.anthropic.com 注册并生成。 + > 也可用 OpenAI Key,但本教程以 Anthropic(Claude)为例。 + +> 第 1 部分(裸协议)**不需要任何账号或 Key**,可以先跳过 0.3。 + +--- + +# 第 1 部分:ROS2 仿真 + 裸协议验证(不需要 AI) + +> **本部分的成功标准**:在终端发一条 JSON 消息,仿真里的机器人向前移动。 +> +> 这一步**完全不涉及 AI、不涉及 OpenClaw 插件**——只有"你 → rosbridge → 机器人"。目的是让你亲眼看到 rosbridge 协议是怎么驱动机器人的。理解了这层,第 4 部分的 AI 不过是"自动帮你发这些 JSON"而已。 + +## 1.1 克隆仓库 + +```bash +git clone https://github.com/PlaiPin/rosclaw.git +cd rosclaw +``` + +验证:`ls` 应能看到 `docker`、`extensions`、`learn`、`ros2_ws`、`package.json` 等。 + +## 1.2 安装 Node 依赖并类型检查 + +```bash +pnpm install +``` + +这会装好 `extensions/` 下两个包的全部依赖。 + +**✅ 检查点:类型检查** + +```bash +pnpm typecheck +``` + +期望:没有任何错误、安静返回。看到两个包(openclaw-plugin、openclaw-canvas)都执行了 `tsc --noEmit` 且无报错即通过。 + +> 若报错,最常见是 Node 版本不够:`node -v` 必须 ≥ v20。 + +## 1.3 构建 ROS2 Docker 镜像 + +```bash +cd docker +docker compose build ros2 +``` + +第一次构建较慢(5–15 分钟,要拉 ROS2 基础镜像)。看到最后出现: + +``` +=> naming to docker.io/rosclaw/ros2:latest +``` + +即构建成功。 + +> **只构建 ros2 服务**:`docker-compose.yml` 里还有个 `rosclaw` 服务,它依赖尚未合并到仓库的 `packages/` 目录,会报错。本部分只需要 `ros2`。 + +**构建失败排查**: +- 网络超时(国内拉镜像慢):重跑 `docker compose build ros2`,Docker 会断点续传;或配置镜像加速(见文末排错指南)。 +- 磁盘不足:`df -h /`(需 ≥ 5GB),`docker system prune` 清缓存。 + +## 1.4 启动仿真 + +```bash +docker compose up ros2 +``` + +终端持续输出日志,**等出现这一行**(约 30–60 秒): + +``` +ros2-ros2-1 | [INFO] [rosbridge_websocket_launch]: Rosbridge WebSocket server started on port 9090 +``` + +看到 `port 9090` 就绪。**保持这个终端开着**,另开一个新终端做后续步骤。 + +## 1.5 验证 rosbridge 端口 + +新终端里: + +```bash +curl -s --max-time 3 http://localhost:9090 +``` + +期望报这类错(都说明端口通了——rosbridge 是 WebSocket,不接受普通 HTTP): + +``` +curl: (56) Recv failure: Connection reset by peer +# 或 +curl: (1) Received HTTP/0.9 when not allowed +``` + +> 若是 `curl: (7) Failed to connect`,说明容器还没起好,等几秒再试。 + +## 1.6 用裸 JSON 控制机器人(本部分高潮) + +安装 `websocat`(命令行 WebSocket 客户端): + +```bash +# macOS +brew install websocat +# Linux +curl -L https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl -o /usr/local/bin/websocat && chmod +x /usr/local/bin/websocat +# Windows(scoop) +scoop install websocat +``` + +> **不想装 websocat?** 用浏览器替代:F12 打开开发者工具,在 Console 粘贴: +> ```javascript +> const ws = new WebSocket('ws://localhost:9090'); +> ws.onopen = () => ws.send(JSON.stringify({op:'call_service', service:'/rosapi/topics'})); +> ws.onmessage = (e) => console.log(JSON.parse(e.data)); +> ``` +> 看到话题列表打印即成功。 + +### 第 ① 步:先列出话题(最简单的连通测试) + +```bash +websocat ws://localhost:9090 +``` + +连上后光标停在空行,粘贴并回车: + +```json +{"op":"call_service","service":"/rosapi/topics"} +``` + +期望几秒内返回包含 `/cmd_vel`、`/odom` 等的话题列表。**看到这个就说明 rosbridge ↔ ROS2 通了。** + +### 第 ② 步:订阅里程计(用来确认机器人真的在动) + +保持上面的连接,粘贴: + +```json +{"op":"subscribe","topic":"/odom","type":"nav_msgs/msg/Odometry","queue_length":1} +``` + +### 第 ③ 步:发布速度指令(**新开一个终端**再连一次) + +```bash +websocat ws://localhost:9090 +``` + +发送(线速度 0.3 m/s 向前): + +```json +{"op":"publish","topic":"/cmd_vel","type":"geometry_msgs/msg/Twist","msg":{"linear":{"x":0.3,"y":0.0,"z":0.0},"angular":{"x":0.0,"y":0.0,"z":0.0}}} +``` + +### 第 ④ 步:观察 + +切回**订阅 `/odom` 的那个终端**,你应看到持续涌入的里程计数据,`position.x` 在不断增大——**机器人正在向前移动!** + +### 第 ⑤ 步:停下机器人 + +在发指令的终端发零速: + +```json +{"op":"publish","topic":"/cmd_vel","type":"geometry_msgs/msg/Twist","msg":{"linear":{"x":0.0,"y":0.0,"z":0.0},"angular":{"x":0.0,"y":0.0,"z":0.0}}} +``` + +> 🎯 **你刚刚亲手做了 RosClaw 在底层做的事**:拼一条 rosbridge JSON、发给 rosbridge、机器人响应。第 4 部分的 AI,本质就是自动帮你生成并发送这些 JSON。对照 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md) 看代码里是怎么走的,会非常有感觉。 + +### ✅ 第 1 部分成功标准 + +| 检查点 | 命令 | 成功标志 | +|---|---|---| +| 依赖安装 | `pnpm install` | 无报错 | +| 类型检查 | `pnpm typecheck` | 无错误 | +| 镜像构建 | `docker compose build ros2` | 出现 `naming to rosclaw/ros2:latest` | +| 仿真启动 | `docker compose up ros2` | 日志出现 `port 9090` | +| 端口可达 | `curl localhost:9090` | 报 WebSocket 相关错误 | +| ROS2 响应 | 发 `/rosapi/topics` | 返回含 `/cmd_vel` 的列表 | +| **机器人移动** | 发布 `/cmd_vel` | `/odom` 的 `position.x` 持续增大 | + +完成第 1 部分后,如果只想验证底层就到此为止;想体验完整 AI 链路,继续往下。 + +--- + +# 第 2 部分:安装并初始化 OpenClaw + +> 从这里开始进入"AI 链路"。我们让浏览器网页聊天框 → 本机 OpenClaw 网关 → AI 这条线先通起来。**网页聊天内置在网关里,不需要注册任何消息平台账号或 Bot。** + +## 2.1 安装 OpenClaw + +```bash +curl -fsSL https://openclaw.ai/install.sh | bash +``` + +它会检测系统、安装/更新 Node(需 22.19+)、装好 `openclaw` CLI。装完**重开终端**刷新 PATH,验证: + +```bash +openclaw --version # 期望输出版本号 +``` + +> **Windows**:PowerShell(管理员)跑 `iwr -useb https://openclaw.ai/install.ps1 | iex`,之后在 WSL2 里操作。 + +## 2.2 初始化 + +```bash +openclaw onboard --install-daemon +``` + +`--install-daemon` 把 OpenClaw 注册为开机自启的系统服务。向导按如下回答: + +``` +? Choose your AI provider → Anthropic (Claude) +? Anthropic API Key → 粘贴你的 sk-ant-... Key +? Choose a model → claude-sonnet-4-6(或最新 Sonnet) +? Gateway port → 回车(默认 3000) +? Enable web dashboard → Yes ← 关键!网页聊天就靠它 +``` + +> **务必把 `Enable web dashboard` 选 `Yes`**——网页聊天界面内置在 Dashboard 里,是本教程和机器人对话的入口。 + +初始化完跑一下体检: + +```bash +openclaw doctor # 期望所有项都是绿色 ✓ +``` + +## 2.3 启动网关并验证 + +```bash +openclaw gateway start +# 或(装了 daemon 时) +openclaw gateway status # 期望 Gateway is running (PID: xxxxx) +``` + +浏览器打开 `http://localhost:3000`,应看到 OpenClaw Dashboard。 + +**✅ 阶段验证**:浏览器能打开 `http://localhost:3000` 并显示 Dashboard。 + +## 2.4 打开网页聊天,验证 AI + +进入 Dashboard 后,在左侧导航找到 **Chat** 入口点进去(首次可能提示创建会话,按默认建一个)。在聊天框输入: + +``` +hello +``` + +几秒内 AI 应回复你——说明"网页 → 网关 → AI"这条线通了。 + +> 没回复?看日志排查:`openclaw logs --tail 50`。常见原因:API Key 填错/额度用尽。 + +**✅ 阶段验证**:网页聊天框发 `hello`,几秒内收到 AI 回复。 + +--- + +# 第 3 部分:安装并配置 RosClaw 插件 + +## 3.1 确认仓库已克隆 + 装依赖 + +你在第 1 部分已经 `git clone` 和 `pnpm install` 过了。确认一下: + +```bash +ls /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin/ +# 应看到:openclaw.plugin.json package.json src/ skills/ ... +``` + +(路径换成你的实际仓库位置。) + +## 3.2 把插件装进 OpenClaw + +```bash +openclaw skills install /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin +openclaw skills list # 期望看到 rosclaw 出现在列表 +``` + +> 若命令不支持本地路径,手动复制: +> ```bash +> cp -r /home/agiuser/data/ROSCLAW/extensions/openclaw-plugin ~/.openclaw/skills/rosclaw +> ``` + +## 3.3 配置插件 + +打开 `~/.openclaw/config.json5`,加上 `plugins` 部分(`channels.web` 是第 2 部分留下的,保持): + +```json5 +{ + channels: { + web: { enabled: true } + }, + + plugins: { + rosclaw: { + enabled: true, + config: { + transport: { mode: "rosbridge" }, + rosbridge: { + url: "ws://localhost:9090", // Docker 暴露的 rosbridge 地址 + reconnect: true + }, + robot: { name: "TurtleBot3", namespace: "" }, + safety: { + maxLinearVelocity: 0.5, // 仿真用保守速度 + maxAngularVelocity: 1.0 + } + } + } + } +} +``` + +保存后重启网关并看日志: + +```bash +openclaw gateway restart +openclaw logs --follow +``` + +应看到类似 `RosClaw plugin loaded successfully`。按 `Ctrl+C` 退出日志跟踪。 + +**✅ 阶段验证**:`openclaw logs` 出现 `RosClaw plugin loaded successfully`。 + +--- + +# 第 4 部分:连接并下达自然语言指令 + +## 4.1 确保仿真在运行 + +如果第 1 部分的仿真还开着就行;否则重新启动(这次用后台模式 `-d`): + +```bash +cd /home/agiuser/data/ROSCLAW/docker +docker compose up -d ros2 +docker compose logs ros2 | grep "port 9090" # 确认就绪 +``` + +## 4.2 确认插件连上了机器人 + +```bash +openclaw logs | grep -i "transport" +# 期望看到:ROS2 transport status: connected +``` + +**✅ 阶段验证**:`openclaw logs` 出现 `ROS2 transport status: connected`。 + +## 4.3 发出第一条自然语言指令(完整链路的高潮) + +回到浏览器网页聊天(`http://localhost:3000` 的 Chat),发送: + +``` +向前走 1 米 +``` + +**预期过程**(约 5–10 秒): +1. AI 回复:"好的,我来控制机器人向前移动 1 米。" +2. AI 调用 `ros2_publish` 工具,向 `/cmd_vel` 发速度指令; +3. AI 确认:"已完成。" + +**后台验证**(另开终端): + +```bash +docker exec -it docker-ros2-1 bash -c "source /opt/ros/jazzy/setup.bash && ros2 topic echo /odom --once" +# 应看到 pose.pose.position.x 有非零值 +``` + +> 🎉 **见证一下**:第 1 部分你**亲手发 JSON** 让机器人动;这次你只说了句中文,AI **自动**帮你完成了同样的事。这就是 RosClaw 的价值。 + +## 4.4 试试更多指令 + +| 你发的消息 | AI 调用的工具 | 效果 | +|---|---|---| +| `向前走 1 米` | `ros2_publish` → `/cmd_vel` | 前进 | +| `左转 90 度` | `ros2_publish` → `/cmd_vel` | 左转 | +| `电池还有多少` | `ros2_subscribe_once` → `/battery_state` | 返回电量 | +| `现在在哪里` | `ros2_subscribe_once` → `/odom` | 返回坐标 | +| `有哪些可以控制的话题` | `ros2_list_topics` | 返回话题列表 | +| `/estop` | 直接发零速(绕过 AI) | 立即停止 | + +## 4.5 查看 AI 的工具调用过程(可选) + +网页 Dashboard 里,你能在对话旁看到每条消息的完整推理与工具调用记录:AI 选了哪个工具、传了什么参数、工具返回了什么。**对照 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md),你能把界面上看到的每一步对上代码里的函数。** + +**✅ 阶段验证(核心)**:网页发「向前走 1 米」→ AI 回复 + `/odom` 数值变化。 + +--- + +# 第 5 部分:进阶练习 + +> 跑通链路后,下面的练习帮你从"会用"走向"会改"。每个练习都链到相关的逐行详解篇,便于深入。 + +## 练习一:理解并修改安全限制 + +**目标**:理解安全校验逻辑,并改速度限制。 + +1. 打开 [extensions/openclaw-plugin/src/config.ts](../extensions/openclaw-plugin/src/config.ts),找到 `safety` 的默认值 `maxLinearVelocity`。 +2. 打开 [extensions/openclaw-plugin/src/safety/validator.ts](../extensions/openclaw-plugin/src/safety/validator.ts),看校验逻辑(合速度 `Math.sqrt(x²+y²+z²)` 与上限比较)。 +3. **尝试**:把配置里的 `maxLinearVelocity` 改成 `0.1`,重启网关,再让机器人"向前走"(AI 会发约 0.5 的速度),观察日志是否出现拦截:`Blocked: linear velocity ... exceeds limit ...`。 + +**思考题**:安全校验目前只查 `/cmd_vel` 速度。若要给导航目标点也加边界检查,该改哪个文件的哪部分? + +> 🔍 深入:[code/22 · validator.ts](code/22-safety-validator.ts.md) + +## 练习二:添加一个新的 ROS2 工具 + +**目标**:给 AI 加一个 `ros2_list_services` 工具(列出可用服务)。 + +1. 参考 [tools/ros2-introspect.ts](../extensions/openclaw-plugin/src/tools/ros2-introspect.ts)(现有的 `ros2_list_topics`),看工具的结构:名字、参数 schema、`execute`。 +2. 在 `RosTransport` 接口([transport/types.ts](../extensions/openclaw-plugin/src/transport/types.ts) / [transport.ts](../extensions/openclaw-plugin/src/transport/transport.ts))参考 `listTopics()` 加一个 `listServices()`。 +3. 在 rosbridge 适配器里实现它——rosbridge 列服务用 `{"op":"call_service","service":"/rosapi/services"}`(对照列话题用的 `/rosapi/topics`)。 +4. 在 `tools/ros2-introspect.ts` 注册新工具,并确保 `tools/index.ts` 注册了它。 +5. `pnpm typecheck` 验证。 + +> 🔍 深入:[code/14 工具结构](code/14-tools-index.ts.md)、[code/20 introspect](code/20-ros2-introspect.ts.md)、[code/10 适配器](code/10-rosbridge-adapter.ts.md) + +## 练习三:理解三种部署模式的差异 + +**目标**:通过对比 Docker Compose 文件理解三种模式的实际部署。 + +1. 对比阅读: + - [docker/docker-compose.local.yml](../docker/docker-compose.local.yml)(模式 A) + - [docker/docker-compose.yml](../docker/docker-compose.yml)(模式 B) + - [docker/docker-compose.cloud.yml](../docker/docker-compose.cloud.yml) + [docker/docker-compose.robot.yml](../docker/docker-compose.robot.yml)(模式 C) +2. 找出每种模式里 `ROSCLAW_TRANSPORT_MODE` 的值,以及服务之间如何通信(同容器?不同容器?不同网络?)。 +3. 对照 [docs/architecture.zh.md](../docs/architecture.zh.md) 验证理解。 + +**思考题**:模式 C 为什么需要两个独立的 compose 文件(cloud + robot)? + +> 🔍 深入:模式 A [code/26–28](code/26-local-transport.ts.md)、模式 C [code/29–31](code/29-webrtc-signaling-types.ts.md) + +## 练习四:理解"技能(Skill)"与工具的区别 + +**目标**:搞懂"技能"概念。 + +1. 阅读 `extensions/openclaw-plugin/skills/` 下四个技能:`navigate-to`、`take-photo`、`check-status`、`pick-object` 的 `SKILL.md`。 +2. 理解区别: + - **工具**:单步操作(发一个话题、调一个服务)。 + - **技能**:多步流程(如"导航到目标点" = 发 Nav2 目标 + 等完成 + 订阅里程计确认)。 +3. **尝试**:参考现有技能,为"巡逻"场景草拟一个 `patrol/SKILL.md`,描述 AI 应如何依次执行多个导航目标。 + +## 练习五:探索 `rosclaw_discovery` 能力发现节点 + +**目标**:理解能力自动发现机制。 + +仿真运行时,进入 ROS2 容器: + +```bash +docker exec -it docker-ros2-1 bash +source /opt/ros/jazzy/setup.bash +ros2 topic list # 当前话题 +ros2 service list | grep rosclaw # 发现服务是否就绪 +ros2 service call /rosclaw/get_capabilities rosclaw_msgs/srv/GetCapabilities # 手动调用 +``` + +对比输出,理解插件如何把这些信息注入给 AI(对应 [code/23 robot-context](code/23-robot-context.ts.md))。 + +--- + +# 排错指南(合并去重) + +### 网页端打不开 / 聊天框不显示 + +```bash +openclaw gateway status # 1. 网关在运行? +openclaw doctor # 2. web dashboard 已启用? +nc -zv localhost 3000 # 3. 端口没被占用? +``` +常见原因:onboard 时 `Enable web dashboard` 选了 No;3000 端口被占。 + +### 网页发消息后 AI 不回复 + +```bash +openclaw gateway status +openclaw logs --tail 50 +openclaw doctor # 确认 AI Key 正确 +``` +常见原因:Anthropic Key 填错/额度用尽;config.json5 语法错误(缺逗号、括号不匹配)导致网关没正常启动。 + +### 插件加载报错 + +```bash +openclaw logs | grep -i "error\|rosclaw" +``` + +| 报错 | 原因 | 解决 | +|---|---|---| +| `Cannot find module` | 插件依赖没装 | `cd 仓库 && pnpm install` | +| `Transport not initialized` | rosbridge 还没连上 | 确认 Docker 容器已启动 | +| `Connection to ws://localhost:9090 timed out` | rosbridge 没起来/端口不对 | 查 `docker compose logs ros2` | + +### AI 调用工具后说"失败"——可能被安全校验拦了 + +```bash +openclaw logs | grep -i "blocked\|safety\|velocity" +``` +看到 `Blocked: linear velocity X exceeds limit Y`,说明速度超过 `maxLinearVelocity`。临时解决:把它调大(如 1.0)并重启网关。 + +### `/estop` 发出后机器人没停 + +`/estop` 要求 transport 已连接: +```bash +openclaw logs | grep "transport status" +``` +若显示 `disconnected`,重启网关等重连。 + +### Docker 镜像构建卡在下载 + +ROS2 基础镜像约 1GB,国内慢。配置镜像加速:Docker Desktop → Settings → Docker Engine 添加: +```json +{ "registry-mirrors": ["https://mirror.ccs.tencentyun.com"] } +``` + +### `docker compose up ros2` 看不到 `port 9090` + +等待不够(ROS2 启动慢)。超过 3 分钟仍无,查错: +```bash +docker compose logs ros2 | grep -i error +``` + +### 容器起不来 + +```bash +docker compose logs ros2 +docker compose down +docker compose build ros2 --no-cache +docker compose up -d ros2 +``` + +--- + +# 完整成功标准 + +全部通过即为完整链路打通: + +| # | 验证项 | 命令/操作 | 成功标志 | +|---|---|---|---| +| 1 | 类型检查 | `pnpm typecheck` | 无错误 | +| 2 | 仿真起来 | `docker compose logs ros2 \| grep 9090` | 出现 `port 9090` | +| 3 | **裸协议控制** | websocat 发 `/cmd_vel` | `/odom` 的 `position.x` 增大 | +| 4 | OpenClaw 安装 | `openclaw --version` | 输出版本号 | +| 5 | Dashboard 可访问 | 浏览器开 `localhost:3000` | 正常显示 | +| 6 | 网页对话通 | 网页发 `hello` | 收到 AI 回复 | +| 7 | 插件加载 | `openclaw logs \| grep RosClaw` | `loaded successfully` | +| 8 | transport 连接 | `openclaw logs \| grep transport` | `status: connected` | +| **9** | **自然语言控制** | 网页发「向前走 1 米」 | **AI 回复 + odom 变化** | + +第 3 条是"裸协议里程碑",第 9 条是"完整链路里程碑"——两个都达到,你就走通了从底层协议到 AI 的全程。 + +--- + +## 下一步探索 + +- **接入消息平台**:在网页之外,再配 Telegram/WhatsApp/Discord 通道,就能用手机远程控制(把 `channels.web` 换成对应平台,配置方式类似)。 +- **尝试导航**:发「导航到坐标 2, 2」,测试 `ros2_action_goal` 工具(动作)。 +- **接真实机器人**:把 `rosbridge.url` 改成真机 IP,其余代码完全不变——这正是传输层抽象的好处。 +- **读源码**:对照 [第四章:核心代码导读](04-核心代码导读.md) 和 [code/ 逐行详解系列](code/README.md) 理解每一步背后的实现,尤其推荐先读 [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md)。 + +--- + +← 返回 [README 学习总览](README.md) | ← 上一章 [第四章:核心代码导读](04-核心代码导读.md) diff --git a/learn/README.md b/learn/README.md new file mode 100644 index 0000000..d4eb84d --- /dev/null +++ b/learn/README.md @@ -0,0 +1,79 @@ +# RosClaw 新手学习指南 + +欢迎来到 RosClaw 学习中心!本指南带你从零开始理解和使用这个项目——**面向"新手中的新手"**,不要求你懂 ROS2 或 TypeScript。 + +--- + +## 学习路径 + +按顺序读这五章,循序渐进: + +| 序号 | 文件 | 内容 | 需要动手吗 | +|---|---|---|---| +| 1 | [项目概览](01-项目概览.md) | 是什么、解决什么痛点、三大概念、整体架构、术语表 | 否 | +| 2 | [技术前置知识](02-技术前置知识.md) | 会碰到哪些技术,每项最少要懂多少 | 否 | +| 3 | [架构深度解析](03-架构深度解析.md) | 三层架构、六模块、四种对接 AI 的方式、传输层设计 | 否 | +| 4 | [核心代码导读](04-核心代码导读.md) | 源码鸟瞰索引:哪个文件干什么、按什么顺序读 | 否 | +| 5 | [完整实战教程](05-完整实战教程.md) | 从零跑通:裸协议 → AI → 进阶练习 | **是** | + +读完这五章,再进 **[code/ 逐行详解系列](code/README.md)**(33 篇)逐行精读源码。 + +--- + +## 两条特别推荐的主线 + +无论你想"读懂"还是"跑通",这两篇是各自的最佳起点: + +- 🧭 **想读懂代码怎么运转** → [code/00 · 一条命令的旅程](code/00-一条命令的旅程.md) + 跟着「向前走 1 米」这一条命令,看它如何穿过 8 个文件到达机器人——**精确到哪个文件、哪个函数、第几行**,并链到每篇逐行详解。这是整个 code/ 系列的"龙头"。 + +- 🚀 **想亲手跑起来** → [第五章:完整实战教程](05-完整实战教程.md) + 手把手:先用裸 JSON 让仿真机器人动(不用 AI),再叠加 OpenClaw + AI,最后做进阶练习。 + +--- + +## 按目标快速定位 + +- **我只想最快看到机器人动** → [第五章](05-完整实战教程.md) 的"第 0 + 第 1 部分"(不用 AI)。 +- **我想理解它怎么工作** → [第一章](01-项目概览.md) → [第三章](03-架构深度解析.md)。 +- **我想读源码** → [第四章](04-核心代码导读.md) 鸟瞰,再进 [code/ 系列](code/README.md);先读 [code/00](code/00-一条命令的旅程.md) 主线不迷路。 +- **我想自己动手改** → [第五章 · 第 5 部分进阶练习](05-完整实战教程.md#第-5-部分进阶练习)。 + +--- + +## 仓库地图 + +``` +rosclaw/ +├── extensions/openclaw-plugin/ ← 核心插件(TypeScript) +│ └── src/ +│ ├── config.ts ← 配置定义 +│ ├── index.ts ← 插件入口 +│ ├── tools/ ← AI 可调用的 8 个 ROS2 工具 +│ ├── transport/ ← 三种传输层适配器(A/B/C 模式) +│ ├── safety/ ← 安全校验钩子 +│ ├── context/ ← 机器人能力注入钩子 +│ └── commands/ ← 直接命令(如 /estop 急停) +├── extensions/openclaw-canvas/ ← 第二个扩展:实时仪表盘(占位中) +├── ros2_ws/src/ ← ROS2 Python 包 +│ ├── rosclaw_discovery/ ← 能力自动发现节点 +│ ├── rosclaw_msgs/ ← 自定义消息/服务类型 +│ └── rosclaw_agent/ ← WebRTC 模式的机器人侧节点 +├── docker/ ← 一键启动仿真环境 +├── examples/ ← 三个场景示例 +├── docs/ ← 架构文档 +└── learn/ ← 【你在这里】新手学习资料 + ├── 01~05-*.md ← 五章学习路径 + └── code/ ← 逐行详解系列(00 主线 + 01~32 各文件) +``` + +--- + +## 这套资料的两个层次 + +| 层次 | 在哪 | 干什么 | 比喻 | +|---|---|---|---| +| **入门五章** | `learn/01~05` | 概念、架构、鸟瞰、实战 | 城市地图 | +| **逐行详解** | `learn/code/` | 每一行、每个语法都拆开讲 | 逐街逐户导览 | + +先看地图(五章)建立全局观,再逐户细看(code/)才扎实。code/ 系列专为"新手中的新手"设计,每个新出现的 TypeScript 语法都有「语法小课堂」,并配有可反查的"语法学习地图"。 diff --git "a/learn/code/00-\344\270\200\346\235\241\345\221\275\344\273\244\347\232\204\346\227\205\347\250\213.md" "b/learn/code/00-\344\270\200\346\235\241\345\221\275\344\273\244\347\232\204\346\227\205\347\250\213.md" new file mode 100644 index 0000000..e78964e --- /dev/null +++ "b/learn/code/00-\344\270\200\346\235\241\345\221\275\344\273\244\347\232\204\346\227\205\347\250\213.md" @@ -0,0 +1,485 @@ +# 逐行详解 ⓪:一条命令的旅程(全系列主线 · 先读我) + +> **这是整个逐行详解系列的"龙头"文档,建议第一个读。** +> +> 前面的 01–32 篇是"显微镜"——每篇把一个源文件**逐行**拆开。但新手最容易迷路的不是某一行看不懂,而是**"这些文件之间到底怎么串起来的?我发一句话,代码里到底依次发生了什么?"** +> +> 本篇就回答这个问题:我们跟踪**一条命令**——用户在聊天框打「向前走 1 米」——看它如何穿过整个 RosClaw,最终让机器人动起来。**每一跳都精确标注"哪个文件、哪个函数、第几行、信息变成了什么形状",并链接到对应的逐行详解篇。** +> +> 读完它,你就有了一张"全局地图"。之后再按 01→32 钻进每个文件时,你随时知道"我现在在地图的哪个位置"。 + +--- + +## 怎么用这篇文档 + +- **第一次读**:从头顺读,不必点开每个链接,先在脑子里建立"一条命令流过 8 个站点"的整体印象。 +- **读 01–32 时**:每当你读某一篇有点"只见树木"的感觉,回到本篇,找到那个文件在主线中的位置,就知道它"上游是谁、下游是谁"。 +- **查地图**:文末有一张"全站点速查表",可随时反查。 + +> 本篇出现的所有行号都对照当前源码核实过。若你本地代码版本不同,行号可能有小幅偏移,但函数名和顺序不变。 + +--- + +## 先看全景:一条命令要过几道关 + +想象你在微信里对机器人说「**向前走 1 米**」。这句中文,要变成机器人电机的转动,中间隔着**两个世界**: + +- **"人话"的世界**:自然语言、聊天消息。 +- **"机器"的世界**:ROS2 的话题、二进制 DDS 消息、电机指令。 + +RosClaw 就是把前者翻译成后者的**翻译流水线**。这条流水线大致是: + +```dot +digraph journey { + rankdir=TB; + bgcolor="transparent"; + compound=true; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=10, margin="0.18,0.1"]; + edge [color="#64748b", fontname="sans-serif", fontsize=9]; + + you [label="你(聊天框)\n「向前走 1 米」", fillcolor="#dbeafe", color="#3b82f6"]; + + subgraph cluster_oc { + label="OpenClaw 网关(AI 代理)— 不在本仓库"; + style="rounded,filled"; fillcolor="#fffbeb"; color="#eab308"; fontname="sans-serif"; fontsize=10; + ai [label="② AI 理解意图 → 决定调用 ros2_publish\ntopic=/cmd_vel, message={linear:{x:0.5}}", fillcolor="#fef9c3", color="#eab308"]; + } + + subgraph cluster_plg { + label="RosClaw 插件(本仓库的主角)"; + style="rounded,filled"; fillcolor="#faf5ff"; color="#c026d3"; fontname="sans-serif"; fontsize=10; + sf [label="③ 安全钩子拦一道(超速则拦下)\nsafety/validator.ts", fillcolor="#fae8ff", color="#c026d3"]; + tl [label="④ ros2_publish 工具执行\ntools/ros2-publish.ts : execute()", fillcolor="#dcfce7", color="#16a34a"]; + sv [label="⑤ 取出传输层单例\nservice.ts : getTransport()", fillcolor="#dbeafe", color="#3b82f6"]; + ad [label="⑥ 适配器转成 rosbridge 协议\nrosbridge/adapter.ts : publish()", fillcolor="#e0e7ff", color="#6366f1"]; + tp [label="⑦ 拼成 JSON {op:\"publish\",topic,msg}\nrosbridge/topics.ts", fillcolor="#e0e7ff", color="#6366f1"]; + cl [label="⑧ 通过 WebSocket 发出\nrosbridge/client.ts : send()", fillcolor="#e0e7ff", color="#6366f1"]; + sf -> tl -> sv -> ad -> tp -> cl; + } + + rb [label="⑨ rosbridge_server\n把 JSON 翻译成 DDS(Docker 容器里)", fillcolor="#ffedd5", color="#ea580c"]; + ros [label="ROS2 机器人 → /cmd_vel → 电机转动 🚗", shape=box, style="rounded,filled", fillcolor="#dcfce7", color="#16a34a"]; + + you -> ai [label="① 自然语言", lhead=cluster_oc]; + ai -> sf [label="工具调用", lhead=cluster_plg]; + cl -> rb [label="WebSocket 帧", ltail=cluster_plg]; + rb -> ros [label="DDS"]; +} +``` + +**注意信息的"形态"在一路上不断变化**(这是本篇最想让你记住的一点): + +| 阶段 | 信息长什么样 | +|---|---| +| 你发的 | 一句中文字符串 `"向前走 1 米"` | +| AI 产出的 | 一次"工具调用":工具名 + 参数对象 | +| 工具内部 | 一个 JavaScript 对象 `{ topic, type, msg }` | +| 传输层发出的 | 一条 rosbridge **JSON 文本** `{"op":"publish",...}` | +| WebSocket 上 | 一个网络数据帧(就是上面那段 JSON 的字节) | +| rosbridge 之后 | ROS2 的 **DDS 二进制消息** | + +**翻译,就是不断改变信息的形态、同时保持含义不变。** 下面逐站点细看。 + +--- + +## 站点 0(启动时就绪):插件装配 —— `index.ts` + +在你发任何消息**之前**,OpenClaw 启动时会加载 RosClaw 插件,调用它的 `register()` 函数。这一步把后面要用到的所有"零件"装好。 + +📍 **`src/index.ts` · `register()` · 第 17–39 行** + +```typescript +register(api: OpenClawPluginApi): void { + api.logger.info("RosClaw plugin loading..."); + const config = parseConfig(api.pluginConfig ?? {}); // 解析配置 + + registerService(api, config); // ① 建立 ROS2 连接(传输层) + registerTools(api); // ② 注册 8 个 AI 工具 + registerSafetyHook(api, config); // ③ 注册安全钩子 + registerRobotContext(api, config); // ④ 注册"能力注入"钩子 + registerEstopCommand(api, config); // ⑤ 注册 /estop 命令 + registerTransportCommand(api, config); // ⑥ 注册 /transport 命令 +} +``` + +- **打个比方**:`register` 像开店前的"备货 + 摆货架"。它本身不卖东西,只是把"连接"建好、把"工具"摆上货架、把"安检员"安排到门口。等顾客(你的消息)上门,这些零件才各司其职。 +- 这 6 个 `registerXxx` 就是插件的全部家当,**后面每一个站点都是其中之一被触发**。 +- 注意一个**关键顺序**:`registerService` 排第一——必须先把"和机器人的连接"建好,后面的工具才有"管子"可用。 + +> 🔍 逐行详解:[第 13 篇 · index.ts](13-index.ts.md)(插件入口、`export default`、"只注册不执行"的节奏) + +--- + +## 站点 1(会话开始前):告诉 AI "这台机器人能干什么" —— `robot-context.ts` + +你刚打开对话、还没说话时,就有一个钩子悄悄运行了:它**查出机器人当前有哪些话题/服务/动作,塞进 AI 的"脑子"里**。否则 AI 根本不知道该往哪个话题发消息。 + +📍 **`src/context/robot-context.ts` · `registerRobotContext()` · 第 26–49 行** + +```typescript +api.on("before_agent_start", async (_event, _ctx) => { + const capabilities = await discoverCapabilities(api, robotNamespace); // 查能力 + const context = buildRobotContext(robotName, robotNamespace, capabilities); // 拼成文本 + return { prependContext: context }; // 塞进 AI 系统提示 +}); +``` + +- **`before_agent_start`** 是个"钩子"——OpenClaw 在"AI 开始思考之前"自动触发它。返回 `{ prependContext: 文本 }`,这段文本就会被**插到 AI 的系统提示最前面**。 +- 那段文本长这样(大意):`这台机器人叫 TurtleBot3,可用话题有 /cmd_vel、/odom、/battery_state……`。AI 读了它,才知道"向前走"应该往 `/cmd_vel` 发。 + +**那"能力"是怎么查出来的?** 看 `discoverCapabilities`: + +📍 **`src/context/robot-context.ts` · `discoverCapabilities()` · 第 55–98 行** + +```typescript +const [topics, services, actions] = await Promise.all([ + transport.listTopics(), // 并行发起三个查询 + transport.listServices(), + transport.listActions(), +]); +``` + +- **`Promise.all`** 让三个查询**并行**跑、一起等结果(比一个个等快)。 +- 这三个 `transport.listXxx()` 最终会**调用一个 ROS2 服务**去问机器人。以 `listTopics` 为例,它落到适配器里: + +📍 **`src/transport/rosbridge/adapter.ts` · `listTopics()` · 第 102–112 行** + +```typescript +const response = await callService(this.client, "/rosapi/topics", {}, "rosapi/srv/Topics"); +``` + +- 看到了吗——**"查有哪些话题"本身也是一次"调用服务"**(服务名 `/rosapi/topics`)。这就和站点 6 之后的请求-响应走同一套机制了(后面会讲)。 +- 还有个贴心设计:结果会**缓存 60 秒**(第 60 行 `Date.now() - cache.timestamp < CACHE_TTL_MS`),避免每次对话都重新查一遍;查询失败也不会崩,而是**降级**成一份硬编码的默认能力(第 89–97 行)。 + +> 🔍 逐行详解:[第 23 篇 · robot-context.ts](23-robot-context.ts.md)(`before_agent_start`、`Promise.all`、TTL 缓存、降级兜底) + +--- + +## 站点 2(AI 的工作):把"人话"变成"工具调用" —— OpenClaw 侧 + +这一步**不在本仓库**,是 OpenClaw 平台 + 大模型(Claude)干的,但它是整条链的转折点,必须理解: + +- AI 收到你的「向前走 1 米」+ 站点 1 注入的能力清单,**推理**出:"这要往 `/cmd_vel` 发一个 Twist 速度消息。" +- 于是 AI 产出一次**工具调用(tool call)**: + - 工具名:`ros2_publish` + - 参数:`{ topic: "/cmd_vel", type: "geometry_msgs/msg/Twist", message: { linear: { x: 0.5, ... }, angular: {...} } }` +- **AI 怎么知道有 `ros2_publish` 这个工具、该传什么参数?** 靠的是工具注册时写的 `description`(提示词)和 `parameters`(参数 schema)——这正是站点 0 里 `registerTools` 摆上货架时声明的。 + +> 📌 **这里是"人话世界"和"机器世界"的分界线**:往上是自然语言 + AI 推理,往下全是确定的代码逻辑。RosClaw 插件的工作从下一站正式开始。 +> +> 🔍 想理解"工具"这种交互形式:[第 14 篇 · 工具通用结构](14-tools-index.ts.md);想看整体架构:[顶层 · 03 架构深度解析](../03-架构深度解析.md) + +--- + +## 站点 3(工具执行前):安全员拦一道 —— `validator.ts` + +AI 说要发速度消息,但**先别急**——在工具真正执行前,安全钩子会拦下来检查"这个速度会不会太快出危险"。 + +📍 **`src/safety/validator.ts` · `registerSafetyHook()` 内的 `before_tool_call` · 第 11–42 行** + +```typescript +api.on("before_tool_call", async (event, _ctx) => { + if (event.toolName === "ros2_publish") { + const msg = event.params["message"] as Record | undefined; + if (msg) { + const linear = msg["linear"] as Record | undefined; + if (linear) { + const speed = Math.sqrt( // 第 20 行:算合速度 + (linear["x"] ?? 0) ** 2 + (linear["y"] ?? 0) ** 2 + (linear["z"] ?? 0) ** 2, + ); + if (speed > safety.maxLinearVelocity) { + return { block: true, blockReason: `...exceeds safety limit...` }; // 第 27 行:拦下 + } + } + // ……角速度同理(第 31–37 行)…… + } + } +}); +``` + +- **`before_tool_call`** 也是钩子,但时机不同:**每次工具即将执行前**触发。 +- 它把参数里的 `message.linear` 取出来,用 `Math.sqrt(x² + y² + z²)` 算出**合速度**(第 20 行),和配置的上限 `maxLinearVelocity` 比。 +- **两种结局**: + - 超限 → `return { block: true, blockReason: "..." }`(第 27 行)→ **工具被拦截、不执行**,AI 收到拦截原因。 + - 没超 → 函数自然走完、`return` 一个 `undefined` → **放行**,进入下一站。 +- **打个比方**:安全员站在工具门口,速度合规就放你进去,超速就把你拦下并说明理由。这一道关保证"AI 再怎么发疯,也发不出超过物理上限的危险速度"。 + +> 🔍 逐行详解:[第 22 篇 · validator.ts](22-safety-validator.ts.md)(钩子机制、`Math.sqrt`/`**`、默认放行/危险拦截) + +--- + +## 站点 4:工具执行 —— `ros2-publish.ts` + +安全员放行后,`ros2_publish` 工具的 `execute` 真正运行。它的工作出奇地简单——**取参数、拿传输层、发出去**。 + +📍 **`src/tools/ros2-publish.ts` · `execute()` · 第 24–37 行** + +```typescript +async execute(_toolCallId, params) { + const topic = params["topic"] as string; // 第 25 行:取出三个参数 + const type = params["type"] as string; + const message = params["message"] as Record; + + const transport = getTransport(); // 第 29 行:拿到传输层单例 + transport.publish({ topic, type, msg: message }); // 第 30 行:发布! + + const result = { success: true, topic, type }; + return { content: [{ type: "text", text: JSON.stringify(result) }], details: result }; +} +``` + +- 第 25–27 行:从 `params` 里取出 AI 传来的 `topic`、`type`、`message`。 +- 第 29 行:`getTransport()` 拿到"和机器人的连接"(下一站讲它从哪来)。 +- 第 30 行:`transport.publish({...})`——**真正的发布动作**。注意工具把参数 `message` 改名成 `msg` 装进对象(迁就传输层接口的字段名)。 +- **工具层只是"薄薄一层"**:它不关心底层用 WebSocket 还是 WebRTC,只管"把请求交给 transport"。这正是分层设计的好处。 + +> 🔍 逐行详解:[第 15 篇 · ros2-publish.ts](15-ros2-publish.ts.md)(TypeBox 参数、`execute` 套路、`ToolResult` 的 content/details) + +--- + +## 站点 5:取出"传输层"单例 —— `service.ts` + +第 29 行那个 `getTransport()` 是从哪儿拿到连接的?答案在 `service.ts`——它管着一个**全程序共享的唯一连接对象(单例)**。 + +📍 **`src/service.ts` · `getTransport()` · 第 18–23 行** + +```typescript +let transport: RosTransport | null = null; // 第 9 行:模块级单例变量 + +export function getTransport(): RosTransport { + if (!transport) { + throw new Error("Transport not initialized. Is the service running?"); + } + return transport; // 返回那个唯一的连接 +} +``` + +- `transport` 是个**模块级变量**(写在文件最外层,全文件共享),保存"唯一的那条连接"。 +- **这条连接是什么时候建的?** 回到站点 0:`registerService` 在插件启动时,于 `start()` 里建好并连上: + +📍 **`src/service.ts` · `registerService().start()` · 第 94–101 行** + +```typescript +transport = await createTransport(transportCfg); // 第 94 行:按配置造一个传输层 +transport.onConnection((status) => { ... }); +await transport.connect(); // 第 100 行:连上机器人 +currentMode = mode; +``` + +- `createTransport` 是个**工厂**:看配置里 `mode` 是 `rosbridge`/`local`/`webrtc`,就造对应的那一种。默认 `rosbridge`,所以这里造的是 `RosbridgeTransport`。 +- **打个比方**:`service.ts` 像大楼的"总闸"——开店时合闸(`connect`),所有工具共用这一路电(同一个 `transport`),关店时拉闸(`disconnect`)。 +- 还有个 `switchTransport`(第 35 行)支持运行时换传输模式,用了"并发门闩"防止换到一半被打断——那是 `/transport` 命令用的,本主线用不到。 + +> 🔍 逐行详解:[第 12 篇 · service.ts](12-service.ts.md)(模块级单例、并发门闩、生命周期);工厂见 [第 11 篇](11-transport-factory.ts.md) + +--- + +## 站点 6:适配器 —— `adapter.ts` + +`transport.publish(...)` 调到的是 `RosbridgeTransport`(默认模式)的 `publish` 方法。它做的事也很薄——**转交给一个专门发布话题的小助手**。 + +📍 **`src/transport/rosbridge/adapter.ts` · `publish()` · 第 55–58 行** + +```typescript +publish(options: PublishOptions): void { + const publisher = new TopicPublisher(this.client, options.topic, options.type); + publisher.publish(options.msg); +} +``` + +- `RosbridgeTransport` 实现了一个统一接口 `RosTransport`(13 个方法:publish/subscribe/callService……)。**三种传输模式都实现同一个接口**,所以上层(工具)调 `transport.publish` 时根本不用管底下是哪种模式。 +- 这里它把活儿交给 `TopicPublisher`(话题发布小助手),自己只负责"组装零件、转交"。 +- **打个比方**:适配器像前台。你说"我要发布到 /cmd_vel",前台不亲自跑腿,而是叫来"话题发布专员"(`TopicPublisher`)去办。 + +> 🔍 逐行详解:[第 10 篇 · adapter.ts](10-rosbridge-adapter.ts.md)(`implements` 接口、13 个方法、把活儿分派给各助手);统一接口本身见 [第 04 篇](04-transport.ts.md) + +--- + +## 站点 7:拼成 rosbridge 协议 JSON —— `topics.ts` + +`TopicPublisher.publish` 终于把请求变成了**rosbridge 协议规定的那条 JSON**。 + +📍 **`src/transport/rosbridge/topics.ts` · `TopicPublisher.publish()` · 第 15–22 行** + +```typescript +publish(msg: Record): void { + this.client.send({ + op: "publish", // ← rosbridge 协议:op 字段表明"这是发布操作" + topic: this.topic, // /cmd_vel + type: this.type, // geometry_msgs/msg/Twist + msg, // { linear: {...}, angular: {...} } + }); +} +``` + +- **这就是"信息形态"的关键一变**:原本是 JavaScript 对象,现在被拼成了 **rosbridge 协议**约定的形状——一个带 `op` 字段的对象。`op: "publish"` 告诉 rosbridge_server "我要往话题发消息"。 +- rosbridge 协议就是一套"用带 `op` 字段的 JSON 表达 ROS2 操作"的约定:`publish`(发布)、`subscribe`(订阅)、`call_service`(调服务)等。 +- 拼好后交给 `this.client.send(...)`——下一站真正发到网络上。 + +> 🔍 逐行详解:[第 07 篇 · topics.ts](07-rosbridge-topics.ts.md);rosbridge 协议全貌见 [第 05 篇](05-rosbridge-types.ts.md) + +--- + +## 站点 8:通过 WebSocket 发出去 —— `client.ts` + +`client.send(...)` 是整条出站链的**最后一站**(插件这一侧)。它把那个 JSON 对象变成文本,写进 WebSocket。 + +📍 **`src/transport/rosbridge/client.ts` · `send()` · 第 138–144 行** + +```typescript +send(message: RosbridgeMessage & Record): void { + if (!this.ws || this.status !== "connected") { + throw new Error("Not connected to rosbridge server"); + } + this.ws.send(JSON.stringify(message)); // 第 143 行:对象 → JSON 文本 → 发出 +} +``` + +- 第 143 行 `JSON.stringify(message)`——**把对象序列化成 JSON 字符串**,再 `this.ws.send(...)` 写进 WebSocket 连接。 +- 至此,那段文本 `{"op":"publish","topic":"/cmd_vel",...}` 以网络数据帧的形式,飞向 Docker 容器里的 **rosbridge_server**。 +- `RosbridgeClient` 是最底层的"网络工":管 WebSocket 的连接、断线重连、收发。**它是全系列最难、最核心的一篇**——因为"收消息"那一半(下面返回路径要用)也在这里。 + +> 🔍 逐行详解:[第 06 篇 · client.ts](06-rosbridge-client.ts.md) 🔥(全系列最难:connect、send、handleMessage) + +--- + +## 站点 9(插件之外):rosbridge → DDS → 机器人 + +JSON 飞出插件后: + +1. **rosbridge_server**(跑在 Docker 容器里的 ROS2 官方组件)收到这条 JSON,**把它翻译成 ROS2 的 DDS 二进制消息**,发布到 `/cmd_vel` 话题。 +2. ROS2 机器人(仿真里的 TurtleBot3)的电机控制节点**订阅**了 `/cmd_vel`,收到速度消息,驱动电机。 +3. 🚗 **机器人向前动了。** + +这一段不在本仓库(是 ROS2 生态的标准组件),但你要知道**信息在这里完成了最后一次形态转换**:rosbridge JSON → DDS 二进制。 + +> 想理解 rosbridge 是什么、为什么需要它:[顶层 · 01 项目概览](../01-项目概览.md) 和 [顶层 · 02 技术前置知识](../02-技术前置知识.md)。 + +--- + +## 返回路径:读类命令怎么把数据拿回来? + +上面的「向前走」是**单向**的——发出去就完事(发布不需要回复)。但如果你问「**电池还有多少?**」,AI 会调 `ros2_subscribe_once` 工具,这就需要**等机器人回一条数据**。返回路径用了一个很妙的"竞速"技巧。 + +📍 **`src/tools/ros2-subscribe.ts` · `execute()` · 第 22–42 行** + +```typescript +const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: msgType }, + (msg) => { // 收到消息时: + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); // ← 兑现:把数据交出去 + }, + ); + const timer = setTimeout(() => { // 同时起一个倒计时: + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); // ← 超时:报错 + }, timeout); +}); +``` + +- **这是一场"竞速"**:两条路谁先到算谁—— + - **路 A**:机器人在 `timeout`(默认 5 秒)内回了消息 → 回调触发 → `resolve` 兑现,拿到数据。 + - **路 B**:超时了还没消息 → `setTimeout` 触发 → `reject` 报错。 + - 无论哪条先到,都会 `unsubscribe()` 退订并清掉对方(清 timer / 退订)——干净收尾。 + +**那机器人回的消息,是怎么走到这个回调里的?** 这就要回到站点 8 的 `client.ts`,但这次是**收消息**的那一半: + +📍 **`src/transport/rosbridge/client.ts` · `handleMessage()` · 第 216–273 行** + +```typescript +private handleMessage(data: string): void { + const msg = JSON.parse(data); // 收到的 JSON 文本 → 对象 + const op = msg.op as string | undefined; + switch (op) { // 按 op 分派 + case "publish": { // 订阅的话题来消息了 + const handlers = this.messageHandlers.get(msg.topic); + if (handlers) for (const h of handlers) h(msg.msg); // → 触发上面那个回调 + break; + } + case "service_response": // 服务调用的回复 + case "action_result": { + this.resolvePending(msg.id, msg); // 第 189 行:按 id 找到等待者并兑现 + break; + } + case "action_feedback": { ... } // 动作进度 + } +} +``` + +- 收到的每条消息都先 `JSON.parse` 变回对象,再看 `op` 字段**分派**: + - `op: "publish"`(订阅的话题有新消息)→ 找到登记在 `messageHandlers` 里的回调,挨个触发——**这就接回了上面 subscribe 的那个 `(msg) => { ... resolve(...) }`**。 + - `op: "service_response"` / `"action_result"` → 按消息里的 `id` 调 `resolvePending`(第 189 行),找到当初那个"在等这个 id 回复"的 Promise 并兑现。 +- **"按 id 配对"是核心套路**:发请求时记下 `id` 和一个"等待者",回复带着同样的 `id` 回来,就能精确找到该唤醒谁。站点 1 查能力用的 `callService` 也是靠这套机制拿到话题列表的。 + +把 `handleMessage` 这个"总机"的分派逻辑画出来: + +```dot +digraph dispatch { + rankdir=LR; + bgcolor="transparent"; + node [shape=box, style="rounded,filled", fontname="sans-serif", fontsize=10, margin="0.18,0.1"]; + edge [color="#64748b", fontname="sans-serif", fontsize=9]; + + inbound [label="服务器来消息\nJSON.parse → 看 op 字段", fillcolor="#ffedd5", color="#ea580c"]; + sw [label="switch(op)\n总机分派", shape=diamond, fillcolor="#fef9c3", color="#eab308"]; + + pub [label="op: publish\n订阅的话题有新消息", fillcolor="#e0e7ff", color="#6366f1"]; + resp [label="op: service_response /\naction_result", fillcolor="#e0e7ff", color="#6366f1"]; + + cb [label="找 messageHandlers 里的回调\n挨个触发 → subscribe 的 resolve(...)", fillcolor="#dcfce7", color="#16a34a"]; + rp [label="按 id 调 resolvePending\n唤醒在等这个 id 的 Promise", fillcolor="#dcfce7", color="#16a34a"]; + + inbound -> sw; + sw -> pub -> cb; + sw -> resp -> rp; +} +``` + +> 🔍 逐行详解:[第 16 篇 · ros2-subscribe.ts](16-ros2-subscribe.ts.md)(竞速 Promise);收发核心 [第 06 篇 · client.ts](06-rosbridge-client.ts.md)(`handleMessage` 的 `switch(op)`、`registerPending`/`resolvePending`) + +--- + +## 全站点速查表(随时回查) + +| 站点 | 文件 | 函数 | 行号 | 一句话 | 逐行详解 | +|---|---|---|---|---|---| +| 0 启动装配 | `index.ts` | `register()` | 17–39 | 注册 6 个零件 | [13](13-index.ts.md) | +| 1 能力注入 | `context/robot-context.ts` | `registerRobotContext` / `discoverCapabilities` | 26–49 / 55–98 | 把机器人能力塞进 AI 提示 | [23](23-robot-context.ts.md) | +| └ 发现走服务 | `transport/rosbridge/adapter.ts` | `listTopics()` | 102–112 | 调 `/rosapi/topics` 服务 | [10](10-rosbridge-adapter.ts.md) | +| 2 AI 选工具 | (OpenClaw 侧,非本仓库) | — | — | 人话 → 工具调用 | [14](14-tools-index.ts.md) | +| 3 安全拦截 | `safety/validator.ts` | `before_tool_call` 钩子 | 11–42 | 超速则 `block` | [22](22-safety-validator.ts.md) | +| 4 工具执行 | `tools/ros2-publish.ts` | `execute()` | 24–37 | 取参 → `transport.publish` | [15](15-ros2-publish.ts.md) | +| 5 取单例 | `service.ts` | `getTransport()` | 18–23 | 返回唯一连接(建于 94–101) | [12](12-service.ts.md) | +| 6 适配器 | `transport/rosbridge/adapter.ts` | `publish()` | 55–58 | 转交 `TopicPublisher` | [10](10-rosbridge-adapter.ts.md) | +| 7 拼协议 | `transport/rosbridge/topics.ts` | `TopicPublisher.publish()` | 15–22 | 组 `op:"publish"` JSON | [07](07-rosbridge-topics.ts.md) | +| 8 发送 | `transport/rosbridge/client.ts` | `send()` | 138–144 | `ws.send(JSON.stringify)` | [06](06-rosbridge-client.ts.md) | +| 9 机器人侧 | rosbridge_server → DDS | — | — | JSON → DDS → `/cmd_vel` | [01](../01-项目概览.md) | +| 返回 收消息 | `transport/rosbridge/client.ts` | `handleMessage()` | 216–273 | 按 `op`/`id` 分派回调 | [06](06-rosbridge-client.ts.md) | +| 返回 读工具 | `tools/ros2-subscribe.ts` | `execute()` | 22–42 | 竞速 Promise 等一条消息 | [16](16-ros2-subscribe.ts.md) | + +--- + +## 三个最该记住的"全局道理" + +读完这条主线,比记住具体行号更重要的是这三点——它们在 01–32 里会反复出现: + +1. **分层 + 同一接口**:工具不知道底层用 WebSocket 还是 WebRTC,因为三种传输模式都实现同一个 `RosTransport` 接口(站点 4→6)。换模式只改工厂里 `new` 哪个类,上层一行都不用动。 +2. **信息逐层改形、含义不变**:中文 → 工具调用 → JS 对象 → rosbridge JSON → WebSocket 帧 → DDS(看开头那张形态表)。每一层只负责"翻译成下一层听得懂的话"。 +3. **请求-响应靠 id 配对、订阅靠话题回调**:出站是一条直线;入站则是 `handleMessage` 这个"总机"按 `op`/`id` 把消息接回正确的等待者(返回路径)。 + +--- + +## 接下来怎么读 + +- **想从最基础的"合同"开始**:→ [第 01 篇 · plugin-api.ts](01-plugin-api.ts.md),然后按 01→32 顺序读,本系列每篇都假设你已掌握前面讲过的语法。 +- **想先看大图、再钻代码**:→ 顶层 [03 架构深度解析](../03-架构深度解析.md) 和 [04 核心代码导读](../04-核心代码导读.md)。 +- **想亲手跑起来、发一句话让机器人动**:→ 顶层 [05 完整实战教程](../05-完整实战教程.md)。 + +**这条主线会一直在这里。** 任何时候你在某一篇里"看不到全局"了,就回到本篇的速查表,找到那个文件的站点编号,你就知道它的上下游是谁。 + +下一份:[第 01 篇 · plugin-api.ts 逐行详解 →](01-plugin-api.ts.md)(一切的起点:OpenClaw 和插件之间的"合同") + +← 返回 [逐行详解系列 · 总览](README.md) diff --git a/learn/code/01-plugin-api.ts.md b/learn/code/01-plugin-api.ts.md new file mode 100644 index 0000000..2d4f42f --- /dev/null +++ b/learn/code/01-plugin-api.ts.md @@ -0,0 +1,496 @@ +# 逐行详解 ①:`plugin-api.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/plugin-api.ts](../../extensions/openclaw-plugin/src/plugin-api.ts) +> +> 这是推荐阅读顺序的第 1 个文件。它**不是程序逻辑,而是一份"合同"**——用 TypeScript 的类型系统,把 OpenClaw 这个平台和我们插件之间的约定写下来。读懂它,你就知道"OpenClaw 会给我们什么、我们要还给它什么"。 + +--- + +## 阅读本章前,先建立 3 个最基础的概念 + +这 3 个概念后面会反复出现,先用大白话讲清楚。 + +### 概念 1:什么是"类型"? + +在 TypeScript(简称 TS)里,每个数据都有"类型",就是"这个数据长什么样"的标签。 + +- `string` —— 字符串,也就是文字,比如 `"你好"`、`"/cmd_vel"`。 +- `number` —— 数字,比如 `3000`、`1.5`。 +- `boolean` —— 布尔值,只有两个取值:`true`(真)和 `false`(假)。 +- `void` —— "什么都不返回"。一个函数如果做完事不给你任何结果,它的返回类型就是 `void`。 + +### 概念 2:什么是 `interface`(接口)? + +`interface` 是 TS 的关键字,意思是"定义一种对象的形状"。它**只描述长什么样,不写具体怎么做**。 + +打个比方:`interface` 就像招聘启事——"我们要招一个人,他必须会做 A、会做 B"。它不关心具体是谁来做,只规定"必须具备哪些能力/字段"。 + +```typescript +interface 人 { + 名字: string; // 必须有一个叫"名字"的字段,且是文字 + 年龄: number; // 必须有一个叫"年龄"的字段,且是数字 +} +``` + +任何对象只要同时有 `名字`(文字)和 `年龄`(数字),就算"符合这个接口"。 + +### 概念 3:什么是"声明文件 / 类型声明"? + +这个文件开头的注释说得很清楚(第 1-7 行): + +```typescript +/** + * Type declarations matching the real OpenClaw plugin SDK. + * + * Only the subset used by the rosclaw plugin is declared here. + * These types mirror openclaw/plugin-sdk so that the plugin compiles + * without importing the SDK at build time (it is provided at runtime). + */ +``` + +**语法小课堂:`/** ... */` 是什么?** +以 `/*` 开头、`*/` 结尾的是"块注释",中间的内容计算机**完全忽略**,纯粹写给人看。开头是 `/**`(两个星号)的叫 **JSDoc 注释**,是一种约定俗成的、写给函数/类型的"文档说明"。 + +这段注释翻译过来是: +> 这些是与"真正的 OpenClaw 插件 SDK"相匹配的类型声明。这里只声明了 rosclaw 插件用到的那一小部分。这些类型是 `openclaw/plugin-sdk` 的"镜像",这样插件在**编译时**不需要真的去导入 SDK 就能通过类型检查(真正的实现是在**运行时**由 OpenClaw 提供的)。 + +用大白话说:**OpenClaw 平台运行的时候,会塞给我们一个真实的工具箱(`api` 对象)。但我们写代码时它还不在手边,所以我们先按它的样子画一张"图纸"(类型声明),照着图纸写代码就不会出错。** 这个文件就是那张图纸。 + +--- + +## 第 9 行:导入一个外部类型 + +```typescript +import type { TSchema } from "@sinclair/typebox"; +``` + +逐部分拆解: + +- `import` —— 关键字,"从别的文件/库里拿东西过来用"。 +- `type` —— 紧跟在 `import` 后面,强调"我只拿它的**类型**,不拿它的实际代码"。这是个优化:编译成最终运行的 JavaScript 时,这一行会被完全删掉(因为类型只在开发期检查用,运行时不需要)。 +- `{ TSchema }` —— 一对花括号里写"我要拿的东西的名字"。这里要拿的是 `TSchema`。 +- `from "@sinclair/typebox"` —— "从哪里拿"。`@sinclair/typebox` 是一个第三方库的名字(叫 TypeBox),用来描述"数据的结构和规则"。 +- 行末的 `;` —— 分号,表示"这条语句到此结束"。TS 里分号大多数时候可加可不加,本项目风格是加。 + +**`TSchema` 是什么?** 它代表"一份用 TypeBox 写出来的数据结构定义"。后面工具的 `parameters`(参数定义)字段就是这个类型——AI 调用工具时要填哪些参数、每个参数什么类型,都用它来描述。现在记住"它是参数结构的类型"即可。 + +--- + +## 第 13-17 行:日志记录器 `PluginLogger` + +```typescript +export interface PluginLogger { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} +``` + +**语法小课堂:`export` 是什么?** +`export` 意思是"导出"——把这个东西公开出去,让别的文件可以 `import` 它。没有 `export` 的东西只能在本文件内部用。这里 `export interface PluginLogger` 表示"我定义了一个叫 `PluginLogger` 的接口,并且允许别的文件使用它"。 + +逐行看接口内部: + +- `info(msg: string): void;` —— 规定:必须有一个叫 `info` 的**方法(函数)**。 + - `info` 是方法名。 + - 括号 `(msg: string)` 表示它接收一个**参数**,参数名叫 `msg`,类型是 `string`(文字)。`参数名: 类型` 是 TS 写参数的固定格式。 + - `: void` 在括号后面,表示这个方法的**返回类型是 `void`**——调用它,它只是默默打印日志,不会返回任何值给你。 +- `warn(msg: string): void;` —— 同理,一个叫 `warn`(警告)的方法。 +- `error(msg: string): void;` —— 一个叫 `error`(错误)的方法。 + +**整体含义**:OpenClaw 会给我们一个"日志对象",上面挂着三个方法。我们想打印普通信息就调 `api.logger.info("...")`,想打印警告就 `api.logger.warn("...")`,想打印错误就 `api.logger.error("...")`。这三档对应日志的严重程度。 + +> 第 11 行的 `// --- Logger ---` 是**单行注释**:以 `//` 开头,从这里到行尾都是给人看的说明,计算机忽略。这里只是个分隔小标题。 + +--- + +## 第 21-31 行:AI 工具的形状 `AgentTool` + +这是整个插件最核心的类型之一——它定义了"一个能被 AI 调用的工具长什么样"。 + +```typescript +export interface AgentTool { + name: string; + label: string; + description: string; + parameters: TSchema; + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +} +``` + +逐字段拆解: + +- `name: string;` —— 工具的**唯一 ID**,是文字。AI 在内部就是用这个名字来"点名调用"工具,比如 `"ros2_publish"`。 +- `label: string;` —— 工具的**显示名**,给人在界面上看的,比如 `"ROS2 Publish"`。 +- `description: string;` —— 工具的**说明书**。这一段文字会交给 AI 阅读,AI 靠它判断"什么时候该用这个工具、每个参数该怎么填"。**这段写得越精确,AI 调用得越准**,所以它极其重要。 +- `parameters: TSchema;` —— 工具**参数的结构定义**(就是上面导入的 `TSchema` 类型)。它规定了 AI 调用时要提供哪些参数、各是什么类型。 +- `execute(...)` —— 工具的**执行函数**,也就是"真正干活的代码"。当 AI 决定调用这个工具时,OpenClaw 就会调用这个 `execute`。 + +重点看 `execute` 的签名(也就是它的输入和输出): + +```typescript + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +``` + +它接收三个参数: + +1. `toolCallId: string` —— 本次调用的唯一编号(文字),用于追踪是哪一次调用。 +2. `params: Record` —— AI 实际填进来的参数。 + - **语法小课堂:`Record` 是什么?** + `Record` 是 TS 内置的一个"泛型类型",写成 `Record<键的类型, 值的类型>`,表示"一个对象,它的键是某种类型、值是另一种类型"。 + - `string` 是键的类型——意思是对象的字段名都是文字。 + - `unknown` 是值的类型——意思是"值是什么类型我现在不知道"。`unknown` 是 TS 里最"谨慎"的类型,表示"未知",用它之前你必须先检查/断言它到底是什么,不能直接拿来乱用。 + - 合起来 `Record` 就是"一个键是文字、值类型未知的普通对象",等价于 `{ [字段名: string]: unknown }`。这正好适合描述"AI 传进来的、我们还没核实过的一包参数"。 + - **什么是"泛型"?** 就是尖括号 `<...>` 里那部分,相当于给类型传"参数"。`Record` 本身是个模板,你用 `` 把模板里的空填上,得到一个具体类型。后面会反复见到尖括号,见到就想"这是在给类型传参"。 +3. `signal?: AbortSignal` —— 一个"中止信号",用来在任务执行到一半时取消它。 + - **语法小课堂:参数名后面的 `?` 是什么?** + `?` 表示"这个参数**可选**"——调用时可以传,也可以不传。没有 `?` 的参数是必填的。所以 `signal?` 意思是"中止信号你爱给不给"。 + - `AbortSignal` 是浏览器/Node.js 内置的一个类型,专门用于"取消异步操作",现在不深究。 + +返回类型是 `Promise`: + +- **语法小课堂:`Promise<...>` 是什么?这是理解本项目的关键。** + 很多操作不是一瞬间完成的,比如"通过网络问机器人要数据",需要等一会儿。TS/JS 用 `Promise`(承诺)来表示"一个将来才会有结果的值"。 + - 你可以把 `Promise` 想成一张"取餐号":你点了餐(发起了操作),拿到一张号(`Promise`),暂时没饭吃,但承诺将来饭好了凭号取。 + - `Promise` 的尖括号里写的是"将来兑现时给你的东西的类型"。所以这表示"将来会给你一个 `ToolResult`(工具执行结果)"。 + - 后面你会看到 `async` 和 `await` 两个关键字,它们就是专门用来优雅地"等 Promise 兑现"的,到时再细讲。 + +**整体含义**:一个工具 = 一个名字 + 一个标签 + 一段给 AI 看的说明 + 一份参数结构 + 一个"将来会返回结果"的执行函数。 + +--- + +## 第 33-36 行:工具的返回结果 `ToolResult` + +```typescript +export interface ToolResult { + content: ToolContent[]; + details?: unknown; +} +``` + +- `content: ToolContent[];` —— 给 AI 看的内容。 + - **语法小课堂:类型后面的 `[]` 是什么?** + `[]` 表示"数组",也就是"一串同类型的东西排成队"。`ToolContent[]` 就是"一个由若干 `ToolContent` 组成的数组/列表"。例如 `[内容1, 内容2]`。 + - 为什么是数组?因为一次返回可能包含多条内容,比如"一段文字 + 一张图片"(后面摄像头工具就是这样)。 +- `details?: unknown;` —— 可选的(注意那个 `?`)附加数据。类型是 `unknown`(未知结构)。这部分**给界面展示用,AI 不读**。 + +--- + +## 第 38-40 行:内容可以是文字或图片 `ToolContent` + +```typescript +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; +``` + +**语法小课堂:`type` 和 `interface` 有什么区别?** +`type` 也是用来定义类型的关键字。粗略说,`interface` 偏向描述"对象的形状",而 `type` 更万能,还能表达"这个 **或** 那个"这种组合。这里用 `type` 正是因为要表达"或"。 + +**语法小课堂:`|` 是"联合类型"(union),表示"或者"。** +开头那个 `=` 后面换行写的 `| ... | ...`,每个 `|` 引出一个可能的选项。整体读作:"`ToolContent` 是下面两种之一": + +- `{ type: "text"; text: string }` —— 第一种:一个对象,有 `type` 字段且值**正好是文字 `"text"`**,外加一个 `text` 字段放具体文字。 + - **语法小课堂:`type: "text"` 这里 `"text"` 当类型用是什么意思?** + 通常类型是 `string`(任意文字),但这里直接写了具体的 `"text"`,叫"字面量类型"——意思是"这个字段的值必须**恰好等于** `"text"` 这个词,别的文字都不行"。它起到"标签"的作用,让程序能一眼区分这是哪一种内容。 +- `{ type: "image"; data: string; mimeType: string }` —— 第二种:`type` 必须是 `"image"`,再加 `data`(图片数据,通常是 base64 编码的文字)和 `mimeType`(媒体类型,比如 `"image/jpeg"`)。 + +**整体含义**:工具返回的每条内容,要么是"文字",要么是"图片",通过 `type` 字段区分。这种"用一个固定标签字段区分多种形状"的写法叫**判别联合(discriminated union)**,后面还会再见到。 + +--- + +## 第 44-54 行:服务相关类型 + +```typescript +export interface ServiceContext { + config: Record; + stateDir: string; + logger: PluginLogger; +} + +export interface PluginService { + id: string; + start(ctx: ServiceContext): Promise; + stop?(ctx: ServiceContext): Promise; +} +``` + +先看 `ServiceContext`(服务上下文)——OpenClaw 启动一个服务时,会把一些"环境信息"打包给我们: + +- `config: Record;` —— 用户填的配置(未知结构的对象)。 +- `stateDir: string;` —— 一个目录路径(文字),服务可以把要持久保存的状态写在这里。 +- `logger: PluginLogger;` —— 又见到上面定义的日志器类型,方便服务打日志。 + +再看 `PluginService`(插件服务)——所谓"服务",是一段有**生命周期**的后台逻辑(比如"维持和机器人的连接"),它需要"启动"和"停止"两个动作: + +- `id: string;` —— 服务的唯一标识。 +- `start(ctx: ServiceContext): Promise;` —— 启动方法。接收一个上下文 `ctx`,返回 `Promise`(异步执行,做完不返回具体值)。 +- `stop?(ctx: ServiceContext): Promise;` —— 停止方法。注意方法名后面的 `?`:**整个 `stop` 方法是可选的**——有的服务不需要清理,就可以不提供 `stop`。 + +> 小结一下 `?` 出现在不同位置的含义: +> - `参数名?` → 这个**参数**可选(可不传)。 +> - `字段名?` 或 `方法名?` → 这个**字段/方法**可选(可不写)。 +> 本质都是"可有可无"。 + +--- + +## 第 58-80 行:命令相关类型 + +OpenClaw 里"命令"是用户直接敲 `/xxx` 触发的(比如 `/estop`),**不经过 AI**。这几段定义命令的相关类型。 + +```typescript +export interface CommandContext { + senderId?: string; + channel: string; + channelId?: string; + isAuthorizedSender: boolean; + args?: string; + commandBody: string; + config: Record; + from?: string; + to?: string; + accountId?: string; + messageThreadId?: number; +} +``` + +`CommandContext` 是"命令被触发时的现场信息",挑几个关键字段看(带 `?` 的都是可选): + +- `senderId?: string;` —— 谁发的(用户 ID),可选。 +- `channel: string;` —— 来自哪个渠道(如 `"telegram"`),必填。 +- `isAuthorizedSender: boolean;` —— 这个发送者是否**已授权**(`true`/`false`)。这对安全很重要——比如紧急停止命令,可能只允许授权用户用。 +- `args?: string;` —— 命令后面跟的参数文字。比如用户输入 `/transport rosbridge ws://x:9090`,`args` 就是 `"rosbridge ws://x:9090"`。可选(有的命令没参数)。 +- `commandBody: string;` —— 命令的完整原文。 +- `config: Record;` —— 配置对象。 +- 其余 `from / to / accountId / messageThreadId` 是不同消息平台的细节,可选,现在略过。 +- 注意 `messageThreadId?: number;` 这个是**数字**类型,其它大多是文字。 + +```typescript +export interface PluginCommand { + name: string; + description: string; + handler(ctx: CommandContext): Promise | CommandResult; +} +``` + +`PluginCommand`(一个命令的定义): + +- `name: string;` —— 命令名,比如 `"estop"`(用户敲 `/estop`)。 +- `description: string;` —— 命令说明(给人看)。 +- `handler(ctx: CommandContext): Promise | CommandResult;` —— 处理函数,命令被触发时执行。 + - 它接收上面的 `ctx`(现场信息)。 + - 返回类型是 `Promise | CommandResult`——又见到联合 `|`。意思是"返回值**要么**是一个 `Promise`(异步给结果),**要么**直接是一个 `CommandResult`(同步立刻给结果)"。这样写很贴心:你的命令逻辑如果是异步的就返回 Promise,是同步的就直接返回,两种都允许。 + +```typescript +export interface CommandResult { + text: string; +} +``` + +`CommandResult`(命令的执行结果)非常简单:就一个 `text` 字段,是要回给用户的文字。 + +--- + +## 第 84-103 行:钩子之一 —— "会话开始前" `before_agent_start` + +**先理解什么是"钩子(hook)"**:钩子是"在某个时机自动被调用的函数"。你把自己的函数"挂"在某个事件上,事件一发生,平台就替你调用它。`before_agent_start` 这个钩子,会在"AI 会话即将开始"这个时机触发——我们正好趁这时把机器人能力信息塞给 AI。 + +```typescript +export interface BeforeAgentStartEvent { + prompt: string; +} +``` + +`BeforeAgentStartEvent`——事件触发时带来的数据,这里就是 `prompt`(用户即将发给 AI 的提示文字)。 + +```typescript +export interface BeforeAgentStartResult { + prependContext?: string; +} +``` + +`BeforeAgentStartResult`——我们的钩子函数可以**返回**的东西: + +- `prependContext?: string;` —— 可选的一段文字。如果我们返回了它,OpenClaw 会把这段文字"**前置(prepend)**"插入到 AI 的系统提示最前面。我们正是用它来注入"机器人有哪些话题/服务可用"。 + +```typescript +export interface BeforeAgentStartContext { + agentId?: string; + sessionKey?: string; + sessionId?: string; + workspaceDir?: string; + messageProvider?: string; +} +``` + +`BeforeAgentStartContext`——额外的现场信息(会话 ID、工作目录等),全是可选,现在略过。 + +```typescript +export type BeforeAgentStartHandler = ( + event: BeforeAgentStartEvent, + ctx: BeforeAgentStartContext, +) => Promise | BeforeAgentStartResult | void; +``` + +这是**最绕的一行**,但拆开就不难。它用 `type` 定义了"这个钩子的处理函数长什么样"。 + +**语法小课堂:`(参数) => 返回类型` 是"函数类型"的写法。** +箭头 `=>` 把"输入"和"输出"分开:左边括号里是参数,右边是返回类型。注意这里是**描述一个函数的类型**,不是真的函数体。 + +- 参数部分: + - `event: BeforeAgentStartEvent` —— 第一个参数,是上面的事件数据。 + - `ctx: BeforeAgentStartContext` —— 第二个参数,是现场信息。 +- 返回部分(`=>` 右边):`Promise | BeforeAgentStartResult | void` + - 这里嵌了好几层"或",慢慢读:整体是三选一—— + 1. `Promise` —— 异步返回,将来兑现的可能是"一个结果"或"什么都不返回(void)"。 + 2. `BeforeAgentStartResult` —— 同步直接返回一个结果。 + 3. `void` —— 同步地什么都不返回。 + - 翻译成人话:**你的钩子函数,可以异步也可以同步,可以返回一段要注入的内容、也可以什么都不返回(那就不注入)。** 框架把所有合理写法都允许了,给你最大自由。 + +--- + +## 第 105-124 行:钩子之二 —— "工具调用前" `before_tool_call` + +这是**安全机制的核心钩子**:在每个工具真正执行**之前**触发,给我们一个"拦下来不让它跑"的机会。 + +```typescript +export interface BeforeToolCallEvent { + toolName: string; + params: Record; +} +``` + +`BeforeToolCallEvent`——事件数据: + +- `toolName: string;` —— 即将被调用的工具名字(比如 `"ros2_publish"`)。 +- `params: Record;` —— AI 准备传给那个工具的参数。我们可以**检查**这些参数,判断危不危险。 + +```typescript +export interface BeforeToolCallResult { + block?: boolean; + blockReason?: string; +} +``` + +`BeforeToolCallResult`——我们钩子返回的东西: + +- `block?: boolean;` —— 可选。如果设为 `true`,就**拦截**这次工具调用(不让它执行)。 +- `blockReason?: string;` —— 可选。拦截的理由(文字)。这段理由会被回给 AI,AI 再解释给用户听(比如"速度超过安全上限,已阻止")。 + +```typescript +export interface BeforeToolCallContext { + agentId?: string; + sessionKey?: string; + toolName: string; +} +``` + +`BeforeToolCallContext`——现场信息,含会话标识和工具名,略过。 + +```typescript +export type BeforeToolCallHandler = ( + event: BeforeToolCallEvent, + ctx: BeforeToolCallContext, +) => Promise | BeforeToolCallResult | void; +``` + +和前面那个钩子的函数类型**结构完全一样**,只是把类型换成了 `BeforeToolCall...` 系列。返回部分同样是"异步/同步、返回结果/不返回"四种组合都允许。 + +- **返回一个结果**(里面 `block: true`)→ 拦截工具。 +- **返回 `void`(什么都不返回)** → 放行,工具照常执行。 + +这就是安全校验的工作方式:检查参数 → 危险就返回拦截结果 → 安全就什么都不返回。 + +--- + +## 第 128-138 行:总入口 `OpenClawPluginApi`(最重要的接口) + +前面定义的所有类型,都是为了这个"总工具箱"。OpenClaw 运行时塞给我们的那个 `api` 对象,就符合这个接口。 + +```typescript +export interface OpenClawPluginApi { + pluginConfig?: Record; + logger: PluginLogger; + + registerTool(tool: AgentTool, opts?: { name?: string; names?: string[]; optional?: boolean }): void; + registerService(service: PluginService): void; + registerCommand(command: PluginCommand): void; + + on(hookName: "before_agent_start", handler: BeforeAgentStartHandler): void; + on(hookName: "before_tool_call", handler: BeforeToolCallHandler): void; +} +``` + +逐成员拆解: + +- `pluginConfig?: Record;` —— 用户在界面上填的**原始配置**(可选,未知结构)。注意它是"生的"——还没校验过,所以类型是 `unknown`。我们后面会用 `config.ts` 里的工具去校验它。 +- `logger: PluginLogger;` —— 那个日志器(`info/warn/error` 三件套)。 + +接下来三个是**注册方法**——"向 OpenClaw 登记我们提供的东西": + +- `registerTool(tool: AgentTool, opts?: {...}): void;` —— 注册一个工具。 + - 第一个参数 `tool: AgentTool` 就是我们上面讲过的工具定义。 + - 第二个参数 `opts?` 是可选的额外选项,它的类型直接写在原地:`{ name?: string; names?: string[]; optional?: boolean }`。 + - **语法小课堂:类型可以"就地内联"写。** 不一定非得先 `interface` 再引用,简单的对象类型可以直接写在花括号里。这里表示"一个对象,可能有 `name`(文字)、`names`(文字数组)、`optional`(布尔),都可选"。 + - 返回 `void`:登记完不返回东西。 +- `registerService(service: PluginService): void;` —— 注册一个服务(前面的 `PluginService`)。 +- `registerCommand(command: PluginCommand): void;` —— 注册一个 `/` 命令。 + +最后两个 `on(...)` 是**挂钩子**的方法。注意它出现了**两次**,名字都叫 `on`: + +- `on(hookName: "before_agent_start", handler: BeforeAgentStartHandler): void;` +- `on(hookName: "before_tool_call", handler: BeforeToolCallHandler): void;` + +**语法小课堂:为什么同名 `on` 写两遍?这叫"函数重载(overload)"。** +同一个方法,根据第一个参数传的具体值不同,第二个参数要求的类型也不同: + +- 如果你调用 `api.on("before_agent_start", ...)`(第一个参数正好是字面量 `"before_agent_start"`),那么第二个参数 `handler` 必须是 `BeforeAgentStartHandler` 类型。 +- 如果你调用 `api.on("before_tool_call", ...)`,第二个参数就必须是 `BeforeToolCallHandler` 类型。 + +TS 会根据你传的钩子名,自动要求你提供对应正确类型的处理函数,传错了会在编译期报错。这就是字面量类型(`"before_agent_start"` 当类型用)的威力——它让方法"看名下菜"。 + +--- + +## 整章回顾:这个文件到底说了什么? + +用一句话总结:**它画出了 OpenClaw 和我们插件之间的全部"接口契约"。** + +把这些类型按用途归类,你的脑子里就有一张地图了: + +| 类别 | 类型 | 作用 | +|---|---|---| +| 日志 | `PluginLogger` | 打印 info/warn/error | +| 工具 | `AgentTool` / `ToolResult` / `ToolContent` | 定义 AI 可调用的工具、它的返回值、返回内容(文字或图片) | +| 服务 | `PluginService` / `ServiceContext` | 定义有启停生命周期的后台服务 | +| 命令 | `PluginCommand` / `CommandContext` / `CommandResult` | 定义用户敲 `/xxx` 触发的命令 | +| 钩子 | `BeforeAgentStart*` / `BeforeToolCall*` | 定义"会话前注入上下文""工具执行前拦截"两个时机 | +| 总入口 | `OpenClawPluginApi` | 把上面全部串起来:`registerTool/Service/Command` 注册东西,`on` 挂钩子 | + +**你只要记住一件事**:整份文件没有一行"真正干活的代码",全是"形状描述"。真正的实现由 OpenClaw 在运行时提供。我们后面写的每个文件,本质都是在"填这些形状"——造一个符合 `AgentTool` 的工具、写一个符合 `PluginService` 的服务,等等。 + +**语法点回顾清单**(这一章你新学到的): +- `/* */`、`/** */`、`//` 三种注释 +- `import type { X } from "..."` 只导入类型 +- `export` 导出 +- `interface` 定义对象形状;`type` 更万能、能表达"或" +- `string` / `number` / `boolean` / `void` / `unknown` 基础类型 +- `字段: 类型`、`参数: 类型`、`): 返回类型` 的标注位置 +- `?` 表示可选(参数/字段/方法) +- `[]` 表示数组 +- `Record` 表示键值对象 +- `Promise` 表示"将来才有的值" +- `|` 联合类型("或") +- `"具体值"` 当类型用 = 字面量类型 +- 判别联合(用一个标签字段区分多种形状) +- 函数类型写法 `(参数) => 返回` +- 函数重载(同名方法写多个签名) + +下一份:[`config.ts` 逐行详解 →](02-config.ts.md) diff --git a/learn/code/02-config.ts.md b/learn/code/02-config.ts.md new file mode 100644 index 0000000..d1d7133 --- /dev/null +++ b/learn/code/02-config.ts.md @@ -0,0 +1,320 @@ +# 逐行详解 ②:`config.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/config.ts](../../extensions/openclaw-plugin/src/config.ts) +> +> 推荐阅读顺序第 2 个文件。它负责一件事:**定义"用户能填哪些配置、每项的默认值是什么",并提供一个函数把用户填的原始数据校验成可靠的配置对象。** + +--- + +## 先理解这个文件要解决的问题 + +用户在界面上会填一堆配置:连接机器人的地址、速度上限、机器人名字……但用户填的东西**不可信**: + +- 可能漏填(那就该用默认值)。 +- 可能填错类型(该填数字却填了文字)。 +- 可能填了我们不认识的模式。 + +我们需要一个"守门员":把用户填的"生数据"检查一遍,缺的补上默认值,错的直接拒绝。这个文件用一个叫 **Zod** 的库来当这个守门员。 + +**什么是 Zod?** 它是一个"运行时数据校验库"。你先用它描述"我期望的数据长什么样"(这份描述叫 **schema / 模式**),然后把真实数据丢给它检验。它和上一章的 `interface` 有个根本区别: + +- `interface` 只在**开发期/编译期**检查,程序真正运行时它已经被删掉了,**管不到运行时来的真实数据**(比如用户输入)。 +- Zod 是真正的代码,在**运行时**执行,能拦住运行时进来的脏数据。 + +所以:`interface` 管"我们自己写的代码对不对",Zod 管"外面进来的数据对不对"。两者配合。 + +--- + +## 第 1 行:导入 Zod + +```typescript +import { z } from "zod"; +``` + +- `import { z } from "zod"` —— 从名为 `"zod"` 的库里,导入一个叫 `z` 的东西。 +- 注意这里**没有** `type` 关键字(对比上一章的 `import type`)。因为 `z` 不是类型,而是一个**真实的对象**,我们要在运行时真的调用它身上的方法(`z.object(...)`、`z.string()` 等)。它必须留到运行时,不能被删掉。 +- `z` 是 Zod 库约定俗成的名字,可以理解成"Zod 的工具台",所有构建 schema 的工具都挂在它身上。 + +--- + +## 第 3-7 行:一个小 schema —— ICE 服务器 + +```typescript +const IceServerSchema = z.object({ + urls: z.union([z.string(), z.array(z.string())]), + username: z.string().optional(), + credential: z.string().optional(), +}); +``` + +**语法小课堂:`const` 是什么?** +`const` 声明一个**常量**——一个起了名字、且**之后不会被重新赋值**的变量。`const IceServerSchema = ...` 就是"造一个叫 `IceServerSchema` 的常量,它的值是右边那一坨"。声明变量还有 `let`(可改)和过时的 `var`,本项目几乎只用 `const`,因为"不可变"更安全、更可预测。 + +> 这个 `IceServerSchema` 没有 `export`,所以只在本文件内部用——它是给下面 webrtc 配置当"零件"的。 + +逐行看里面: + +- `z.object({ ... })` —— 调用 `z` 的 `object` 方法,"我要描述一个**对象**,它有以下这些字段"。花括号里就是字段清单。 +- `urls: z.union([z.string(), z.array(z.string())]),` —— 描述一个叫 `urls` 的字段。 + - `z.union([...])` —— "联合",表示"这个字段可以是下面几种类型之一"(对应上一章 TS 的 `|`,这里是 Zod 的运行时版本)。 + - `[z.string(), z.array(z.string())]` —— 方括号是一个**数组**,里面列出允许的两种形态: + - `z.string()` —— 一个字符串(比如单个地址 `"stun:..."`)。 + - `z.array(z.string())` —— 一个"字符串的数组"(比如多个地址 `["...", "..."]`)。 + - 合起来:`urls` 既可以是一个地址,也可以是一串地址。 +- `username: z.string().optional(),` —— 字段 `username`,类型是字符串,但 **`.optional()`** 表示"可以不填"。 + - **语法小课堂:`.方法()` 这种"点号链式调用"是什么?** + `z.string()` 先得到一个"字符串 schema",然后在它后面接 `.optional()`,是在这个 schema 的基础上"再加一条规则:可省略"。这种"造好一个东西后用点号继续在它身上加工"的写法叫**链式调用(chaining)**,整份文件大量使用。可以读成"字符串、并且可选"。 +- `credential: z.string().optional(),` —— 同理,字段 `credential`(密码/凭据),字符串、可选。 + +这个小 schema 描述的是 WebRTC 用到的 STUN/TURN 服务器配置,现在不用懂它的网络含义,重点是**看懂 Zod 描述数据的写法**。 + +--- + +## 第 9 行:主配置 schema 开始 + +```typescript +export const RosClawConfigSchema = z.object({ +``` + +- `export const RosClawConfigSchema = z.object({` —— 导出一个常量 `RosClawConfigSchema`,它是一个 Zod 对象 schema。这是**整个插件的配置总表**。 +- 花括号 `{` 开始列字段,一直到第 63 行的 `})` 才结束。中间分成 `transport / rosbridge / local / webrtc / robot / safety` 六大块,每块自己又是一个 `z.object`(对象套对象,叫"嵌套")。 + +下面逐块讲。 + +--- + +## 第 10-14 行:`transport`(传输模式) + +```typescript + transport: z + .object({ + mode: z.enum(["rosbridge", "local", "webrtc"]).default("rosbridge"), + }) + .default({}), +``` + +- `transport: z.object({ ... }).default({}),` —— 一个叫 `transport` 的字段,它本身又是个对象。 + - 注意这里把链式调用**换行**写了:`z` 在一行,`.object({...})` 一段,`.default({})` 又一段。换行只是排版,连起来读就是 `z.object({...}).default({})`。 +- 里面只有一个字段 `mode`: + - `z.enum(["rosbridge", "local", "webrtc"])` —— **枚举**。意思是"这个字段的值**只能是**列出的这几个之一",填别的就报错。这里限定传输模式只能三选一。 + - `.default("rosbridge")` —— 如果用户没填 `mode`,就默认用 `"rosbridge"`。 +- 外层的 `.default({})` 是这一块设计上的**关键技巧**: + - `{}` 是一个**空对象**(什么字段都没有)。 + - `.default({})` 表示"如果用户连 `transport` 这一整块都没填,就当他填了个空对象 `{}`"。 + - 而空对象会触发里面每个字段各自的 `.default(...)`——于是 `mode` 又变回 `"rosbridge"`。 + - **效果**:一层层的默认值像多米诺骨牌一样被触发,最终保证"即使用户什么都不填,也能得到一份完整、合理的配置"。这就是文档里说的"`parseConfig({})` 也能正常工作"的原理。 + +--- + +## 第 16-22 行:`rosbridge`(WebSocket 连接配置) + +```typescript + rosbridge: z + .object({ + url: z.string().default("ws://localhost:9090"), + reconnect: z.boolean().default(true), + reconnectInterval: z.number().default(3000), + }) + .default({}), +``` + +- `url: z.string().default("ws://localhost:9090"),` —— 连接地址,字符串,默认是本机的 `ws://localhost:9090`(`ws://` 是 WebSocket 协议的前缀,`localhost` 指本机,`9090` 是端口号,是 rosbridge 的默认端口)。 +- `reconnect: z.boolean().default(true),` —— 是否自动重连,布尔值,默认 `true`(开启)。 +- `reconnectInterval: z.number().default(3000),` —— 重连间隔,数字,默认 `3000`(单位毫秒,即 3 秒)。 +- 末尾同样 `.default({})`,整块可省略。 + +--- + +## 第 24-28 行:`local`(本机直连 DDS) + +```typescript + local: z + .object({ + domainId: z.number().default(0), + }) + .default({}), +``` + +- `domainId: z.number().default(0),` —— ROS2 的"域 ID",数字,默认 `0`。(ROS2 用域 ID 把不同机器人/系统隔离开,默认大家都在 0 号域。)现在记住"它是一个数字配置项"即可。 +- 整块可省略(`.default({})`)。 + +--- + +## 第 30-40 行:`webrtc`(点对点远程连接) + +```typescript + webrtc: z + .object({ + signalingUrl: z.string().default(""), + apiUrl: z.string().default(""), + robotId: z.string().default(""), + robotKey: z.string().default(""), + iceServers: z + .array(IceServerSchema) + .default([{ urls: "stun:stun.l.google.com:19302" }]), + }) + .default({}), +``` + +- 前四个字段 `signalingUrl / apiUrl / robotId / robotKey` 都是字符串,默认值都是 `""`(**空字符串**,即"一个字都没有的文字")。默认留空意味着"用户不配 WebRTC 就不填,这些值是空的"。 +- `iceServers: z.array(IceServerSchema).default([...]),` —— 这里把前面定义的小 schema 用上了: + - `z.array(IceServerSchema)` —— "一个数组,里面每个元素都必须符合 `IceServerSchema` 的形状"。这就是为什么前面要先单独定义那个小 schema——为了在这里当"数组元素的模板"复用。 + - `.default([{ urls: "stun:stun.l.google.com:19302" }])` —— 默认值是一个**含一个元素的数组**:`[ {...} ]`。那一个元素 `{ urls: "stun:stun.l.google.com:19302" }` 是个对象,提供了一个公共的 Google STUN 服务器地址。 +- 整块可省略。 + +--- + +## 第 42-47 行:`robot`(机器人身份) + +```typescript + robot: z + .object({ + name: z.string().default("Robot"), + namespace: z.string().default(""), + }) + .default({}), +``` + +- `name: z.string().default("Robot"),` —— 机器人名字,字符串,默认 `"Robot"`。这个名字会出现在注入给 AI 的提示里("你正连接着一台叫 XXX 的机器人")。 +- `namespace: z.string().default(""),` —— 命名空间,字符串,默认空。 + - ROS2 用"命名空间"给话题名加前缀,实现多机器人隔离。比如命名空间是 `/robot1`,那它的速度话题就是 `/robot1/cmd_vel` 而不是 `/cmd_vel`。默认空表示不加前缀。 + +--- + +## 第 49-62 行:`safety`(安全限制)—— 含一层嵌套 + +```typescript + safety: z + .object({ + maxLinearVelocity: z.number().default(1.0), + maxAngularVelocity: z.number().default(1.5), + workspaceLimits: z + .object({ + xMin: z.number().default(-10), + xMax: z.number().default(10), + yMin: z.number().default(-10), + yMax: z.number().default(10), + }) + .default({}), + }) + .default({}), +``` + +- `maxLinearVelocity: z.number().default(1.0),` —— **最大线速度**(直线行进速度),数字,默认 `1.0`(米/秒)。安全钩子会用它来拦截超速指令。 +- `maxAngularVelocity: z.number().default(1.5),` —— **最大角速度**(转弯/旋转速度),数字,默认 `1.5`(弧度/秒)。 +- `workspaceLimits: z.object({ ... }).default({}),` —— **工作空间边界**,它本身又是一个嵌套对象(对象套对象套对象,到这里是第三层了)。里面四个数字定义了一个矩形活动范围: + - `xMin: z.number().default(-10),` —— X 方向最小值,默认 `-10`。 + - `xMax: z.number().default(10),` —— X 方向最大值,默认 `10`。 + - `yMin / yMax` 同理,Y 方向的 `-10` 到 `10`。 + - 即默认允许机器人在一个 20×20 的方形区域内活动。 + - **语法小课堂:负数 `-10` 怎么读?** `-` 是负号,`-10` 就是负十。没什么特别,只是数字。 +- 这一块同样层层 `.default({})`,全可省略。 + +--- + +## 第 63 行:主 schema 结束 + +```typescript +}); +``` + +- `}` 关闭最外层 `z.object({` 的花括号。 +- `)` 关闭 `z.object(` 的圆括号。 +- `;` 语句结束。 + +到这里,`RosClawConfigSchema` 这份"配置总表"就定义完了。 + +--- + +## 第 65 行:从 schema **反推**出 TS 类型 + +```typescript +export type RosClawConfig = z.infer; +``` + +这一行很巧妙,是 Zod 最受欢迎的特性,逐部分拆: + +- `export type RosClawConfig = ...` —— 导出一个**类型**叫 `RosClawConfig`。 +- `z.infer<...>` —— `infer` 意思是"推断"。`z.infer<某个schema>` 的作用是:**自动从一个 Zod schema 推算出对应的 TS 静态类型**。 +- `typeof RosClawConfigSchema` —— **语法小课堂:`typeof` 是什么?** + - `RosClawConfigSchema` 是一个**值**(一个真实存在的常量对象)。 + - 但 `z.infer<...>` 尖括号里需要的是一个**类型**。 + - `typeof 某个值` 这个写法,作用是"取出这个值的类型",把值"翻译"成类型,好让它能放进尖括号里。 +- 整句合起来:**"看看 `RosClawConfigSchema` 这个 schema 描述的数据长啥样,自动生成一个对应的 TS 类型,命名为 `RosClawConfig`。"** + +**为什么要这样做?** 这是为了**"单一事实来源"**:我们只在一个地方(Zod schema)描述配置结构,TS 类型自动跟着生成。如果以后改了 schema(比如加一个配置项),对应的 TS 类型会**自动更新**,永远不会和 schema 不一致。如果手动再写一遍 `interface`,就得改两处、容易忘记其一。 + +推断出来的 `RosClawConfig` 大致等价于: + +```typescript +type RosClawConfig = { + transport: { mode: "rosbridge" | "local" | "webrtc" }; + rosbridge: { url: string; reconnect: boolean; reconnectInterval: number }; + local: { domainId: number }; + // ...等等 +}; +``` + +注意:因为所有字段都有 `.default()`,推断出来的类型里这些字段都是**必有**的(不是可选的)——因为经过校验后它们一定有值了。 + +--- + +## 第 67-73 行:守门员函数 `parseConfig` + +```typescript +/** + * Parse and validate raw plugin config against the RosClaw schema. + * Returns a fully-defaulted, typed config object. + */ +export function parseConfig(raw: Record): RosClawConfig { + return RosClawConfigSchema.parse(raw); +} +``` + +先看注释(第 67-70 行): +> 根据 RosClaw schema 解析并校验"生的"插件配置。返回一个"补全了所有默认值的、带类型的"配置对象。 + +再看函数本体: + +- `export function parseConfig(...)` —— **语法小课堂:`function` 怎么定义函数?** + - `function` 是定义函数的关键字。 + - `parseConfig` 是函数名。 + - 括号里是参数,`): 类型` 是返回类型,花括号 `{ }` 里是函数体(真正执行的代码)。 +- 参数 `raw: Record` —— 接收一个"生的"配置对象。回忆上一章:`Record` = "键是文字、值类型未知的对象",正好描述"用户填的、还没校验的脏数据"。参数名取 `raw`(生的、未加工的)很贴切。 +- 返回类型 `: RosClawConfig` —— 承诺"我会返回一个符合 `RosClawConfig` 类型的、干净的配置对象"。 +- 函数体只有一行: + - `return RosClawConfigSchema.parse(raw);` + - **语法小课堂:`return` 是什么?** `return` 表示"把后面这个值作为函数的结果交出去,并结束函数"。 + - `RosClawConfigSchema.parse(raw)` —— 调用 schema 的 `.parse()` 方法,把生数据 `raw` 丢进去校验。`.parse()` 会做三件事: + 1. **校验**:检查每个字段类型对不对、枚举值合不合法。 + 2. **补默认值**:缺的字段用 `.default(...)` 补上。 + 3. **要么成功返回干净对象,要么直接抛出异常**:如果数据有无法接受的错误(比如 `mode` 填了 `"foobar"` 这种不在枚举里的值),`.parse()` 会**抛出错误(throw)**,整个插件加载就会失败并报错。 + - 这正是文档里强调的"**Zod 解析失败会直接抛出异常——插件不会带着错误配置悄悄运行**"。宁可一开始就响亮地报错,也不要带着坏配置偷偷跑起来导致更难查的问题。 + +> **`throw`(抛出异常)是什么?** 当代码遇到无法继续的错误,它可以"抛出"一个异常,就像拉响警报。如果没人"接住"(用 `try/catch`,后面文件会见到),程序就会中断并打印错误。这里我们故意不接住——配置错了就该让插件启动失败。 + +--- + +## 整章回顾 + +这个文件做了三件事: + +1. **用 Zod 描述配置结构**(`RosClawConfigSchema`)——六大块配置,每项都带默认值,层层 `.default({})` 保证"全空也能跑"。 +2. **从 schema 自动推断 TS 类型**(`RosClawConfig`)——一处定义,类型自动同步,杜绝不一致。 +3. **提供守门员函数**(`parseConfig`)——把用户的脏数据校验+补全成干净配置,错就报错。 + +它和上一章的关系:上一章 `OpenClawPluginApi` 里有个 `pluginConfig?: Record`(用户填的生配置)。我们拿到它之后,第一件事就是丢给这里的 `parseConfig` 洗干净,之后全程用洗干净的 `RosClawConfig`。这个交接动作发生在下一个要读的关键文件 `index.ts` 里。 + +**语法点回顾清单**(本章新增): +- `const` / `let` / `var`:声明变量,本项目偏爱 `const`(不可变) +- 不带 `type` 的 `import { z } from "zod"`:导入运行时真实存在的值 +- Zod 基础:`z.object` / `z.string` / `z.number` / `z.boolean` / `z.enum` / `z.array` / `z.union` +- 链式调用 `.方法().方法()`,尤其 `.optional()` 和 `.default(值)` +- 空对象 `{}`、空字符串 `""`、数组字面量 `[...]` +- `z.infer`:从 schema 反推类型 +- `typeof 值`:把"值"翻译成"类型" +- `function 名字(参数): 返回类型 { ... }`:定义函数 +- `return`:交出结果 +- `throw` / 抛出异常的概念(Zod 校验失败时) + +下一份:[`transport/types.ts` 逐行详解 →](03-transport-types.ts.md)(第三批开始进入传输层) diff --git a/learn/code/03-transport-types.ts.md b/learn/code/03-transport-types.ts.md new file mode 100644 index 0000000..cf45ff9 --- /dev/null +++ b/learn/code/03-transport-types.ts.md @@ -0,0 +1,365 @@ +# 逐行详解 ③:`transport/types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/types.ts](../../extensions/openclaw-plugin/src/transport/types.ts) +> +> 推荐阅读顺序第 3 个文件。它是**传输层的"数据字典"**——把"发布一条消息要带哪些信息""调用服务的结果长什么样""三种连接模式各自的配置"全部用类型定义好。和第①篇 `plugin-api.ts` 一样,这里**全是类型声明,没有一行干活的逻辑**。 + +--- + +## 先搞清楚"传输层"是干什么的 + +回忆一下整个项目的链路: + +``` +用户 → OpenClaw → RosClaw 插件 → (某种连接方式) → ROS2 机器人 +``` + +那个"某种连接方式"就是**传输层**。它可能是 WebSocket(rosbridge)、可能是本机直连(DDS)、可能是 WebRTC。不管底层用哪种,上层的工具代码都希望"用一样的方式去发布消息、调用服务"。 + +要做到"用一样的方式",就得先约定好**统一的数据格式**——发布消息时该提供什么、得到的结果长什么样。这个文件就是定义这些数据格式的。它本身不连接任何东西,只是"词汇表"。 + +> 第 1-3 行的 JSDoc 注释:「RosClaw 传输抽象层的共享类型」。"共享(shared)"是关键词——这些类型会被传输层的很多文件 `import` 复用。 + +--- + +## 第 7 行:连接状态 `ConnectionStatus` + +```typescript +export type ConnectionStatus = "disconnected" | "connecting" | "connected"; +``` + +- 用 `type` 定义一个类型 `ConnectionStatus`。 +- 右边是三个**字面量类型**用 `|`(或)连起来——回忆第①篇:把具体的字符串当类型用,表示"值只能恰好是这几个词之一"。 +- 含义:连接状态**只能是**这三种之一: + - `"disconnected"` —— 已断开 + - `"connecting"` —— 连接中 + - `"connected"` —— 已连上 +- 为什么不直接用 `string`?因为如果用 `string`,谁都能填 `"abc"` 这种乱七八糟的状态。限定成这三个,写错了 TS 立刻报错。这是字面量联合类型最常见的用途:**当"有限的几个选项"用**(相当于一个轻量级枚举)。 + +--- + +## 第 9 行:消息处理器 `MessageHandler` + +```typescript +export type MessageHandler = (msg: Record) => void; +``` + +- 这是一个**函数类型**(回忆第①篇:`(参数) => 返回类型` 的写法描述"一个函数长什么样")。 +- 拆解: + - 参数 `msg: Record` —— 收到的消息,是个"键是文字、值未知"的对象(因为 ROS2 消息可以是任意结构)。 + - 返回 `void` —— 处理完不返回任何值。 +- 含义:**"消息处理器"是一种函数,它接收一条消息、处理它、不返回东西。** 后面订阅话题时,我们会写一个这样的函数交给传输层:"每来一条消息,就用我这个函数处理"。这种"事后被调用的函数"通俗叫**回调函数(callback)**。 + +> **什么是回调?** 你把一个函数"交出去",不是自己马上调用它,而是让别人"将来在合适的时机替你调用"。就像你留个电话号码给餐厅:"菜好了打这个号码"——电话号码就是回调,餐厅将来会"回拨"。 + +--- + +## 第 11 行:连接状态处理器 `ConnectionHandler` + +```typescript +export type ConnectionHandler = (status: ConnectionStatus) => void; +``` + +- 同样是函数类型(回调)。 +- 参数 `status: ConnectionStatus` —— 接收一个连接状态(就是上面那三选一)。 +- 返回 `void`。 +- 含义:**一种"连接状态变化时被调用"的回调函数。** 连接从"连接中"变成"已连上"时,传输层会调用我们登记的这种函数,并把新状态传进来。我们就能据此打日志、刷新界面等。 + +--- + +## 第 13-16 行:订阅句柄 `Subscription` + +```typescript +/** Returned by subscribe(); call unsubscribe() to stop receiving messages. */ +export interface Subscription { + unsubscribe(): void; +} +``` + +- 先看注释:「由 subscribe() 返回;调用 unsubscribe() 来停止接收消息」。 +- `interface Subscription` —— 定义一个对象形状。 +- 里面只有一个方法 `unsubscribe(): void;` —— "取消订阅",无参数、无返回。 +- 含义:当你订阅一个话题,传输层会给你一个 `Subscription` 对象(就像给你一张"订阅凭证")。等你不想再收消息了,就调用这张凭证上的 `unsubscribe()` 来退订。 +- **设计要点**:为什么要返回一个"句柄对象",而不是用话题名字来退订?因为同一个话题**可能被订阅好几次**。如果用话题名退订,会分不清要退哪一个。而每次订阅返回一个独立的 `Subscription`,就能**精确退订其中某一个**,互不影响。(文档第 372-377 行也强调了这点。) + +> **"句柄(handle)"是什么?** 一个代表"某个资源/操作"的小对象,你拿着它就能对那个资源做操作。这里 `Subscription` 就是"这次订阅"的句柄,拿着它能取消这次订阅。 + +--- + +## 第 20-24 行:发布选项 `PublishOptions` + +```typescript +export interface PublishOptions { + topic: string; + type: string; + msg: Record; +} +``` + +发布一条消息到话题时要提供的信息: + +- `topic: string;` —— 话题名(比如 `"/cmd_vel"`),必填。 +- `type: string;` —— 消息类型(比如 `"geometry_msgs/msg/Twist"`),必填。ROS2 里每个话题都有固定的消息类型。 +- `msg: Record;` —— 消息体本身,是个任意结构的对象(因为不同类型的消息字段完全不同)。 + +> **为什么把参数打包成一个"选项对象",而不是写成三个独立参数?** +> 对比两种写法: +> - `publish(topic, type, msg)` —— 调用时得记住顺序,容易传错。 +> - `publish({ topic, type, msg })` —— 调用时每个值都有名字,一目了然,且以后加新选项不影响老代码。 +> 本项目统一用"选项对象"风格,所以才有这么多 `XxxOptions` 接口。 + +--- + +## 第 28-33 行:订阅选项 `SubscribeOptions` + +```typescript +export interface SubscribeOptions { + topic: string; + type?: string; + throttleRate?: number; + queueLength?: number; +} +``` + +订阅话题时的选项: + +- `topic: string;` —— 话题名,必填。 +- `type?: string;` —— 消息类型,**可选**(注意 `?`)。为什么可选?因为 rosbridge 通常能自己推断出话题类型,不一定要我们指定。 +- `throttleRate?: number;` —— **节流速率**,可选,单位毫秒。意思是"两条消息之间至少间隔这么久"。用于高频话题(如里程计 `/odom` 每秒几十条),限制频率避免被淹没。 +- `queueLength?: number;` —— 队列长度,可选。rosbridge 服务端为这个订阅缓存多少条消息。 + +--- + +## 第 37-46 行:服务调用的选项和结果 + +ROS2 的"服务(service)"是**请求-响应**模式:你发一个请求,它给你一个回应(类似打电话问一件事、对方答复)。 + +```typescript +export interface ServiceCallOptions { + service: string; + type?: string; + args?: Record; +} +``` + +调用服务的选项: + +- `service: string;` —— 服务名,必填。 +- `type?: string;` —— 服务类型,可选。 +- `args?: Record;` —— 请求参数,可选的任意对象(有的服务不需要参数)。 + +```typescript +export interface ServiceCallResult { + result: boolean; + values?: Record; +} +``` + +服务返回的结果: + +- `result: boolean;` —— 是否成功(`true`/`false`),必有。 +- `values?: Record;` —— 服务返回的实际数据,可选(有的服务只表示成功失败,没有数据要返回)。 + +--- + +## 第 50-60 行:动作的选项和结果 + +ROS2 的"动作(action)"用于**耗时较长、还能反馈进度、可中途取消**的任务(比如"导航到某点",可能要走几十秒,途中不断报告进度)。 + +```typescript +export interface ActionGoalOptions { + action: string; + actionType: string; + args?: Record; + onFeedback?: (feedback: Record) => void; +} +``` + +发送动作目标的选项: + +- `action: string;` —— 动作名,必填。 +- `actionType: string;` —— 动作类型,**必填**(注意这里没有 `?`,和服务不同——动作类型不能省略)。 +- `args?: Record;` —— 目标参数,可选(比如目标坐标)。 +- `onFeedback?: (feedback: Record) => void;` —— **可选的进度回调**。 + - 它的类型是一个函数(回调):接收一个 `feedback` 对象、返回 `void`。 + - 如果你提供了这个回调,那么动作服务器每推送一次进度,传输层就会调用你这个函数把进度交给你。 + - 如果不提供(可选),就单纯等动作做完、不接收中途进度。 + +```typescript +export interface ActionResult { + result: boolean; + values?: Record; +} +``` + +动作完成后的结果——结构和 `ServiceCallResult` 一模一样:`result`(成功与否)+ 可选的 `values`(结果数据)。 + +--- + +## 第 64-77 行:三种"自省"信息 + +"自省(introspection)"指**让机器人告诉我们它有哪些能力**——有哪些话题、服务、动作可用。这三个接口形状完全相同,都是"名字 + 类型": + +```typescript +export interface TopicInfo { + name: string; + type: string; +} + +export interface ServiceInfo { + name: string; + type: string; +} + +export interface ActionInfo { + name: string; + type: string; +} +``` + +- `TopicInfo` —— 一个话题的信息:`name`(如 `"/cmd_vel"`)+ `type`(如 `"geometry_msgs/msg/Twist"`)。 +- `ServiceInfo` —— 一个服务的信息,同样是名字 + 类型。 +- `ActionInfo` —— 一个动作的信息,同样是名字 + 类型。 + +> **为什么三个长得一样还要分别定义?** 虽然现在形状相同,但它们**含义不同**(一个是话题、一个是服务、一个是动作)。分开定义,代码读起来语义更清楚,而且将来某一个要加字段时不会牵连另外两个。 + +--- + +## 第 81-113 行:三种传输模式各自的配置 + +接下来定义三种连接模式各自需要的配置。**注意每个里面都有一个 `mode` 字段,且值是一个固定的字符串**——这是后面要讲的"判别联合"的关键。 + +### 第 81-89 行:rosbridge 模式配置 + +```typescript +export interface RosbridgeTransportConfig { + mode: "rosbridge"; + rosbridge: { + url: string; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; + }; +} +``` + +- `mode: "rosbridge";` —— **判别字段**。它的类型是字面量 `"rosbridge"`,意思是"这个对象的 `mode` 必须恰好是 `"rosbridge"`"。这个字段就像贴在盒子上的标签。 +- `rosbridge: { ... };` —— 一个**内联的嵌套对象类型**(回忆第①篇:简单对象类型可以直接就地写在花括号里,不必单独 `interface`)。里面: + - `url: string;` —— 连接地址,必填。 + - `reconnect?: boolean;` —— 是否重连,可选。 + - `reconnectInterval?: number;` —— 重连间隔,可选。 + - `maxReconnectAttempts?: number;` —— 最多重连几次,可选。 + +### 第 91-96 行:local 模式配置 + +```typescript +export interface LocalTransportConfig { + mode: "local"; + local?: { + domainId?: number; + }; +} +``` + +- `mode: "local";` —— 标签固定为 `"local"`。 +- `local?: { domainId?: number };` —— 可选的本地配置,里面 `domainId` 也可选。注意整个 `local` 字段都带 `?`——因为本地模式可以完全用默认值,啥也不配。 + +### 第 98-107 行:webrtc 模式配置 + +```typescript +export interface WebRTCTransportConfig { + mode: "webrtc"; + webrtc: { + signalingUrl: string; + apiUrl: string; + robotId: string; + robotKey: string; + iceServers?: RTCIceServerConfig[]; + }; +} +``` + +- `mode: "webrtc";` —— 标签固定为 `"webrtc"`。 +- `webrtc: { ... };` —— WebRTC 的配置,前四个字段都是必填字符串(信令地址、API 地址、机器人 ID、密钥),最后: + - `iceServers?: RTCIceServerConfig[];` —— 可选的 ICE 服务器**数组**(注意 `[]`),数组元素的类型是下面紧接着定义的 `RTCIceServerConfig`。 + +### 第 109-113 行:ICE 服务器配置 `RTCIceServerConfig` + +```typescript +export interface RTCIceServerConfig { + urls: string | string[]; + username?: string; + credential?: string; +} +``` + +- `urls: string | string[];` —— 地址,类型是 `string | string[]`,即"一个字符串 **或** 一个字符串数组"(对应第②篇 Zod 里那个 `z.union`)。 +- `username?` / `credential?` —— 可选的用户名和凭据。 + +> 你会发现这个接口和第②篇 `config.ts` 里的 `IceServerSchema` 描述的是同一种东西——一个是 Zod 运行时校验版,一个是 TS 类型版。 + +--- + +## 第 115-119 行:把三者合成"判别联合" `TransportConfig`(本章重点) + +```typescript +/** Discriminated union of all transport configurations. */ +export type TransportConfig = + | RosbridgeTransportConfig + | LocalTransportConfig + | WebRTCTransportConfig; +``` + +- 注释:「所有传输配置的判别联合」。 +- 用 `type` 定义 `TransportConfig`,它是上面三个配置接口用 `|`(或)连起来——意思是"一份传输配置,是这三种之一"。 + +**重点理解"判别联合(discriminated union)"——为什么它这么好用?** + +注意三个接口的共同点:它们都有一个 `mode` 字段,且值各不相同(`"rosbridge"` / `"local"` / `"webrtc"`)。这个充当"标签"的字段叫**判别字段(discriminant)**。 + +它带来的好处是:**TypeScript 能根据 `mode` 的值,自动推断出当前到底是哪一种配置**。看个例子(这正是后面 `factory.ts` 和 `service.ts` 会写的代码): + +```typescript +function 处理(config: TransportConfig) { + if (config.mode === "rosbridge") { + // 在这个 if 内部,TS 100% 确定 config 是 RosbridgeTransportConfig + // 所以你能安全访问 config.rosbridge.url,TS 不会报错 + console.log(config.rosbridge.url); + } else if (config.mode === "webrtc") { + // 这里 TS 又确定 config 是 WebRTCTransportConfig + console.log(config.webrtc.robotId); + } +} +``` + +这种"根据一个标签字段,自动缩小到具体类型"的能力叫**类型收窄(narrowing)**。它让我们写多模式处理代码时又安全又省心——不会出现"在 rosbridge 分支里手滑访问了 webrtc 才有的字段"这种错误,TS 会当场拦下。 + +--- + +## 整章回顾 + +这个文件是传输层的"数据字典",把所有跨模式共享的数据结构定义清楚,分几类: + +| 类别 | 类型 | 作用 | +|---|---|---| +| 连接 | `ConnectionStatus` / `MessageHandler` / `ConnectionHandler` / `Subscription` | 连接状态、两种回调函数、订阅句柄 | +| 发布 | `PublishOptions` | 发布消息要带的信息 | +| 订阅 | `SubscribeOptions` | 订阅话题的选项(含节流/队列) | +| 服务 | `ServiceCallOptions` / `ServiceCallResult` | 请求-响应式调用 | +| 动作 | `ActionGoalOptions` / `ActionResult` | 长任务,含进度回调 `onFeedback` | +| 自省 | `TopicInfo` / `ServiceInfo` / `ActionInfo` | 机器人能力发现 | +| 配置 | `RosbridgeTransportConfig` / `LocalTransportConfig` / `WebRTCTransportConfig` / `RTCIceServerConfig` / `TransportConfig` | 三种模式配置 + 判别联合 | + +这些类型本身不"做"任何事,但它们是下一篇 `transport.ts`(统一接口)和整个传输层所有文件的"共同语言"。读懂它们,后面的代码才有意义。 + +**语法点回顾清单**(本章新增/巩固): +- 字面量联合当"轻量枚举"用:`"a" | "b" | "c"` +- 回调函数 / 函数类型:`(参数) => void` +- "句柄对象"的设计思路(返回一个能操作资源的小对象) +- "选项对象"参数风格(`XxxOptions`)vs 散参数 +- 内联嵌套对象类型 `字段: { 子字段: 类型 }` +- `string | string[]`:单个值或数组 +- **判别联合**:用共同的标签字段(如 `mode`)区分多种形状 +- **类型收窄**:`if (x.mode === "...")` 后 TS 自动确定具体类型 + +下一份:[`transport/transport.ts` 逐行详解 →](04-transport.ts.md)(传输层的统一接口契约) diff --git a/learn/code/04-transport.ts.md b/learn/code/04-transport.ts.md new file mode 100644 index 0000000..ace7d9a --- /dev/null +++ b/learn/code/04-transport.ts.md @@ -0,0 +1,262 @@ +# 逐行详解 ④:`transport/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/transport.ts](../../extensions/openclaw-plugin/src/transport/transport.ts) +> +> 推荐阅读顺序第 4 个文件。它定义了**整个传输层的"统一接口" `RosTransport`**——这是全项目最重要的一个接口之一。它依旧是纯类型声明(没有实现),但它是所有传输模式(rosbridge / local / webrtc)都必须遵守的"契约"。 + +--- + +## 先理解这个文件的地位 + +回忆第③篇讲的:传输层有三种底层实现方式,但上层工具希望"用一样的方式操作"。 + +这个文件就是定义那个"一样的方式"——它写出一份接口 `RosTransport`,规定"任何一个传输实现,都必须提供这些方法(连接、发布、订阅、调用服务……)"。 + +打个比方: + +- `RosTransport` 是**插座标准**(规定插孔形状、电压)。 +- rosbridge、local、webrtc 三种实现是**三种不同的发电方式**(火电、水电、太阳能)。 +- 上层工具是**电器**。 + +只要三种发电方式都按同一个插座标准供电,电器插上去就能用,根本不关心电是怎么发的。这就是"接口/抽象"最核心的价值:**让使用者和实现者解耦**。 + +--- + +## 第 1-15 行:导入一堆类型 + +```typescript +import type { + ConnectionStatus, + ConnectionHandler, + Subscription, + PublishOptions, + SubscribeOptions, + ServiceCallOptions, + ServiceCallResult, + ActionGoalOptions, + ActionResult, + TopicInfo, + ServiceInfo, + ActionInfo, + MessageHandler, +} from "./types.js"; +``` + +- `import type { ... } from "./types.js"` —— 从同目录的 `types.js` 导入一批**类型**(`import type` 表示只拿类型,回忆第①篇)。 +- 花括号里换行列出了 13 个名字——全是上一篇 `types.ts` 里定义的那些类型。这里把它们都拿过来,因为下面定义接口时要用到。 +- **语法小课堂:为什么是 `"./types.js"` 而不是 `"./types.ts"`?** + - `./` 表示"当前目录"(`../` 表示"上一级目录")。所以 `./types` 指"本文件旁边那个 types 文件"。 + - 后缀写 `.js` 而不是 `.ts`,看起来很反直觉(我们明明导入的是 `.ts` 文件)。这是现代 ESM(ES 模块)标准 + 本项目 `NodeNext` 模块解析的规矩:**导入路径要写最终运行时的扩展名 `.js`**,即便源文件是 `.ts`。TS 编译时会自动对应过去。你现在只要记住"本项目内部导入一律写 `.js` 后缀"即可,照抄不会错。 + +--- + +## 第 17-23 行:接口的文档注释 + +```typescript +/** + * Unified transport interface for ROS2 communication. + * + * All deployment modes (local DDS, rosbridge WebSocket, WebRTC data channel) + * implement this interface so that plugin tools work identically regardless + * of the underlying transport. + */ +``` + +翻译: +> ROS2 通信的统一传输接口。所有部署模式(本地 DDS、rosbridge WebSocket、WebRTC 数据通道)都实现这个接口,这样插件工具无论底层用哪种传输,操作方式都完全一致。 + +这正是上面"插座标准"那个比方的官方表述。 + +--- + +## 第 24 行:接口声明开始 + +```typescript +export interface RosTransport { +``` + +- 导出一个接口 `RosTransport`。从这里到第 70 行的 `}` 之间,列出所有"任何传输实现都必须提供的方法"。 +- 接口里只写**方法的签名**(名字、参数、返回类型),不写方法体——因为这是"要求",不是"实现"。具体怎么连、怎么发,是各个实现文件的事。 + +下面按功能分组逐方法看。每个方法前面都有一行 `/** ... */` 注释说明它干嘛。 + +--- + +## 第 25-37 行:连接生命周期(4 个方法) + +```typescript + // --- Connection lifecycle --- + + /** Establish the transport connection. */ + connect(): Promise; +``` + +- `connect(): Promise;` —— 「建立传输连接」。 + - 无参数。 + - 返回 `Promise`:这是个**异步**操作(连接需要时间),将来完成、但不返回具体值。 + - 回忆第①篇:`Promise` 是"将来才有的结果",`void` 是"没有结果",合起来就是"将来会做完,但不给你东西"。 + +```typescript + /** Gracefully close the transport connection. */ + disconnect(): Promise; +``` + +- `disconnect(): Promise;` —— 「优雅地关闭连接」。同样异步、无返回值。"优雅地(gracefully)"意味着会做好清理(通知服务端、释放资源),而不是粗暴切断。 + +```typescript + /** Get current connection status. */ + getStatus(): ConnectionStatus; +``` + +- `getStatus(): ConnectionStatus;` —— 「获取当前连接状态」。 + - 注意返回类型是 `ConnectionStatus`(上一篇那个三选一),**不是 `Promise`**——因为查询当前状态是**瞬间**就能拿到的,不需要等待,所以是同步方法。 + - **对比体会**:`connect()` 要等所以返回 `Promise`;`getStatus()` 立刻有答案所以直接返回值。是否包 `Promise`,取决于"这事要不要等"。 + +```typescript + /** Register a connection status change handler. Returns a cleanup function. */ + onConnection(handler: ConnectionHandler): () => void; +``` + +- `onConnection(handler: ConnectionHandler): () => void;` —— 「注册一个连接状态变化的处理器。返回一个清理函数」。逐部分看: + - 参数 `handler: ConnectionHandler` —— 接收一个回调(上一篇定义的 `ConnectionHandler`,即 `(status) => void`)。意思是"你给我一个函数,状态一变我就调用它"。 + - 返回类型 `() => void` —— **重点**:返回的是**一个函数**! + - `() => void` 是函数类型:无参数、无返回值的函数。 + - 注释说这是"cleanup function(清理函数)"。意思是:你登记了一个监听器,`onConnection` 给你回一个"取消登记"的小函数。将来你不想再听了,调用这个返回的函数就能注销。 + - **语法小课堂:函数可以"返回另一个函数"。** 在 JS/TS 里函数是"一等公民"——可以当参数传、可以当返回值返回、可以存进变量。这里 `onConnection` 接收一个函数(`handler`)又返回一个函数(清理器),是非常典型的用法。和上一篇 `Subscription.unsubscribe()` 是同一个思路(给你一个"取消"的手段),只不过这里用"返回一个函数"实现,那里用"返回一个带方法的对象"实现。 + +--- + +## 第 39-45 行:话题(2 个方法) + +```typescript + // --- Topics --- + + /** Publish a message to a ROS2 topic. */ + publish(options: PublishOptions): void; +``` + +- `publish(options: PublishOptions): void;` —— 「向 ROS2 话题发布一条消息」。 + - 参数 `options: PublishOptions` —— 一个发布选项对象(上一篇定义的,含 `topic`/`type`/`msg`)。 + - 返回 `void`,而且**不是 `Promise`**!这很关键:发布是"**发完即忘(fire-and-forget)**"——把消息扔出去就完事,不等待机器人确认。ROS2 话题本来就是这种"广播"语义,没有回执。所以它既不异步也无返回。 + +```typescript + /** Subscribe to a ROS2 topic. Returns a Subscription handle. */ + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription; +``` + +- `subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription;` —— 「订阅一个话题,返回一个订阅句柄」。 + - 两个参数: + 1. `options: SubscribeOptions` —— 订阅选项(话题名、可选的类型/节流等)。 + 2. `handler: MessageHandler` —— 消息回调(上一篇定义的 `(msg) => void`)。每来一条消息就调用它。 + - 返回 `Subscription` —— 那个"订阅凭证"句柄(上一篇定义),拿着它可以 `unsubscribe()` 退订。 + - 同样**不是 `Promise`**:订阅这个动作是即时建立的,之后消息靠回调陆续送来,不需要"等一个结果"。 + +--- + +## 第 47-50 行:服务(1 个方法) + +```typescript + // --- Services --- + + /** Call a ROS2 service and return the result. */ + callService(options: ServiceCallOptions): Promise; +``` + +- `callService(options: ServiceCallOptions): Promise;` —— 「调用一个 ROS2 服务并返回结果」。 + - 参数 `options: ServiceCallOptions` —— 服务调用选项(服务名、类型、参数)。 + - 返回 `Promise` —— **这次是 `Promise` 了**!因为服务是"请求-响应":发出请求后要**等**对方答复,所以异步;将来兑现时给你一个 `ServiceCallResult`(含 `result` 成功与否、`values` 返回数据)。 + - **再次对比**:`publish` 不等(`void`),`callService` 要等(`Promise<结果>`)。是不是 `Promise`,永远取决于"需不需要等一个回应"。 + +--- + +## 第 52-58 行:动作(2 个方法) + +```typescript + // --- Actions --- + + /** Send a goal to a ROS2 action server. */ + sendActionGoal(options: ActionGoalOptions): Promise; +``` + +- `sendActionGoal(options: ActionGoalOptions): Promise;` —— 「向动作服务器发送一个目标」。 + - 参数 `options: ActionGoalOptions` —— 动作目标选项(动作名、类型、参数,以及可选的进度回调 `onFeedback`)。 + - 返回 `Promise` —— 异步,等动作**完全做完**后兑现,给你最终结果 `ActionResult`。 + - (动作可能耗时几十秒到几分钟,所以这个 `Promise` 可能要等很久。中途的进度则通过 `onFeedback` 回调实时给你,不走这个返回值。) + +```typescript + /** Cancel an in-progress action goal. */ + cancelActionGoal(action: string): Promise; +``` + +- `cancelActionGoal(action: string): Promise;` —— 「取消一个进行中的动作目标」。 + - 参数 `action: string` —— 要取消哪个动作(用动作名指定)。注意这里参数直接是个字符串,不是选项对象——因为只需要一个信息,没必要包装。 + - 返回 `Promise` —— 异步(取消请求要发给服务端并等其受理),做完不返回具体值。 + +--- + +## 第 60-69 行:自省(3 个方法) + +```typescript + // --- Introspection --- + + /** List all available ROS2 topics. */ + listTopics(): Promise; +``` + +- `listTopics(): Promise;` —— 「列出所有可用话题」。 + - 无参数。 + - 返回 `Promise` —— 异步(要向机器人查询),兑现时给一个 `TopicInfo` **数组**(注意 `[]`),即一串"话题名+类型"。 + +```typescript + /** List all available ROS2 services. */ + listServices(): Promise; + + /** List all available ROS2 action servers. */ + listActions(): Promise; +} +``` + +- `listServices(): Promise;` —— 列出所有服务,返回 `ServiceInfo` 数组。 +- `listActions(): Promise;` —— 列出所有动作,返回 `ActionInfo` 数组。 +- 最后的 `}` 关闭 `interface RosTransport` 的花括号,接口定义完毕。 + +这三个方法就是第⑫篇"机器人上下文注入"会用到的——会话开始时并行调用它们,把机器人能力查出来喂给 AI。 + +--- + +## 一张表看懂所有方法(以及"要不要等"的规律) + +| 方法 | 参数 | 返回 | 要等吗? | +|---|---|---|---| +| `connect()` | 无 | `Promise` | 要等(连接耗时) | +| `disconnect()` | 无 | `Promise` | 要等 | +| `getStatus()` | 无 | `ConnectionStatus` | **不等**(瞬间查询) | +| `onConnection(handler)` | 回调 | `() => void`(清理函数) | **不等**(登记动作) | +| `publish(options)` | 选项 | `void` | **不等**(发完即忘) | +| `subscribe(options, handler)` | 选项+回调 | `Subscription`(句柄) | **不等**(建立订阅) | +| `callService(options)` | 选项 | `Promise` | 要等(请求-响应) | +| `sendActionGoal(options)` | 选项 | `Promise` | 要等(长任务) | +| `cancelActionGoal(action)` | 字符串 | `Promise` | 要等 | +| `listTopics/Services/Actions()` | 无 | `Promise<...[]>` | 要等(远程查询) | + +**规律总结**:凡是"需要等机器人/服务端回应"的,返回 `Promise`;凡是"本地瞬间完成或发完不管"的,直接返回值。读接口时养成习惯——看到 `Promise` 就知道"这步要 `await` 等待",看到直接返回就知道"这步立刻有结果"。 + +--- + +## 整章回顾 + +- `RosTransport` 是**全项目传输层的统一契约**。它用纯接口规定了 13 个方法,覆盖:连接生命周期、话题、服务、动作、自省五大块。 +- 它的价值是**解耦**:上层工具只认这个接口,底层换成 rosbridge / local / webrtc 任何一种都行,工具代码一行不用改。 +- 后面你会看到: + - `rosbridge/adapter.ts` 写 `class RosbridgeTransport implements RosTransport`(用 `implements` 声明"我实现了这份契约")。 + - `local/transport.ts`、`webrtc/transport.ts` 同样 `implements RosTransport`。 + - 三者方法签名必须和这里完全对得上,否则 TS 报错——这就是接口"强制约束实现"的作用。 + +**语法点回顾清单**(本章新增/巩固): +- 内部导入路径写 `.js` 后缀(即便源文件是 `.ts`)、`./` 当前目录、`../` 上级目录 +- 接口里只写方法签名、不写方法体 +- "返回一个函数":`(): () => void`(函数是一等公民,可当返回值) +- 同步 vs 异步的判断:看返回值是不是 `Promise`,本质看"要不要等回应" +- 用注释(`/** */`)给每个方法写一句说明的习惯 + +下一份:[`transport/rosbridge/types.ts` 逐行详解 →](05-rosbridge-types.ts.md)(开始进入唯一完整实现的 rosbridge,先看它的协议消息格式) diff --git a/learn/code/05-rosbridge-types.ts.md b/learn/code/05-rosbridge-types.ts.md new file mode 100644 index 0000000..cc23ed2 --- /dev/null +++ b/learn/code/05-rosbridge-types.ts.md @@ -0,0 +1,247 @@ +# 逐行详解 ⑤:`transport/rosbridge/types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/types.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/types.ts) +> +> 推荐阅读顺序第 5 个文件。它把 **rosbridge 协议**里"在 WebSocket 上来回传的每一种消息长什么样"用类型定义出来。理解它,你才能看懂下一篇 `client.ts` 里收发的那些 `{ op: "..." }` 对象。 + +--- + +## 先理解 rosbridge 协议是什么 + +我们的插件和机器人之间隔着一个叫 **rosbridge_server** 的中间件,它把 ROS2 的世界"翻译"成 WebSocket 上的 **JSON 消息**。双方约定:每条消息都是一个 JSON 对象,且都有一个 `op` 字段(operation,操作类型),用 `op` 来表示"这条消息要干什么"。 + +比如: + +```json +{ "op": "publish", "topic": "/cmd_vel", "msg": { ... } } // 我要发布 +{ "op": "subscribe", "topic": "/odom" } // 我要订阅 +{ "op": "call_service", "id": "x_1", "service": "/reset" } // 我要调用服务 +``` + +这个文件就是把这些消息格式逐一写成 TS 接口。 + +> 第 1-4 行注释,连同 `@see` 给了官方协议文档链接。`@see` 是 JSDoc 标记,意思是"参见此处"。 + +--- + +## 第 8-15 行:客户端选项 & 连接状态 + +```typescript +export interface RosbridgeClientOptions { + url: string; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export type ConnectionStatus = "disconnected" | "connecting" | "connected"; +``` + +- `RosbridgeClientOptions` —— 创建 WebSocket 客户端时的配置:`url`(必填,连接地址)+ 三个可选项(是否重连、重连间隔、最多重连几次)。下一篇 `client.ts` 的构造函数就吃这个。 +- `ConnectionStatus` —— 和第③篇那个一模一样的三选一状态。(这里又定义了一遍,是因为 rosbridge 模块想"自给自足",不强依赖上层类型。) + +--- + +## 第 19-22 行:所有消息的"基类" `RosbridgeMessage` + +```typescript +export interface RosbridgeMessage { + op: string; + id?: string; +} +``` + +- `op: string;` —— 每条 rosbridge 消息**都有**的操作类型字段。 +- `id?: string;` —— 可选的消息 ID(用于把"请求"和"响应"配对,后面服务/动作会用到)。 + +这个接口是后面所有具体消息的"共同底座"。下面马上用到一个新语法把它"继承"过去。 + +--- + +## 第 26-31 行:发布消息 `PublishMessage`(第一次见 `extends`) + +```typescript +export interface PublishMessage extends RosbridgeMessage { + op: "publish"; + topic: string; + msg: Record; + type?: string; +} +``` + +**语法小课堂:`interface A extends B` 是"接口继承"。** +`extends` 意思是"扩展/继承"。`PublishMessage extends RosbridgeMessage` 表示:"`PublishMessage` 拥有 `RosbridgeMessage` 的所有字段(`op`、`id?`),并在此基础上**再加/收紧**一些字段。" 这样就不用把 `op`、`id` 在每个消息里重写一遍——继承自基座即可。 + +逐字段看: + +- `op: "publish";` —— **覆盖收紧**。基座里 `op` 是宽泛的 `string`,这里把它收紧成字面量 `"publish"`,意思是"这种消息的 `op` 必须恰好是 `publish`"。这又是判别字段(回忆第③篇判别联合)。 + - **语法小课堂:子接口可以"收紧"父接口的字段。** `"publish"` 是 `string` 的一个具体子集,所以这种收紧是合法的——把"任意文字"缩小为"只能是 publish 这个文字"。 +- `topic: string;` —— 发布到哪个话题。 +- `msg: Record;` —— 消息体(任意结构)。 +- `type?: string;` —— 可选的消息类型。 + +合起来,`PublishMessage` 实际拥有的字段是:`op`(="publish")、`id?`(继承来的)、`topic`、`msg`、`type?`。 + +--- + +## 第 33-46 行:订阅 / 取消订阅 + +```typescript +export interface SubscribeMessage extends RosbridgeMessage { + op: "subscribe"; + topic: string; + type?: string; + throttle_rate?: number; + queue_length?: number; + fragment_size?: number; + compression?: string; +} +``` + +- `op: "subscribe";` —— 标签固定为 `"subscribe"`。 +- `topic` 必填,其余全可选。 +- **注意字段名的风格**:`throttle_rate`、`queue_length`、`fragment_size` 用了**下划线命名(snake_case)**,而不是项目里常见的**驼峰命名(camelCase,如 `throttleRate`)**。 + - **语法小课堂:命名风格。** `camelCase`(小驼峰,单词间用大写字母分隔,如 `throttleRate`)是 JS/TS 的习惯;`snake_case`(蛇形,单词间用下划线,如 `throttle_rate`)是很多其它语言/协议的习惯。 + - **为什么这里用下划线?** 因为这些字段**必须严格匹配 rosbridge 协议规定的名字**——协议用的就是 `throttle_rate`。我们的内部类型(第③篇 `SubscribeOptions`)用 `throttleRate`(驼峰),到了这一层贴着协议就得改成 `throttle_rate`。这个"驼峰 ↔ 下划线"的转换,下一批的 `topics.ts` 会做。 + +```typescript +export interface UnsubscribeMessage extends RosbridgeMessage { + op: "unsubscribe"; + topic: string; +} +``` + +- 取消订阅:标签 `"unsubscribe"` + 一个 `topic`。极简。 + +```typescript +export interface TopicMessage extends RosbridgeMessage { + op: "publish"; + topic: string; + msg: Record; +} +``` + +- `TopicMessage` —— **服务端推给我们的话题消息**。它的 `op` 也是 `"publish"`(rosbridge 协议里,服务端把订阅到的消息也用 `publish` 操作推回来)。结构和 `PublishMessage` 几乎一样,只是少了 `type`。语义上它代表"收到的消息"而非"我要发的消息"。 + +--- + +## 第 56-68 行:服务调用 / 响应 + +```typescript +export interface ServiceCallMessage extends RosbridgeMessage { + op: "call_service"; + service: string; + args?: Record; + type?: string; +} +``` + +- 调用服务的请求:标签 `"call_service"`,带服务名、可选参数、可选类型。它会继承 `id?`——而且服务调用**强烈依赖** `id`,因为要靠它把响应配对回来。 + +```typescript +export interface ServiceResponseMessage extends RosbridgeMessage { + op: "service_response"; + service: string; + values?: Record; + result: boolean; +} +``` + +- 服务的响应:标签 `"service_response"`,带服务名、可选的返回数据 `values`、以及 `result`(成功与否)。服务端回这条时会带上和请求相同的 `id`,我们据此知道"这是哪个请求的回复"。 + +--- + +## 第 72-95 行:动作的四种消息 + +动作(长任务)有四种消息:发目标、收进度、收结果、取消。 + +```typescript +export interface ActionGoalMessage extends RosbridgeMessage { + op: "send_action_goal"; + action: string; + action_type: string; + args?: Record; +} +``` + +- 发送动作目标:标签 `"send_action_goal"`,带动作名、动作类型(`action_type`,又是下划线命名贴协议)、可选参数。 + +```typescript +export interface ActionFeedbackMessage extends RosbridgeMessage { + op: "action_feedback"; + action: string; + values: Record; +} +``` + +- 进度反馈(服务端推来):标签 `"action_feedback"`,带动作名和 `values`(这里 `values` **没有** `?`,是必有的——既然推了反馈,就一定带内容)。 + +```typescript +export interface ActionResultMessage extends RosbridgeMessage { + op: "action_result"; + action: string; + values?: Record; + result: boolean; +} +``` + +- 最终结果:标签 `"action_result"`,结构和服务响应类似(可选 `values` + `result`)。同样靠 `id` 配对。 + +```typescript +export interface ActionCancelMessage extends RosbridgeMessage { + op: "cancel_action_goal"; + action: string; +} +``` + +- 取消动作:标签 `"cancel_action_goal"` + 动作名。 + +--- + +## 第 99-112 行:自省信息(又是三个一样的) + +```typescript +export interface TopicInfo { name: string; type: string; } +export interface ServiceInfo { name: string; type: string; } +export interface ActionInfo { name: string; type: string; } +``` + +和第③篇 `transport/types.ts` 里那三个同名接口结构相同(名字 + 类型)。rosbridge 模块自己再定义一份,保持模块独立。 + +--- + +## 第 116-118 行:两个回调类型 + +```typescript +export type MessageHandler = (msg: Record) => void; +export type ConnectionHandler = (status: ConnectionStatus) => void; +``` + +和第③篇那两个回调类型一模一样:消息处理器、连接状态处理器。下一篇 `client.ts` 会大量用到。 + +--- + +## 整章回顾 + +这个文件是 rosbridge 协议的"TypeScript 镜像"。核心规律只有一条: + +> **每种消息都继承自 `RosbridgeMessage`(拿到 `op`、`id?`),再用字面量把 `op` 收紧成自己专属的操作名,并加上该消息特有的字段。** + +| 我们 → 服务端(请求) | 服务端 → 我们(响应/推送) | +|---|---| +| `PublishMessage` (op=publish) | `TopicMessage` (op=publish,收到的话题消息) | +| `SubscribeMessage` (op=subscribe) | `ServiceResponseMessage` (op=service_response) | +| `UnsubscribeMessage` (op=unsubscribe) | `ActionFeedbackMessage` (op=action_feedback) | +| `ServiceCallMessage` (op=call_service) | `ActionResultMessage` (op=action_result) | +| `ActionGoalMessage` (op=send_action_goal) | | +| `ActionCancelMessage` (op=cancel_action_goal) | | + +记住这张"消息对照表",下一篇 `client.ts` 里 `switch (msg.op)` 处理各种 `case` 时你就一眼能对上号。 + +**语法点回顾清单**(本章新增): +- `interface A extends B`:接口继承(拿到 B 的所有字段再扩展) +- 子接口"收紧"父字段:把 `op: string` 收紧为 `op: "publish"` +- 命名风格:`camelCase`(驼峰,JS 习惯)vs `snake_case`(下划线,贴协议) +- `@see` JSDoc 标记 + +下一份:[`transport/rosbridge/client.ts` 逐行详解 →](06-rosbridge-client.ts.md)(全项目最复杂的文件,第一次大量出现真正的执行逻辑) diff --git a/learn/code/06-rosbridge-client.ts.md b/learn/code/06-rosbridge-client.ts.md new file mode 100644 index 0000000..b099137 --- /dev/null +++ b/learn/code/06-rosbridge-client.ts.md @@ -0,0 +1,855 @@ +# 逐行详解 ⑥:`transport/rosbridge/client.ts`(最难的一篇) + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/client.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/client.ts) +> +> 推荐阅读顺序第 6 个文件,也是**全项目最复杂、最值得细读**的一个。它是底层 WebSocket 连接的完整管理者,负责四件事:**①连接与重连 ②消息路由 ③请求-响应追踪 ④重连后恢复订阅**。 +> +> 这是第一篇出现大量"真正执行逻辑"的文件。语法密度很高,所以我先用一节把贯穿全文的几个大概念讲透,再逐行走。**别急,慢慢来。** + +--- + +## 开篇预备:5 个贯穿全文件的大语法 + +读这个文件前,先建立这 5 个概念,后面就顺了。 + +### 预备 1:什么是"类(class)"? + +前面我们用 `interface` 描述"形状",但形状不会自己干活。**`class`(类)才是"既有数据、又有能做事的方法、还能被造出实例"的东西。** + +打个比方:`class` 是"图纸"(比如汽车设计图),按图纸造出来的具体东西叫"**实例(instance)**"或"对象"(比如一辆真车)。同一个 class 能造很多实例。 + +```typescript +class 计数器 { + count = 0; // 这是"字段"——每个实例自带的数据 + increase() { // 这是"方法"——实例能做的事 + this.count = this.count + 1; + } +} + +const c = new 计数器(); // new 关键字:按图纸造一个实例 +c.increase(); // 调用方法,c.count 变成 1 +``` + +- **`new`** 关键字用来"造一个实例"。 +- 实例自带数据(字段)和能力(方法)。 + +本文件就是定义了一个 `class RosbridgeClient`——一个能管理 WebSocket 连接的"机器"。 + +### 预备 2:`this` 是什么? + +在 class 的方法内部,**`this` 指"当前这个实例自己"**。`this.count` 就是"我这个实例的 count 字段",`this.connect()` 就是"调用我自己的 connect 方法"。 + +为什么需要 `this`?因为同一个 class 能造很多实例,每个实例的数据各不相同。方法里必须用 `this` 才能指明"操作的是我这个实例的数据,不是别人的"。 + +**只要看到 `this.xxx`,就读成"我自己的 xxx"。** 这是本文件出现最频繁的写法。 + +### 预备 3:`Map` 和 `Set` 是什么? + +它们是 JS 内置的两种"容器": + +- **`Map`(映射表)**:存"键 → 值"的对应关系,像一本字典。 + - `map.set(键, 值)` 存一条;`map.get(键)` 取值;`map.has(键)` 问"有没有这个键";`map.delete(键)` 删一条;`map.keys()` 拿所有键。 +- **`Set`(集合)**:存"一堆不重复的值",像一个无序、去重的清单。 + - `set.add(值)` 加一个;`set.delete(值)` 删一个;`set.has(值)` 问"在不在"。 + - 同一个值加两次只算一个(自动去重)。 + +本文件用 `Map` 记"话题名 → 哪些处理器"、用 `Set` 装"一组处理器"。 + +### 预备 4:`Promise` 的"构造"用法 + +第①篇讲过 `Promise` 是"将来才有的结果"。之前我们只是"接收"别人给的 Promise,这里要**亲手造一个**: + +```typescript +new Promise((resolve, reject) => { + // 这里写"异步操作" + // 成功时调用 resolve(结果) —— 兑现承诺 + // 失败时调用 reject(错误) —— 拒绝承诺 +}); +``` + +- `new Promise(...)` 造一个承诺。 +- 括号里传一个函数,这个函数会**立刻被执行**,它收到两个工具: + - `resolve` —— 你调用它,就表示"成功了",Promise 兑现,`await` 它的人就拿到结果继续。 + - `reject` —— 你调用它,表示"失败了",Promise 被拒绝,`await` 它的人会收到一个错误(异常)。 +- 这套机制专门用来**把"基于回调/事件的老式异步"包装成现代的 Promise**。WebSocket 是基于事件的(`onopen`、`onclose`……),所以这里要手动包一层。 + +### 预备 5:`setTimeout` 定时器 + +- `setTimeout(函数, 毫秒)` —— "等这么多毫秒后,执行一次这个函数"。它返回一个"定时器句柄"。 +- `clearTimeout(句柄)` —— "取消那个还没触发的定时器"。 + +本文件用它做"连接超时""请求超时""延迟重连"。 + +好,预备知识到此。下面逐行走。 + +--- + +## 第 1-8 行:导入 + +```typescript +import WebSocket from "ws"; +import type { + RosbridgeClientOptions, + ConnectionStatus, + RosbridgeMessage, + MessageHandler, + ConnectionHandler, +} from "./types.js"; +``` + +- `import WebSocket from "ws";` —— 从第三方库 `"ws"` 导入 `WebSocket`。 + - **语法小课堂:这是"默认导入"。** 注意它**没有花括号**(对比第②篇 `import { z }` 有花括号)。 + - `import { 名字 }` —— **具名导入**:拿这个库导出的、叫这个名字的东西。 + - `import 名字 from` —— **默认导入**:拿这个库的"默认导出"(一个库只能有一个默认导出),名字随你起。 + - `"ws"` 是 Node.js 上最常用的 WebSocket 库(浏览器自带 WebSocket,但 Node.js 服务端需要这个库补上)。 + - 注意这一行**没有** `type`——因为我们要在运行时真的 `new WebSocket(...)`,它是真实代码。 +- 第二段 `import type { ... } from "./types.js"` —— 从上一篇的 `types.ts` 导入 5 个类型(只导类型,故有 `type`)。 + +--- + +## 第 10-15 行:辅助接口 `PendingRequest` + +```typescript +/** Pending request/response tracker for service calls and action goals. */ +export interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} +``` + +注释:「用于服务调用和动作目标的"待响应请求"追踪器」。 + +这个接口描述"一个还在等回应的请求"需要记住哪三样东西: + +- `resolve: (value: unknown) => void;` —— 一个"兑现"函数(来自某个 Promise)。等回应到了,调用它就能让那个 Promise 成功。 +- `reject: (reason: Error) => void;` —— 一个"拒绝"函数。出错/超时就调用它让 Promise 失败。 + - **为什么要把 `resolve`/`reject` 存起来?** 这是本文件最核心的设计:我们发出一个服务请求时,造一个 Promise 并把它的 `resolve`/`reject` **存进一张表**;将来回应从 WebSocket 飘回来时,再从表里找出对应的 `resolve` 来兑现。这样就把"事件"和"Promise"接起来了(预备 4 的实战)。 +- `timer: ReturnType;` —— 这个请求的超时定时器句柄。 + - **语法小课堂:`ReturnType` 是什么?** 拆开看: + - `typeof setTimeout` —— 取 `setTimeout` 这个函数的类型(第②篇讲过 `typeof 值` 把值翻译成类型)。 + - `ReturnType<...>` —— TS 内置的"取一个函数的**返回值类型**"的工具。 + - 合起来:`setTimeout` 返回的那个定时器句柄是什么类型,这里就是什么类型。(在 Node.js 和浏览器里这个类型不一样,用 `ReturnType` 自动适配,不用写死。) + +--- + +## 第 17-21 行:类声明开始 + +```typescript +/** + * WebSocket client for the rosbridge protocol. + * Handles connection lifecycle, reconnection, and message routing. + */ +export class RosbridgeClient { +``` + +- 注释:「rosbridge 协议的 WebSocket 客户端。处理连接生命周期、重连和消息路由。」 +- `export class RosbridgeClient {` —— 导出一个类(预备 1)。从这里到第 317 行的 `}` 都是它的内容。 + +--- + +## 第 22-31 行:类的字段(实例自带的数据) + +```typescript + private ws: WebSocket | null = null; + private options: Required; + private status: ConnectionStatus = "disconnected"; + private messageHandlers = new Map>(); + private connectionHandlers = new Set(); + private pendingRequests = new Map(); + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private intentionalClose = false; + private idCounter = 0; +``` + +这是这个 class 的"随身数据"。每造一个 `RosbridgeClient` 实例,它就自带这 10 个字段。 + +**语法小课堂:`private` 是什么?** +`private` 表示"私有"——这个字段**只能在 class 内部用 `this.xxx` 访问,外部碰不到**。这是"封装":把内部细节藏起来,只对外暴露该暴露的方法。外面的人不该直接动 `ws`,只能调用 `connect()` 等公开方法。 + +逐字段看: + +- `private ws: WebSocket | null = null;` —— 真正的 WebSocket 连接对象。 + - 类型 `WebSocket | null`:要么是一个连接,要么是 `null`(空)。 + - **语法小课堂:`null` 是什么?** `null` 表示"空、没有东西"。一开始还没连接,所以是 `null`;连上后才赋成真正的 WebSocket。`= null` 是给字段的**初始值**。 + - 为什么类型要带 `| null`?因为它确实会在"没有"和"有"两种状态间切换,类型如实反映,TS 才会提醒你"用之前先检查是不是 null"。 +- `private options: Required;` —— 配置选项。 + - **语法小课堂:`Required` 是什么?** TS 内置工具,把一个类型里**所有可选字段变成必填**。`RosbridgeClientOptions` 里 `reconnect?`、`reconnectInterval?` 等是可选的;`Required<...>` 把它们全变必填。 + - 为什么?因为构造函数会给所有可选项都补上默认值(马上看到),补完之后它们就一定有值了,类型如实反映成"全必填",后面用起来不用再判断 `undefined`。 + - 注意这个字段**没有** `= 初始值`——因为它在构造函数里赋值(必须赋,否则 TS 报错)。 +- `private status: ConnectionStatus = "disconnected";` —— 当前连接状态,初始 `"disconnected"`(断开)。 +- `private messageHandlers = new Map>();` —— **消息处理器表**(预备 3 的 Map)。 + - 类型 `Map>`:键是 `string`(话题名),值是 `Set`(这个话题的一组处理器)。 + - 即"每个话题名 → 一堆订阅它的回调函数"。为什么值是 `Set` 而不是单个函数?因为同一话题可能被订阅多次,要存多个处理器,且去重。 + - `= new Map<...>()` 初始化成一个空 Map。 +- `private connectionHandlers = new Set();` —— **连接状态处理器集合**(一个 Set)。所有想知道"连接状态变了"的回调都丢这里。 +- `private pendingRequests = new Map();` —— **待响应请求表**(核心)。键是请求 ID,值是上面定义的 `PendingRequest`(含 resolve/reject/timer)。 +- `private reconnectAttempts = 0;` —— 已经重连了几次,初始 0。(用于指数退避计算。) +- `private reconnectTimer: ReturnType | null = null;` —— 重连定时器句柄,初始 `null`(没有在排队的重连)。 +- `private intentionalClose = false;` —— "是不是我主动关的",初始 `false`。 + - 用途:WebSocket 关闭时要判断——是用户主动断开(那就别重连),还是意外掉线(那就自动重连)。 +- `private idCounter = 0;` —— 生成唯一 ID 用的计数器,初始 0。每发一个请求就 +1。 + +--- + +## 第 33-40 行:构造函数 `constructor` + +```typescript + constructor(options: RosbridgeClientOptions) { + this.options = { + url: options.url, + reconnect: options.reconnect ?? true, + reconnectInterval: options.reconnectInterval ?? 3000, + maxReconnectAttempts: options.maxReconnectAttempts ?? 10, + }; + } +``` + +**语法小课堂:`constructor` 是什么?** +`constructor`(构造函数)是 class 里一个特殊方法,**在 `new RosbridgeClient(...)` 那一刻自动执行一次**,用来做"初始化"。它接收的参数,就是 `new` 时括号里传的东西。 + +- `constructor(options: RosbridgeClientOptions)` —— 造实例时要传一个配置对象 `options`。 +- 函数体:把传进来的 `options` 整理后,存到 `this.options`(我自己的 options 字段)。逐字段: + - `url: options.url,` —— url 直接照搬(它是必填的)。 + - `reconnect: options.reconnect ?? true,` —— **重点看 `??`**。 + - **语法小课堂:`??` 是"空值合并运算符"。** `a ?? b` 的意思是"如果 `a` 是 `null` 或 `undefined`(即'没有值'),就用 `b`,否则用 `a`"。 + - 所以 `options.reconnect ?? true`:用户传了 `reconnect` 就用用户的,没传(`undefined`)就用默认值 `true`。 + - **这就是"给可选项补默认值"的标准手法。** 补完之后 `this.options.reconnect` 一定有值,正好对应上面 `Required<...>` 把它变必填。 + - `reconnectInterval: options.reconnectInterval ?? 3000,` —— 没传就默认 3000 毫秒。 + - `maxReconnectAttempts: options.maxReconnectAttempts ?? 10,` —— 没传就默认最多重连 10 次。 + +> 你可能问:第②篇 `config.ts` 不是已经用 Zod 补过默认值了吗?这里为什么又补一遍?因为 `RosbridgeClient` 是个独立、自包含的类,它不假设别人一定帮它补好了默认值——自己再兜一次底,更健壮。 + +--- + +## 第 42-108 行:`connect()` —— 连接(含超时与重连触发) + +这是本文件最长的方法。它要"发起 WebSocket 连接,并把这个基于事件的过程包装成一个 Promise"。 + +```typescript + /** Connect to the rosbridge WebSocket server. */ + async connect(): Promise { + if (this.status === "connected") return; +``` + +- `async connect(): Promise` —— 一个**异步方法**,返回 `Promise`。 + - **语法小课堂:`async` 是什么?** 写在函数前的 `async` 表示"这是个异步函数"。它有两个效果:①函数自动返回一个 Promise;②函数内部可以用 `await` 等待别的 Promise。这里我们主要用它的"返回 Promise"特性。 +- `if (this.status === "connected") return;` —— **守卫语句**。 + - **语法小课堂:`if (条件) 语句` 是条件判断。** 条件为真才执行后面的语句。 + - **语法小课堂:`===` 是"严格相等"。** 判断两边是不是完全相等(值和类型都相同)。JS 里要用三个等号 `===`,不要用两个 `==`(`==` 有奇怪的隐式转换规则,容易出 bug,本项目一律用 `===`)。 + - 整句意思:如果已经连上了,就直接 `return`(什么都不做、提前结束)。避免重复连接。 + +```typescript + this.intentionalClose = false; + this.setStatus("connecting"); +``` + +- `this.intentionalClose = false;` —— 重置"主动关闭"标记(这次是要连接,不是要关)。 +- `this.setStatus("connecting");` —— 把状态改成"连接中"(`setStatus` 是下面定义的方法,它顺便会通知所有监听器)。 + +```typescript + return new Promise((resolve, reject) => { +``` + +- `return new Promise((resolve, reject) => { ... })` —— 造一个 Promise 并返回它(预备 4)。 + - 谁 `await connect()`,谁就会一直等,直到下面代码里调用了 `resolve()`(连上了)或 `reject(...)`(连接失败)。 + - `(resolve, reject) => { ... }` 是传给 Promise 的那个"立即执行的函数",箭头函数写法。 + +```typescript + const connectTimeout = setTimeout(() => { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + reject(new Error(`Connection to ${this.options.url} timed out`)); + }, 10_000); +``` + +- 这是**连接超时保护**:设一个 10 秒的定时器,如果 10 秒内还没连上,就放弃。 +- `const connectTimeout = setTimeout(() => { ... }, 10_000);` —— 设一个定时器(预备 5),10 秒后执行里面的函数。把句柄存进 `connectTimeout`,以便连上后取消它。 + - **语法小课堂:`10_000` 里的下划线是什么?** 纯粹是"数字分隔符",让大数字好读。`10_000` 就是 `10000`(一万),下划线被忽略。这里 10000 毫秒 = 10 秒。 +- 定时器到点后做的事: + - `if (this.ws) { this.ws.close(); this.ws = null; }` —— 如果已经创建了 WebSocket,就关掉它并置空。`if (this.ws)` 利用了"非 null 即为真"——`this.ws` 不是 `null` 时条件成立。 + - `reject(new Error(\`Connection to ${this.options.url} timed out\`));` —— 拒绝 Promise,报"连接超时"。 + - **语法小课堂:`new Error("...")` 是什么?** 造一个"错误对象",里面装一句错误描述。配合 `reject` 或 `throw` 用,告诉接收方"出了什么错"。 + - **语法小课堂:反引号 `` ` `` 和 `${...}` 是"模板字符串"。** 用反引号包起来的字符串里,可以用 `${表达式}` 嵌入变量的值。这里 `` `Connection to ${this.options.url} timed out` `` 会把 `this.options.url` 的实际地址填进去,得到类似 `"Connection to ws://localhost:9090 timed out"`。比用 `+` 拼接字符串清爽得多。 + +```typescript + try { + this.ws = new WebSocket(this.options.url); + } catch (err) { + clearTimeout(connectTimeout); + this.setStatus("disconnected"); + reject(err); + return; + } +``` + +- **语法小课堂:`try { ... } catch (err) { ... }` 是"异常捕获"。** + - `try` 块里放"可能出错的代码"。 + - 如果里面**抛出异常**,程序不会崩,而是跳到 `catch` 块,把错误对象给 `err`,让你处理。 +- 这里:尝试 `new WebSocket(地址)` 创建连接对象。如果连"创建"这一步就抛错(比如地址格式非法): + - `clearTimeout(connectTimeout);` —— 取消那个 10 秒超时定时器(不然它过会还会触发)。 + - `this.setStatus("disconnected");` —— 状态改回断开。 + - `reject(err);` —— 用捕获到的错误拒绝 Promise。 + - `return;` —— 提前结束这个 Promise 函数,别再往下走。 + +接下来是 WebSocket 的**四个事件回调**。WebSocket 是事件驱动的:连上了触发 `onopen`、来消息触发 `onmessage`、出错触发 `onerror`、关闭触发 `onclose`。我们给每个事件挂一个处理函数。 + +```typescript + this.ws.onopen = () => { + clearTimeout(connectTimeout); + this.reconnectAttempts = 0; + this.setStatus("connected"); + resolve(); + }; +``` + +- `this.ws.onopen = () => { ... };` —— 给"连接成功"事件挂回调。`onopen` 触发说明连上了: + - `clearTimeout(connectTimeout);` —— 取消超时定时器(已经连上,不需要超时了)。 + - `this.reconnectAttempts = 0;` —— 重连次数清零(连上了,之前的失败记录作废)。 + - `this.setStatus("connected");` —— 状态改成"已连接"。 + - `resolve();` —— **兑现 Promise!** 此刻 `await connect()` 的人才会拿到结果、继续往下走。 + +```typescript + this.ws.onmessage = (event) => { + const data = typeof event.data === "string" + ? event.data + : event.data.toString(); + this.handleMessage(data); + }; +``` + +- `this.ws.onmessage = (event) => { ... };` —— 给"收到消息"事件挂回调。每来一条消息触发一次,消息装在 `event` 里。 + - `const data = typeof event.data === "string" ? event.data : event.data.toString();` + - **语法小课堂:`条件 ? A : B` 是"三元运算符",等于一个迷你 if-else。** 读作"如果条件成立就取 A,否则取 B"。 + - `typeof event.data === "string"` —— 判断收到的数据是不是字符串。(`typeof 值` 在运行时会返回该值的类型名,如 `"string"`、`"number"`。注意这和第②篇类型层面的 `typeof` 是两个不同场景的同名用法。) + - 整句:如果数据本来就是字符串,直接用;否则(可能是二进制 Buffer)调 `.toString()` 转成字符串。无论哪种,最终 `data` 都是字符串。 + - `this.handleMessage(data);` —— 把这条消息文本交给自己的 `handleMessage` 方法去分类处理(后面会细看)。 + +```typescript + this.ws.onerror = (_event) => { + clearTimeout(connectTimeout); + if (this.status === "connecting") { + this.ws = null; + this.setStatus("disconnected"); + reject(new Error(`WebSocket error connecting to ${this.options.url}`)); + } + }; +``` + +- `this.ws.onerror = (_event) => { ... };` —— "出错"事件回调。 + - **语法小课堂:参数名前的下划线 `_event`。** 参数我们其实用不到(不关心具体错误事件内容),但 WebSocket 调用回调时会传它。约定俗成地在参数名前加 `_`,表示"我知道有这个参数,但我故意不用它",避免 lint 工具警告。 + - `if (this.status === "connecting")` —— 只有在"连接中"出错才特殊处理: + - 置空 ws、状态改断开、`reject` 报错——让正在 `await` 的 `connect()` 失败。 + - 如果是连上之后才出错,这里不 reject(因为那时 connect 的 Promise 早就 resolve 过了,一个 Promise 只能兑现或拒绝一次,再调用无效)。连上后的错误交给 `onclose` 处理。 + +```typescript + this.ws.onclose = () => { + clearTimeout(connectTimeout); + this.ws = null; + + if (this.status === "connecting") { + this.setStatus("disconnected"); + reject(new Error(`WebSocket closed during connection to ${this.options.url}`)); + return; + } + + this.setStatus("disconnected"); + this.rejectAllPending(new Error("WebSocket connection closed")); + + if (!this.intentionalClose && this.options.reconnect) { + this.attemptReconnect(); + } + }; +``` + +- `this.ws.onclose = () => { ... };` —— "连接关闭"事件回调(最重要的善后逻辑)。 + - `clearTimeout(connectTimeout);` + `this.ws = null;` —— 清超时定时器、置空连接。 + - 第一种情况——**在连接过程中就关了**: + - `if (this.status === "connecting") { ... reject(...); return; }` —— 说明还没连上就被关,视为连接失败:状态置断开、`reject` 报错、`return` 结束。 + - 第二种情况——**连上之后才断开**(代码走到下面): + - `this.setStatus("disconnected");` —— 状态改断开。 + - `this.rejectAllPending(new Error("WebSocket connection closed"));` —— **把所有"还在等回应的请求"全部拒绝**(连都断了,它们不可能再有回应,必须让它们失败,否则会一直挂着)。这个方法在文件末尾。 + - `if (!this.intentionalClose && this.options.reconnect) { this.attemptReconnect(); }` —— **判断要不要自动重连**: + - **语法小课堂:`!` 是"逻辑非"。** `!x` 把真变假、假变真。`!this.intentionalClose` 意思是"不是主动关闭的"。 + - **语法小课堂:`&&` 是"逻辑与"。** `A && B` 表示"A 和 B 都成立"才算成立。 + - 整句:如果"不是我主动关的" **并且** "配置允许重连",就调用 `attemptReconnect()` 启动自动重连。(用户主动 `disconnect()` 时会把 `intentionalClose` 设成 `true`,于是这里 `!true` = `false`,就不会重连——这正是我们想要的。) + +到这里,`connect()` 这个又长又关键的方法就读完了。**核心思想**:把 WebSocket 那套 `onopen/onmessage/onerror/onclose` 事件,巧妙地接到一个 Promise 的 `resolve/reject` 上,让外部能用 `await connect()` 优雅地等待。 + +--- + +## 第 110-136 行:`disconnect()` —— 优雅断开 + +```typescript + /** Disconnect from the rosbridge server. */ + async disconnect(): Promise { + this.intentionalClose = true; +``` + +- `this.intentionalClose = true;` —— **关键**:标记"这是我主动关的"。这样上面 `onclose` 里的重连判断 `!this.intentionalClose` 就会是 `false`,**不会触发自动重连**。 + +```typescript + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } +``` + +- 如果当前正排着一个"待执行的重连定时器",就取消它并置空。(既然要主动断开,就别再自动重连了。) + +```typescript + this.rejectAllPending(new Error("Client disconnected")); +``` + +- 把所有等待中的请求全部拒绝(报"客户端已断开")。理由同前:连接要没了,别让它们傻等。 + +```typescript + if (this.ws) { + const ws = this.ws; + this.ws = null; + + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + await new Promise((resolve) => { + ws.onclose = () => resolve(); + ws.close(); + // Force-resolve after 2s if server doesn't ack the close + setTimeout(resolve, 2000); + }); + } + } +``` + +- `if (this.ws) { ... }` —— 只有当前确实有连接时才处理。 + - `const ws = this.ws; this.ws = null;` —— 先把连接存到本地变量 `ws`,再把 `this.ws` 置空。 + - **为什么先存到本地变量?** 因为下面要异步等待,期间 `this.ws` 可能被别处改动;先抓一份到本地 `ws`,确保后面操作的是同一个连接对象,不受干扰。这是处理异步时常见的小心思。 + - `if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)` —— 检查连接是不是"开着"或"正在连"。 + - `readyState` 是 WebSocket 的内置属性,表示当前处于哪个阶段;`WebSocket.OPEN`、`WebSocket.CONNECTING` 是预定义的常量。 + - `||` 是**逻辑或**(A 或 B 任一成立即可)。 + - 如果是,就 `await` 一个新 Promise,优雅等待关闭完成: + - `ws.onclose = () => resolve();` —— 重新挂一个 onclose:连接真正关闭时兑现这个 Promise。 + - **语法小课堂:`() => resolve()` 这种箭头函数。** 它是一个"无参数、执行 `resolve()`"的小函数。等价于写 `() => { resolve(); }`,单行可省略花括号和分号。 + - `ws.close();` —— 真正发起关闭。 + - `setTimeout(resolve, 2000);` —— **兜底**:万一服务端不响应关闭、`onclose` 迟迟不触发,2 秒后强制 `resolve`,免得 `disconnect()` 永远卡住。 + - 注意这里直接传 `resolve` 而不是 `() => resolve()`——因为 `setTimeout` 到点会自动调用它,两种写法效果一样。 + - **语法小课堂:`await` 终于出场。** `await 某个Promise` 的意思是"在这里暂停,等这个 Promise 兑现/拒绝后再继续"。因为本方法是 `async`,才允许用 `await`。所以 `await new Promise(...)` 让 `disconnect()` 真正等到连接关闭(或 2 秒兜底)才往下走。 + +```typescript + this.setStatus("disconnected"); + } +``` + +- 最后把状态置为断开,方法结束。 + +--- + +## 第 138-149 行:`send()` 与 `nextId()` + +```typescript + /** Send a rosbridge protocol message. */ + send(message: RosbridgeMessage & Record): void { + if (!this.ws || this.status !== "connected") { + throw new Error("Not connected to rosbridge server"); + } + this.ws.send(JSON.stringify(message)); + } +``` + +- `send(message: RosbridgeMessage & Record): void` —— 发送一条 rosbridge 消息。 + - **语法小课堂:参数类型里的 `&` 是"交叉类型"。** `A & B` 表示"同时满足 A 和 B 两种类型"。这里 `RosbridgeMessage & Record` 意思是"既要有 `RosbridgeMessage` 规定的 `op`/`id`,又允许带任意其它字段"。因为不同消息额外字段不同,用 `& Record` 留出灵活空间。 + - `if (!this.ws || this.status !== "connected") { throw ... }` —— **守卫**:如果没有连接(`!this.ws`)**或**状态不是已连接(`this.status !== "connected"`,`!==` 是"严格不等"),就抛异常。不能往一个不存在/没连好的连接上发数据。 + - `this.ws.send(JSON.stringify(message));` —— 真正发送。 + - **语法小课堂:`JSON.stringify(对象)` 把一个 JS 对象变成 JSON 字符串。** WebSocket 只能传字符串/二进制,所以发之前要把对象"序列化"成文本。它的反操作是 `JSON.parse(字符串)`(把文本变回对象),下面 `handleMessage` 会用到。 + +```typescript + /** Generate a unique message ID. */ + nextId(prefix = "rosclaw"): string { + return `${prefix}_${++this.idCounter}`; + } +``` + +- `nextId(prefix = "rosclaw"): string` —— 生成一个唯一 ID(用来配对请求和响应)。 + - **语法小课堂:`prefix = "rosclaw"` 是"默认参数值"。** 调用时不传 `prefix` 就用 `"rosclaw"`,传了就用传的。 + - `return \`${prefix}_${++this.idCounter}\`;` —— 用模板字符串拼出 ID,形如 `"service_1"`、`"action_2"`。 + - **语法小课堂:`++this.idCounter` 是"前置自增"。** `++x` 表示"先把 x 加 1,再用加完的值"。所以第一次调用,`idCounter` 从 0 变 1,用的就是 1,得到 `xxx_1`;下次得到 `xxx_2`……保证每个 ID 都不重复。 + - (对比:`x++` 是"后置自增",先用旧值再加 1。这里用前置 `++this.idCounter`,所以从 1 开始而不是 0。) + +--- + +## 第 151-168 行:`onMessage()` 与 `onConnection()` —— 登记回调 + +```typescript + /** Subscribe to messages on a specific topic. */ + onMessage(topic: string, handler: MessageHandler): () => void { + if (!this.messageHandlers.has(topic)) { + this.messageHandlers.set(topic, new Set()); + } + this.messageHandlers.get(topic)!.add(handler); + return () => { + this.messageHandlers.get(topic)?.delete(handler); + }; + } +``` + +- `onMessage(topic, handler): () => void` —— 为某话题登记一个消息处理器。返回一个"取消登记"的函数(回忆第④篇:返回清理函数)。 + - `if (!this.messageHandlers.has(topic)) { this.messageHandlers.set(topic, new Set()); }` —— 如果这个话题在表里还没有对应的 Set,就先建一个空 Set 放进去。(确保下一步能往里加。) + - `this.messageHandlers.get(topic)!.add(handler);` —— 取出这个话题的 Set,往里加这个处理器。 + - **语法小课堂:`!` 这里是"非空断言"。** 注意这个 `!` 紧贴在 `.get(topic)` 后面、`.add` 前面,和前面"逻辑非"的 `!` 位置不同、含义也不同。`map.get(key)` 的类型是"值 或 undefined"(万一没这个键就是 undefined)。我们刚刚才确保过它一定有,所以用 `!` 告诉 TS:"我保证它不是 undefined,别报警"。它只是消除类型警告,运行时不做任何事。 + - 返回的清理函数: + - `return () => { this.messageHandlers.get(topic)?.delete(handler); };` —— 调用它就把这个处理器从 Set 里删掉。 + - **语法小课堂:`?.` 是"可选链"。** `a?.b` 意思是"如果 `a` 存在就取 `a.b`,如果 `a` 是 null/undefined 就整体返回 undefined、不报错"。这里 `this.messageHandlers.get(topic)?.delete(handler)`:万一这个话题的 Set 已经没了(get 返回 undefined),`?.` 让它安静地跳过,不会因为"对 undefined 调用 .delete"而崩溃。 + - 对比上一行的 `!`(我担保有,强行用)和这里的 `?.`(不确定有没有,没有就算了)——两种应对"可能是 undefined"的不同策略。 + +```typescript + /** Register a connection status change handler. */ + onConnection(handler: ConnectionHandler): () => void { + this.connectionHandlers.add(handler); + return () => { + this.connectionHandlers.delete(handler); + }; + } +``` + +- `onConnection(handler)` —— 登记一个"连接状态变化"回调。把 handler 加进 `connectionHandlers` 这个 Set,返回一个"删掉它"的清理函数。比上面简单,因为连接处理器不分话题,直接一个 Set 管全部。 + +--- + +## 第 170-173 行:`getStatus()` + +```typescript + /** Get current connection status. */ + getStatus(): ConnectionStatus { + return this.status; + } +``` + +- 直接返回当前状态。简单的"读取器"。注意它不是 `async`、不返回 Promise——查状态是瞬间的(呼应第④篇"要不要等"的规律)。 + +--- + +## 第 175-206 行:请求追踪三件套 `registerPending` / `resolvePending` / `rejectPending` + +这是把"事件"接回"Promise"的核心机制(呼应预备 4 和 `PendingRequest` 接口)。 + +```typescript + registerPending(id: string, resolve: (value: unknown) => void, reject: (reason: Error) => void, timeoutMs = 30_000): void { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pendingRequests.set(id, { resolve, reject, timer }); + } +``` + +- `registerPending(id, resolve, reject, timeoutMs = 30_000)` —— **登记一个"等待响应"的请求**。 + - 参数:请求 `id`、它对应 Promise 的 `resolve`/`reject`、超时毫秒数(默认 30 秒)。 + - `const timer = setTimeout(() => { ... }, timeoutMs);` —— 设一个超时定时器:如果到点了还没等到响应,就: + - `this.pendingRequests.delete(id);` —— 从表里删掉这条(不再等)。 + - `reject(new Error(\`Request ${id} timed out after ${timeoutMs}ms\`));` —— 拒绝对应 Promise,报超时。 + - `this.pendingRequests.set(id, { resolve, reject, timer });` —— 把 `{ resolve, reject, timer }` 三件套存进表,键是 `id`。 + - **语法小课堂:`{ resolve, reject, timer }` 是"对象简写"。** 当字段名和变量名相同时,可以省略 `字段: 值`,直接写变量名。它等价于 `{ resolve: resolve, reject: reject, timer: timer }`。 + +```typescript + resolvePending(id: string, result: unknown): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.resolve(result); + } + } +``` + +- `resolvePending(id, result)` —— **响应到了,兑现对应的请求**。 + - `const pending = this.pendingRequests.get(id);` —— 按 id 从表里取出那条登记。 + - `if (pending) { ... }` —— 如果找到了(不是 undefined): + - `clearTimeout(pending.timer);` —— 取消它的超时定时器(已经有响应了,不需要超时了)。 + - `this.pendingRequests.delete(id);` —— 从表里删除(处理完毕)。 + - `pending.resolve(result);` —— **调用当初存的 `resolve`,把响应结果交出去!** 此刻,当初发请求时 `await` 的那个地方就拿到结果、继续执行了。这就是"事件 → Promise"的接合点。 + +```typescript + rejectPending(id: string, error: Error): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.reject(error); + } + } +``` + +- `rejectPending(id, error)` —— 和上面几乎一样,只是最后调用 `pending.reject(error)`(让对应 Promise 失败)而不是 resolve。用于"明确知道这个请求失败了"的场景。 + +--- + +## 第 208-213 行:`setStatus()` —— 改状态并广播 + +```typescript + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const handler of this.connectionHandlers) { + handler(status); + } + } +``` + +- `private setStatus(status)` —— 私有方法,统一改连接状态。 + - `this.status = status;` —— 更新状态字段。 + - `for (const handler of this.connectionHandlers) { handler(status); }` —— **挨个通知所有登记过的连接处理器**。 + - **语法小课堂:`for (const x of 集合) { ... }` 是"遍历循环"。** 它把集合(数组/Set/Map 等)里的元素一个个取出来,每次叫 `x`,执行一遍循环体。这里把 `connectionHandlers` 这个 Set 里的每个处理器取出来叫 `handler`。 + - `handler(status);` —— 调用这个处理器,把新状态传给它。 + - 效果:状态一变,所有关心的人都立刻收到通知。前面 `connect`/`disconnect` 里那些 `this.setStatus(...)` 都会触发这个广播。 + +--- + +## 第 215-273 行:`handleMessage()` —— 消息路由(核心中的核心) + +每条从服务端飘来的消息都经过这里分类、分发。 + +```typescript + /** Route an incoming rosbridge message to the appropriate handler. */ + private handleMessage(data: string): void { + let msg: Record; + try { + msg = JSON.parse(data) as Record; + } catch { + return; // Ignore malformed messages + } +``` + +- `private handleMessage(data: string)` —— 私有方法,收到的原始文本 `data` 进来。 + - `let msg: Record;` —— 声明一个变量 `msg`(先不赋值)。 + - **语法小课堂:为什么这里用 `let` 不用 `const`?** 因为 `const` 必须声明时就赋值且不能再改;这里要"先声明、在 try 里才赋值",所以用可重新赋值的 `let`。 + - `try { msg = JSON.parse(data) as Record; } catch { return; }` + - `JSON.parse(data)` —— 把文本解析回对象(`JSON.stringify` 的逆操作)。 + - `as Record` —— **语法小课堂:`as 类型` 是"类型断言"。** `JSON.parse` 返回的类型是 `any`(什么都行),我们用 `as` 告诉 TS"把它当作一个键值对象看待"。断言只影响类型检查,不改变运行时的值。 + - `catch { return; }` —— **如果解析失败**(收到的不是合法 JSON),就直接 `return` 忽略这条坏消息,不让它搞崩整个客户端。注意这个 `catch` 后面没写 `(err)`——因为我们不关心错误内容,新语法允许省略。 + +```typescript + const op = msg.op as string | undefined; + if (!op) return; +``` + +- `const op = msg.op as string | undefined;` —— 取出消息的 `op` 字段,断言成"字符串或 undefined"。 +- `if (!op) return;` —— 如果没有 `op`(`undefined` 或空字符串都算"假",`!op` 为真),直接忽略。没有 op 的消息我们不认识。 + +```typescript + switch (op) { +``` + +- **语法小课堂:`switch (值) { case A: ... }` 是"多分支选择"。** 它根据 `值` 等于哪个 `case` 后面的常量,跳到对应分支执行。比写一长串 `if-else if` 更清爽。这里根据 `op` 是什么,分别处理不同消息。 + +```typescript + case "publish": { + // Incoming topic message — route to topic subscribers + const topic = msg.topic as string; + const payload = msg.msg as Record; + const handlers = this.messageHandlers.get(topic); + if (handlers) { + for (const handler of handlers) { + handler(payload); + } + } + break; + } +``` + +- `case "publish": { ... break; }` —— 当 `op` 是 `"publish"`(服务端推来一条话题消息): + - **语法小课堂:`case` 后面的 `{ }`。** 给 case 包一对花括号,是为了开一个独立的"作用域",让里面 `const topic` 等变量名只在本 case 有效,不和别的 case 撞名。 + - `const topic = msg.topic as string;` —— 取出话题名。 + - `const payload = msg.msg as Record;` —— 取出消息体(`payload` 意为"载荷/内容")。 + - `const handlers = this.messageHandlers.get(topic);` —— 从处理器表里查这个话题登记了哪些处理器。 + - `if (handlers) { for (const handler of handlers) { handler(payload); } }` —— 如果有处理器,就**挨个调用**,把消息内容 `payload` 发给每一个。这就是"消息分发"——谁订阅了这个话题,谁就被叫到。 + - `break;` —— **语法小课堂:`break` 跳出 switch。** 处理完这个 case 就退出 switch,不会"漏"到下一个 case。(漏到下一个是别的语言常见 bug 来源,所以每个 case 末尾都要 break。) + +```typescript + case "service_response": { + // Response to a call_service request + const id = msg.id as string | undefined; + if (id) { + this.resolvePending(id, msg); + } + break; + } +``` + +- `case "service_response":` —— 服务调用的响应回来了: + - 取出 `id`,如果有,就 `this.resolvePending(id, msg)`——按 id 找到当初那个等待的 Promise 并兑现它,把整条响应 `msg` 作为结果交回去。 + - **这就是请求-响应配对的闭环**:发请求时 `registerPending(id, ...)` 存入,响应回来时 `resolvePending(id, msg)` 取出兑现。 + +```typescript + case "action_result": { + // Final result of an action goal + const id = msg.id as string | undefined; + if (id) { + this.resolvePending(id, msg); + } + break; + } +``` + +- `case "action_result":` —— 动作的**最终结果**回来了。逻辑和服务响应完全一样:按 id 兑现对应 Promise。(动作的"等到做完"就靠这个。) + +```typescript + case "action_feedback": { + // Intermediate feedback for an action goal — route to feedback handlers + const id = msg.id as string | undefined; + if (id) { + const handlers = this.messageHandlers.get(`__action_feedback__${id}`); + if (handlers) { + for (const handler of handlers) { + handler(msg as Record); + } + } + } + break; + } + } + } +``` + +- `case "action_feedback":` —— 动作的**中途进度反馈**(这不是最终结果,所以不能 resolve Promise,而要分发给"进度回调"): + - 取出 `id`。 + - `const handlers = this.messageHandlers.get(\`__action_feedback__${id}\`);` —— **关键技巧**:进度反馈复用了 `messageHandlers` 这张表,但用一个**特殊的键** `__action_feedback__加上id` 来存,避免和普通话题名撞车。 + - 为什么?因为 feedback 的 `op` 是 `"action_feedback"` 不是 `"publish"`,走不了普通话题分发;又因为同一时间可能有多个动作在跑,要用 `id` 区分各自的进度。用 `__action_feedback__{id}` 这个独特的键正好两全。(下一批 `actions.ts` 登记 feedback 时用的就是同一个键。) + - 找到对应处理器后,挨个调用,把整条 feedback 消息发过去。 + - 最后两个 `}`:第一个关 switch,第二个关 `handleMessage` 方法。 + +> **小结 `handleMessage` 的分流逻辑**(对照第⑤篇的消息表): +> - `publish`(话题消息)→ 按话题名找订阅者分发。 +> - `service_response` / `action_result`(一锤子的响应)→ 按 id 兑现 Promise。 +> - `action_feedback`(连续的进度)→ 按特殊键找进度回调分发。 + +--- + +## 第 275-307 行:`attemptReconnect()` —— 指数退避重连 + +```typescript + /** Attempt to reconnect with exponential backoff. */ + private attemptReconnect(): void { + if (this.intentionalClose) return; + if (this.reconnectAttempts >= this.options.maxReconnectAttempts) return; + + this.reconnectAttempts++; +``` + +- `private attemptReconnect()` —— 尝试重连。 + - `if (this.intentionalClose) return;` —— 如果是主动关的,不重连。 + - `if (this.reconnectAttempts >= this.options.maxReconnectAttempts) return;` —— 如果已经试够了最大次数(`>=` 是"大于等于"),放弃。 + - `this.reconnectAttempts++;` —— 重连次数加 1(这里用后置 `++`,加不加在这无所谓,因为不取它的返回值)。 + +```typescript + // Exponential backoff: interval * 2^(attempt-1), capped at 30s + const delay = Math.min( + this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), + 30_000, + ); +``` + +- 计算这次要等多久才重连,用**指数退避**策略(越失败越多、等得越久,避免疯狂重试把服务端打垮): + - `Math.pow(2, this.reconnectAttempts - 1)` —— **语法小课堂:`Math.pow(底, 指数)` 是"乘方"。** 这里算 `2 的 (重连次数-1) 次方`:第 1 次失败 = `2^0 = 1`,第 2 次 = `2^1 = 2`,第 3 次 = `2^2 = 4`……翻倍增长。 + - `this.options.reconnectInterval * 那个乘方` —— 乘上基础间隔(默认 3000 毫秒)。于是:3000 → 6000 → 12000 → 24000…… + - `Math.min(那个值, 30_000)` —— **语法小课堂:`Math.min(a, b)` 取两者中较小的。** 这里给延迟设了**上限 30 秒**:无论翻倍到多大,最多等 30 秒。 + - 最终 `delay` = "本次重连前要等待的毫秒数"。 + +```typescript + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + if (this.intentionalClose) return; + + try { + await this.connect(); + // Re-subscribe to all active topics on successful reconnect + for (const topic of this.messageHandlers.keys()) { + if (topic.startsWith("__action_feedback__")) continue; + this.send({ + op: "subscribe", + id: this.nextId("resub"), + topic, + }); + } + } catch { + // connect() failed — onclose will trigger another attemptReconnect + } + }, delay); + } +``` + +- `this.reconnectTimer = setTimeout(async () => { ... }, delay);` —— 设一个延迟 `delay` 毫秒的定时器,到点执行重连。 + - **语法小课堂:`setTimeout(async () => {...})`——定时器回调本身是 `async`。** 因为里面要 `await this.connect()`,所以这个箭头函数前面要加 `async`。 + - 把定时器句柄存进 `this.reconnectTimer`(前面 `disconnect` 时能取消它)。 +- 定时器到点后: + - `this.reconnectTimer = null;` —— 清掉句柄(这个定时器已经触发了)。 + - `if (this.intentionalClose) return;` —— 等待期间如果用户主动断开了,就别连了。 + - `try { ... } catch { ... }` 包住重连尝试: + - `await this.connect();` —— 重新连接,等它完成。 + - 连上之后,**恢复所有订阅**(关键!因为服务端重启/重连后不记得我们之前订阅过什么): + - `for (const topic of this.messageHandlers.keys()) { ... }` —— 遍历处理器表里所有的话题键(`.keys()` 拿到 Map 的所有键)。 + - `if (topic.startsWith("__action_feedback__")) continue;` —— **语法小课堂:`字符串.startsWith("前缀")` 判断是否以某前缀开头;`continue` 跳过本次循环、进入下一个。** 这里跳过那些"动作进度"的特殊键(它们不是真话题,不该重新订阅)。 + - `this.send({ op: "subscribe", id: this.nextId("resub"), topic });` —— 对每个真话题,重新发一条 `subscribe` 消息给服务端。`id` 用 `nextId("resub")` 生成(resub = re-subscribe,重订阅)。`topic` 用了对象简写(字段名等于变量名)。 + - `catch { }` —— **如果 `connect()` 这次又失败了**,catch 里什么都不做。为什么?因为连接失败会触发 `onclose`,而 `onclose` 里又会调用 `attemptReconnect()` 发起下一轮重连。所以这里不用管,让那条链路自然接力即可。(注释也这么说。) + +> **整个重连机制串起来**:连接断开 → `onclose` 判断需重连 → `attemptReconnect` 算退避延迟 → 定时器到点 → `connect` 重连 → 成功则恢复订阅 / 失败则 `onclose` 再次触发 → 循环,直到连上或达到最大次数。 + +--- + +## 第 309-316 行:`rejectAllPending()` —— 清空所有等待请求 + +```typescript + /** Reject all pending requests (used on disconnect/close). */ + private rejectAllPending(error: Error): void { + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } +} +``` + +- `private rejectAllPending(error)` —— 把所有"还在等回应"的请求全部用同一个错误拒绝。连接断开时调用(前面 `disconnect` 和 `onclose` 都用过)。 + - `for (const [id, pending] of this.pendingRequests) { ... }` —— 遍历整张待处理表。 + - **语法小课堂:`const [id, pending]` 是"数组解构"。** 遍历一个 `Map` 时,每个元素是 `[键, 值]` 这样一个两元素数组。`const [id, pending]` 把这个数组**当场拆开**:第一个给 `id`,第二个给 `pending`。比写 `entry[0]`、`entry[1]` 直观。(这里 `id` 其实没用到,但解构时要占位。) + - `clearTimeout(pending.timer);` —— 取消它的超时定时器。 + - `pending.reject(error);` —— 拒绝它对应的 Promise,让等待方收到错误。 + - `this.pendingRequests.clear();` —— `Map.clear()` 把整张表清空。 +- 最后的 `}` 关闭整个 `class RosbridgeClient`。 + +--- + +## 整章回顾:这个类的四大职责 + +读完这 317 行,回头看它确实只干四件事,现在每件都对应得上具体代码了: + +| 职责 | 涉及方法 | 核心机制 | +|---|---|---| +| ① 连接与重连 | `connect` / `disconnect` / `attemptReconnect` / `onclose` | 把 WebSocket 事件包成 Promise;指数退避重连;`intentionalClose` 区分主动/被动断开 | +| ② 消息路由 | `handleMessage` / `setStatus` | `switch (op)` 按消息类型分流;`messageHandlers` 表按话题/特殊键分发 | +| ③ 请求-响应追踪 | `registerPending` / `resolvePending` / `rejectPending` / `nextId` | 把 `resolve`/`reject` 存进 `pendingRequests` 表,靠 `id` 配对 | +| ④ 重连后恢复订阅 | `attemptReconnect` 里的重订阅循环 | 遍历 `messageHandlers` 的键,重发 `subscribe` | + +**它和外界的关系**:它是"底层引擎",本身不认识 `RosTransport` 接口(第④篇)。下一批的 `adapter.ts` 会把这个引擎包装成符合 `RosTransport` 的样子,再供上层工具使用。 + +**语法点回顾清单**(本章新增,量很大,可当字典查): +- `class` / `new` / 实例 / `this` / `constructor` +- `private` 私有字段、字段默认值、`Required`、`ReturnType` +- 默认导入 `import X from "..."` vs 具名导入 `import { X }` +- `Map`(`set`/`get`/`has`/`delete`/`keys`/`clear`)与 `Set`(`add`/`delete`) +- `new Promise((resolve, reject) => {...})` 手动构造、`resolve`/`reject` +- `async` / `await` +- `setTimeout` / `clearTimeout`、数字分隔符 `10_000` +- `if`、`===`/`!==`、`!`(逻辑非)、`&&`、`||`、三元 `? :` +- `null`、`?.`(可选链)、`!`(非空断言)、`??`(空值合并) +- 模板字符串 `` `...${x}...` ``、`new Error(...)` +- `JSON.stringify` / `JSON.parse`、`as`(类型断言) +- `switch`/`case`/`break`、`for...of`、`continue` +- `&`(交叉类型)、对象简写 `{ a, b }`、数组解构 `const [x, y]` +- `++x`(前置自增)、`Math.min`/`Math.pow`/`startsWith` +- 默认参数 `prefix = "..."`、`_event`(故意不用的参数) + +> 这一篇信息量是前面几篇的总和。**建议你配合真实源码,把 `connect` 和 `handleMessage` 两个方法反复读两三遍**——把它们吃透,后面的文件都会变简单。 + +下一份:[`transport/rosbridge/topics.ts` 逐行详解 →](07-rosbridge-topics.ts.md)(话题发布/订阅的具体封装,比这篇轻松很多) diff --git a/learn/code/07-rosbridge-topics.ts.md b/learn/code/07-rosbridge-topics.ts.md new file mode 100644 index 0000000..e742438 --- /dev/null +++ b/learn/code/07-rosbridge-topics.ts.md @@ -0,0 +1,256 @@ +# 逐行详解 ⑦:`transport/rosbridge/topics.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/topics.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/topics.ts) +> +> 推荐阅读顺序第 7 个文件。上一篇 `client.ts`(最难那篇)已经把"底层管子"——连接、收发、配对——全造好了。从这篇开始,我们看的是**架在 `client` 之上的三个小帮手**:话题、服务、动作。它们都很轻松,因为脏活累活 `client` 全干完了,它们只是"组装一条消息,丢给 `client.send`"。 + +--- + +## 先理解这个文件的定位 + +回忆第④篇那个比方:`client` 是"插座背后的电路"。而这篇 `topics.ts` 提供两个**包装类**: + +- `TopicPublisher`(发布器)——专门负责"往某个话题发消息"。 +- `TopicSubscriber`(订阅器)——专门负责"订阅某个话题、收消息、退订"。 + +它们都很薄:自己不连接、不解析,只是把"发布""订阅""退订"这几个动作,翻译成 `client` 认识的那种 `{ op: "..." }` 消息。可以理解为"专用遥控器"——一个只管发布、一个只管订阅。 + +--- + +## 第 1-2 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { MessageHandler } from "./types.js"; +``` + +- `import type { RosbridgeClient } from "./client.js"` —— 从上一篇的 `client.ts` 拿来 `RosbridgeClient` **类型**。注意是 `import type`(回忆第①篇:只借类型当"标注"用,不参与运行)。我们要把 `client` 实例存起来,所以需要它的类型。 +- `import type { MessageHandler }` —— 从 `types.js` 拿来"消息处理器"回调类型(第⑤篇讲过:`(msg) => void`)。订阅时要用它给参数做标注。 + +--- + +## 第 4-12 行:发布器 `TopicPublisher` 的骨架 + +```typescript +/** + * Helper for publishing messages to a ROS2 topic. + */ +export class TopicPublisher { + constructor( + private client: RosbridgeClient, + private topic: string, + private type: string, + ) {} +``` + +- `export class TopicPublisher` —— 定义并导出一个**类**(回忆第⑥篇:`class` 是"模具",用 `new` 造出实例)。 +- `constructor(...)` —— 构造函数,造实例时跑一次。 + +**语法小课堂:构造函数参数里直接写 `private`,是 TS 的"参数属性"简写。** +看这三个参数前面都带了 `private`: + +```typescript +constructor( + private client: RosbridgeClient, + private topic: string, + private type: string, +) {} +``` + +在普通 JS 里,要把构造参数存成实例字段,得手写一遍: + +```typescript +constructor(client, topic, type) { + this.client = client; // 手动搬运 + this.topic = topic; + this.type = type; +} +``` + +而 TS 允许你**在参数前加 `private`(或 `public`/`readonly`)**,它就自动帮你做上面那三行"搬运"——自动声明同名字段并赋值。所以这里虽然构造函数体是空的 `{}`,但 `this.client`、`this.topic`、`this.type` 已经被悄悄建好了。这是 TS 里极常见的省字偷懒写法,第⑥篇的 `client.ts` 里其实也用过,这里专门点明。 + +- 三个参数含义: + - `client` —— 底层那个 rosbridge 客户端(真正发消息的人)。 + - `topic` —— 这个发布器固定服务于哪个话题。 + - `type` —— 该话题的消息类型。 +- **设计意图**:一个 `TopicPublisher` 实例 = "绑定了某话题+某类型的专用发布器"。建好之后,每次发消息只需给消息体,话题和类型它自己记着。 + +--- + +## 第 14-22 行:`publish` 方法 + +```typescript + /** Publish a message to the topic. */ + publish(msg: Record): void { + this.client.send({ + op: "publish", + topic: this.topic, + type: this.type, + msg, + }); + } +} +``` + +- `publish(msg: Record): void` —— 方法:接收一个消息体 `msg`(任意结构的对象),返回 `void`(发完即忘,不等回执——回忆第④篇"要不要等"那条规律,发布是不等的)。 +- 方法体只有一句 `this.client.send({...})`:把一个 `publish` 消息交给底层客户端发出去。 +- 这个对象就是第⑤篇讲的 `PublishMessage` 格式: + - `op: "publish"` —— 固定的操作标签。 + - `topic: this.topic` —— 用实例里记着的话题。 + - `type: this.type` —— 用实例里记着的类型。 + - `msg,` —— **对象简写**(回忆第⑥篇):`msg,` 等价于 `msg: msg`,即"键名叫 `msg`,值就是参数 `msg`"。 +- `}` 关闭类。 + +到此发布器讲完——它就是把"发布"这件事固定成一句 `client.send`,没有任何复杂逻辑。 + +--- + +## 第 25-35 行:订阅器 `TopicSubscriber` 的骨架 + +```typescript +/** + * Helper for subscribing to messages from a ROS2 topic. + */ +export class TopicSubscriber { + private unsubscribeFromClient: (() => void) | null = null; + + constructor( + private client: RosbridgeClient, + private topic: string, + private type?: string, + ) {} +``` + +- 又一个类,负责订阅。构造参数和发布器几乎一样,只是 `type?` 这次是**可选**的(订阅时类型常可省,回忆第③篇 `SubscribeOptions`)。 +- 多了一个字段,值得细看: + +```typescript +private unsubscribeFromClient: (() => void) | null = null; +``` + +**逐部分拆解这一行(信息量很大):** + +- `private` —— 私有字段,只在类内部用。 +- `unsubscribeFromClient` —— 字段名,含义是"从 client 那里退订用的那个函数"。 +- `: (() => void) | null` —— 它的**类型**,是个联合类型(回忆第①篇 `|`):要么是"一个无参无返回的函数 `() => void`",要么是 `null`(空)。 + - 为什么要能是 `null`?因为**还没订阅时,根本没有"退订函数"可存**,先用 `null` 占位。订阅之后才把真正的退订函数放进来。 + - **语法小课堂:`(() => void) | null` 里外面那层括号是干嘛的?** 是为了**消除歧义**。如果写成 `() => void | null`,TS 会理解成"一个返回 `void | null` 的函数"(括号管到了返回值上)。加一层括号 `(() => void)`,明确表示"这是个函数类型",再 `| null` 表示"这个函数 **或者** null"。括号在这里决定了 `|` 到底作用在谁身上。 +- `= null` —— **字段初始值**。一开始就是 `null`(未订阅)。 + +> 这个"先放 null、之后填真东西、用完再设回 null"的模式,是管理"可有可无的资源"的经典手法。它和第⑥篇 `client.ts` 里 `reconnectTimer` 那种"定时器句柄存起来以便取消"是一个套路。 + +--- + +## 第 37-46 行:`subscribe` 方法 + +```typescript + /** Subscribe to the topic and receive messages via the handler. */ + subscribe(handler: MessageHandler): void { + this.unsubscribeFromClient = this.client.onMessage(this.topic, handler); + this.client.send({ + op: "subscribe", + id: this.client.nextId("subscribe"), + topic: this.topic, + type: this.type, + }); + } +``` + +- `subscribe(handler: MessageHandler): void` —— 接收一个"消息处理器"回调,返回 `void`。意思是"你给我一个函数,以后每来一条消息我就调用它"。 + +方法体两步,顺序很重要: + +**第一步——先在本地登记好"消息来了交给谁":** +```typescript +this.unsubscribeFromClient = this.client.onMessage(this.topic, handler); +``` +- 调用 `client.onMessage(话题, 回调)`:告诉底层客户端"以后这个话题的消息,都转交给 `handler`"。 +- `onMessage` 会**返回一个退订函数**(回忆第④篇"返回一个函数"的设计),我们把它存进刚才那个字段 `this.unsubscribeFromClient`。于是字段从 `null` 变成了真正的退订函数——以后退订时就靠它。 + +**第二步——再向服务端发出"我要订阅"的请求:** +```typescript +this.client.send({ + op: "subscribe", + id: this.client.nextId("subscribe"), + topic: this.topic, + type: this.type, +}); +``` +- 这是第⑤篇的 `SubscribeMessage` 格式。 +- `id: this.client.nextId("subscribe")` —— 调用客户端的 `nextId` 生成一个唯一编号(第⑥篇讲过,`nextId` 用一个自增计数器产出像 `"subscribe_1"` 这样的串)。 +- `topic`/`type` 用实例里存的。 + +> **为什么先登记本地回调、再发订阅请求?** 因为消息随时可能回来。如果先发请求、还没登记回调,万一服务端"秒回"第一条消息,就没人接住、丢了。先把"接球手"站好,再喊"开始发球",才不会漏。这种"先备好接收方,再触发"的顺序在异步编程里是通用安全准则。 + +--- + +## 第 48-59 行:`unsubscribe` 方法 + +```typescript + /** Unsubscribe from the topic. */ + unsubscribe(): void { + if (this.unsubscribeFromClient) { + this.unsubscribeFromClient(); + this.unsubscribeFromClient = null; + } + this.client.send({ + op: "unsubscribe", + id: this.client.nextId("unsubscribe"), + topic: this.topic, + }); + } +} +``` + +退订也是两步,和订阅正好对称: + +**第一步——清掉本地登记:** +```typescript +if (this.unsubscribeFromClient) { + this.unsubscribeFromClient(); + this.unsubscribeFromClient = null; +} +``` +- `if (this.unsubscribeFromClient)` —— 先判断字段里**确实存了一个退订函数**(不是 `null`)才进去。 + - **语法小课堂:把一个"可能是 null 的值"直接放进 `if`,叫"真值判断(truthy check)"。** JS 里 `null`、`undefined`、`0`、`""`、`false` 都算"假值(falsy)",其余都算"真值(truthy)"。所以 `if (this.unsubscribeFromClient)` 的含义是"如果它不是 null(确实存了个函数)"。这是判空的常用简写,等价于 `if (this.unsubscribeFromClient !== null)`。 +- `this.unsubscribeFromClient();` —— 调用那个退订函数,把第一步登记的本地回调撤掉(注意末尾的 `()` 表示"调用它")。 +- `this.unsubscribeFromClient = null;` —— 调用完把字段设回 `null`,表示"已经退订、没有可退的了"。这样即使有人手滑再调一次 `unsubscribe`,`if` 也不会重复执行。 + +**第二步——通知服务端"我不订了":** +```typescript +this.client.send({ + op: "unsubscribe", + id: this.client.nextId("unsubscribe"), + topic: this.topic, +}); +``` +- 第⑤篇的 `UnsubscribeMessage` 格式,极简:操作标签 + 一个 id + 话题名。 +- `}` 关闭类。 + +> **本地撤回调 + 远程发退订,两件事都要做。** 只撤本地不通知服务端,服务端会继续往这条连接推消息(浪费带宽);只通知服务端不撤本地,万一还有残留消息进来,旧回调可能被误触发。两步都做才干净。 + +--- + +## 整章回顾 + +这篇是上一篇"硬核 `client.ts`"之后的第一口轻松气。两个类的本质都一样: + +> **把"发布/订阅/退订"这种动作,固定成一两句 `client.send({ op: ... })`,并管好与之配套的本地回调登记。** + +| 类 | 职责 | 关键动作 | +|---|---|---| +| `TopicPublisher` | 发布 | 一句 `send({op:"publish",...})`,发完即忘 | +| `TopicSubscriber` | 订阅/退订 | 订阅=先 `onMessage` 登记回调、再 `send` 订阅请求;退订=先撤回调、再 `send` 退订请求 | + +记住两条贯穿全篇的"顺序准则": +1. **订阅**:先备好接收方(`onMessage`),再喊开始(`send subscribe`)。 +2. **退订**:先撤本地(调用并清空退订函数),再通知远端(`send unsubscribe`)。 + +**语法点回顾清单**(本章新增): +- 构造函数参数前写 `private`/`public`:**参数属性**简写(自动声明并赋值字段) +- 对象简写补充:`{ msg }` 即 `{ msg: msg }`(第⑥篇见过,这里再巩固) +- `(() => void) | null`:可空的函数类型字段,外层括号用来消歧义 +- "先放 null、用时填、用完设回 null"管理可选资源的模式 +- 真值判断 `if (someValue)`:非空/非假即进入(判空简写) + +下一份:[`transport/rosbridge/services.ts` 逐行详解 →](08-rosbridge-services.ts.md)(用一个函数完整演示"请求-等待响应"的 Promise 配对) diff --git a/learn/code/08-rosbridge-services.ts.md b/learn/code/08-rosbridge-services.ts.md new file mode 100644 index 0000000..8033b39 --- /dev/null +++ b/learn/code/08-rosbridge-services.ts.md @@ -0,0 +1,207 @@ +# 逐行详解 ⑧:`transport/rosbridge/services.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/services.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/services.ts) +> +> 推荐阅读顺序第 8 个文件。整个文件只有**一个函数** `callService`,但它是全项目"请求-等待响应"模式最干净的范例。上一篇 `topics.ts` 的发布是"发完即忘",这篇的服务调用是"发出去之后**得等对方答复**"——这中间怎么把"将来才回来的响应"接住,正是本篇的主角。 + +--- + +## 先理解要解决的难题 + +ROS2 的"服务"是请求-响应式的(回忆第③篇打电话的比方)。难点在于:**消息是在 WebSocket 上异步来回的**。我们 `send` 出一个请求后,函数不能"原地卡住等",但调用方又确实想"等到结果再继续"。 + +第⑥篇的 `client` 已经备好了解决工具——`registerPending`:你给它一个 `id` 和"响应回来时该怎么办",它就先记在一个 Map 里;将来对应 `id` 的响应到了,它替你调用。 + +本篇就是把这个工具和 `Promise` 一拼,变魔术:让一个看似"返回结果"的函数,其实返回的是"将来会有结果的承诺"。 + +--- + +## 第 1-2 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { ServiceResponseMessage } from "./types.js"; +``` + +- `RosbridgeClient` —— 底层客户端类型,函数第一个参数要用。 +- `ServiceResponseMessage` —— 第⑤篇定义的"服务响应消息"类型(含 `result` 成功与否、`values` 返回数据)。这就是我们最终想拿到的东西。 + +--- + +## 第 4-13 行:函数的文档注释 + +```typescript +/** + * Call a ROS2 service via rosbridge. + * + * @param client - The rosbridge client instance + * @param service - The service name (e.g., "/my_node/set_parameters") + * @param args - The service request arguments + * @param type - Optional service type + * @param timeoutMs - Request timeout in milliseconds (default 30s) + * @returns The service response + */ +``` + +- **语法小课堂:`@param` 和 `@returns` 是 JSDoc 标记。** 回忆第⑤篇见过的 `@see`。这里: + - `@param 名字 - 说明` —— 逐个解释参数。 + - `@returns 说明` —— 解释返回值。 +- 这些标记不影响运行,但编辑器会读它们:当你在别处调用 `callService` 时,鼠标悬停就能看到这些说明。等于"给函数配了说明书"。 + +--- + +## 第 14-20 行:函数签名 + +```typescript +export async function callService( + client: RosbridgeClient, + service: string, + args?: Record, + type?: string, + timeoutMs = 30_000, +): Promise { +``` + +逐部分拆: + +- `export async function callService(...)` —— 导出一个 **async(异步)函数**(回忆第⑥篇:`async` 表示"这函数里会有等待,它整体返回一个 `Promise`")。 +- 参数列表: + - `client: RosbridgeClient` —— 用哪个客户端去发。 + - `service: string` —— 服务名,必填。 + - `args?: Record` —— 请求参数,**可选**(`?`)。 + - `type?: string` —— 服务类型,可选。 + - `timeoutMs = 30_000` —— **默认参数**(回忆第⑥篇):不传就用 `30_000`(即 30000 毫秒=30 秒;下划线 `30_000` 只是分位好读,第⑥篇讲过)。意思是"等响应最多等 30 秒,超了就算失败"。 +- 返回类型 `: Promise` —— 返回一个"将来会兑现成服务响应"的承诺。因为是 `async` 函数,返回值天然被包进 `Promise`。 + +> **小注意:必填参数在前、可选参数在后。** `args?`、`type?` 这些带 `?` 的必须排在必填参数后面——否则调用时位置对不上。`timeoutMs` 有默认值,也相当于可选,放最后。 + +--- + +## 第 21 行:生成本次请求的唯一 id + +```typescript + const id = client.nextId("service"); +``` + +- 调用 `client.nextId("service")` 拿一个唯一编号,比如 `"service_1"`、`"service_2"`……(第⑥篇讲过 `nextId` 内部用自增计数器)。 +- **这个 `id` 是整篇的灵魂**:请求带着它发出去,响应也会带着相同的 `id` 回来。我们就靠这个号码,把"哪个响应对应哪个请求"配上对。如同快递单号。 + +--- + +## 第 23-30 行:先架好"接住响应"的 Promise(本篇核心) + +```typescript + const responsePromise = new Promise((resolve, reject) => { + client.registerPending( + id, + (result) => resolve(result as ServiceResponseMessage), + reject, + timeoutMs, + ); + }); +``` + +这是全篇最需要嚼的一段。一层层拆: + +**外层:`new Promise<...>((resolve, reject) => {...})`** +- 回忆第⑥篇的"Promise 构造小课堂":`new Promise` 需要你传一个函数,这个函数收到两个开关——`resolve`(成功兑现,把结果交出去)和 `reject`(失败,抛出错误)。 +- `` 指明这个 Promise 将来兑现时给出的值是 `ServiceResponseMessage` 类型。 +- 重点理解:`new Promise` 此刻**并不会立刻有结果**。它只是先创建一个"待兑现的承诺",并把"将来怎样才算成功/失败"的规则登记好。规则就写在那个箭头函数体里。 + +**内层:把 `resolve`/`reject` 交给 `client.registerPending`** +```typescript +client.registerPending( + id, + (result) => resolve(result as ServiceResponseMessage), + reject, + timeoutMs, +); +``` +- `registerPending` 是第⑥篇 `client.ts` 里那个方法:把 `(id → 成功回调 / 失败回调 / 超时)` 记进一个待处理 Map。将来对应 `id` 的响应回来,`client` 就替我们调用成功回调;超时了就调用失败回调。 +- 四个参数: + 1. `id` —— 用哪个号码登记。 + 2. `(result) => resolve(result as ServiceResponseMessage)` —— **成功回调**。当响应回来,`client` 会把响应数据当 `result` 传进来;我们立刻调用 `resolve(...)`,于是外层那个 Promise 就兑现了、把结果交出去。 + - `result as ServiceResponseMessage` —— **类型断言**(回忆第⑥篇 `as`)。`client` 给回来的 `result` 类型比较宽泛,我们"断言"它其实就是服务响应,好让类型对上。 + 3. `reject` —— **失败回调**。直接把 Promise 的 `reject` 交过去——一旦超时或出错,`client` 调用 `reject`,外层 Promise 就变成失败。 + - 注意这里直接写 `reject`,没有包一层箭头函数。因为 `reject` 本身就是个函数,签名正好匹配,直接整个传过去即可(回忆第①篇"函数是一等公民,可当参数传")。 + 4. `timeoutMs` —— 多久没回就算超时。 + +**这一段整体在干嘛?** 一句话:**"建一个空承诺,并约定好——响应到了就兑现它、超时了就让它失败。"** 注意此刻我们还没发请求!只是先把"接球网"张开。 + +> **再次强调顺序**:和上一篇订阅一样——**先登记好怎么接响应,再发请求**。万一服务端秒回,接球网已经张好,不会漏。 + +--- + +## 第 32-38 行:真正发出请求 + +```typescript + client.send({ + op: "call_service", + id, + service, + args, + type, + }); +``` + +- 现在才把请求发出去。这是第⑤篇的 `ServiceCallMessage` 格式: + - `op: "call_service"` —— 操作标签。 + - `id,` —— **对象简写**:带上刚才那个号码(`id: id`)。响应将来会带着同一个 `id` 回来,配对就靠它。 + - `service,` / `args,` / `type,` —— 都是对象简写,把同名参数原样塞进去。 +- 发完这一句,请求就在路上了。但函数还没结束——下面要把"那个待兑现的承诺"返回出去。 + +--- + +## 第 40-41 行:返回承诺 + +```typescript + return responsePromise; +} +``` + +- `return responsePromise;` —— 把第 23 行建的那个 Promise 返回给调用方。 +- **关键理解**:函数到这里就返回了,但 Promise 里**还没有结果**(响应还在路上)。调用方拿到的是一个"待兑现的承诺",它会 `await` 这个承诺——直到响应回来触发 `resolve`,`await` 才拿到结果继续往下;或者超时触发 `reject`,`await` 处抛出错误。 +- `}` 结束函数。 + +> **为什么函数明明 `async`,却不在里面 `await`,而是直接 `return` 一个 Promise?** +> 完全可以。`async` 函数返回一个 Promise 时,外层不会再多包一层(会"摊平")。这里作者用 `async` 主要是表明"这是个异步操作"的语义,并让返回类型自然写成 `Promise<...>`。真正的"等待"逻辑被巧妙地塞进了 `registerPending` 的回调里,而不是用 `await` 写在函数体内——这是处理"事件式异步"(响应靠回调送达,而非靠某个 `await` 的调用)的标准手法。 + +--- + +## 把整个流程串成一张图 + +``` +①拿号 id ──→ ②张开接球网(new Promise + registerPending:约定"id 的响应到了就 resolve") + │ +③发请求 send({op:"call_service", id, ...}) ──→ (消息上路,飞向 rosbridge) + │ +④return responsePromise(把承诺交给调用方,函数结束) + ┊ …时间流逝,调用方在 await… +⑤服务端回了 {op:"service_response", id, ...} + → client 在 Map 里按 id 找到我们登记的成功回调 → 调用 resolve(响应) + → 那个承诺兑现 → 调用方的 await 拿到响应,继续往下 + (若 30 秒没回 → client 触发 reject → 调用方 await 处抛错) +``` + +这套"拿号 → 先登记回调 → 再发请求 → 返回承诺 → 响应回来按号兑现"的五步流程,就是异步请求-响应的通用骨架。下一篇动作(action)几乎是同一套,只是多了"中途进度反馈"。 + +--- + +## 整章回顾 + +- 本篇只有一个函数 `callService`,演示了**如何把"事件式的异步响应"包装成一个干净的 `Promise`**,让调用方能用 `await` 像写同步代码一样等结果。 +- 核心三件套: + 1. `nextId` 拿唯一号——给请求和响应配对用。 + 2. `new Promise` + `registerPending`——先把"响应到了/超时了怎么办"登记好(接球网先张开)。 + 3. `send` 发请求 + `return promise` 交出承诺。 +- 贯穿的安全准则依旧是:**先备好接收(登记回调),再触发(发请求)。** + +**语法点回顾清单**(本章新增/巩固): +- `@param` / `@returns`:JSDoc 给函数参数和返回值写说明书 +- 默认参数 `timeoutMs = 30_000`、必填在前可选在后(巩固) +- `async function` 直接 `return` 一个 Promise(不一定非得在体内 `await`) +- 把 `resolve`/`reject` 透传给底层登记函数,实现"事件→Promise"的桥接 +- 直接传函数名当回调(`reject`)vs 包一层箭头函数(`(result) => resolve(...)`)的区别 +- 类型断言 `as`(巩固,第⑥篇见过) + +下一份:[`transport/rosbridge/actions.ts` 逐行详解 →](09-rosbridge-actions.ts.md)(在本篇的请求-响应骨架上,再叠加"进度反馈"和 `try/finally` 清理) diff --git a/learn/code/09-rosbridge-actions.ts.md b/learn/code/09-rosbridge-actions.ts.md new file mode 100644 index 0000000..278fe82 --- /dev/null +++ b/learn/code/09-rosbridge-actions.ts.md @@ -0,0 +1,293 @@ +# 逐行详解 ⑨:`transport/rosbridge/actions.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/actions.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/actions.ts) +> +> 推荐阅读顺序第 9 个文件,rosbridge 三小帮手的最后一个。**它就是上一篇 `services.ts` 那套"请求-响应"骨架,再叠两样东西**:①动作是长任务,途中会不断推"进度反馈",要单独接住;②不管成功失败,结束时都得把进度监听清理掉——这引出新语法 `try/finally`。把第⑧篇吃透了,这篇只剩"增量"要学。 + +--- + +## 先理解动作 vs 服务的差别 + +回忆第③篇: +- **服务(service)**=打个电话问一句、对方答一句就完事(一来一回)。 +- **动作(action)**=交代一个大任务("导航到客厅"),它要干几十秒,**途中不断汇报进度**("走了 30%……60%……"),最后才给个总结果,而且**中途可以喊停**。 + +所以动作比服务多两件事要处理: +1. **进度反馈**:任务跑的过程中,服务端会推来一条条 `action_feedback`,得有人接。 +2. **善后清理**:任务一结束(无论成功还是出错),那个"接进度"的监听器要撤掉,别留垃圾。 + +--- + +## 第 1-5 行:导入 + +```typescript +import type { RosbridgeClient } from "./client.js"; +import type { + ActionResultMessage, + ActionFeedbackMessage, +} from "./types.js"; +``` + +- `RosbridgeClient` —— 底层客户端类型。 +- `ActionResultMessage` —— 第⑤篇定义的"动作最终结果"消息类型(含 `result`+`values`)。 +- `ActionFeedbackMessage` —— "动作进度反馈"消息类型(含必有的 `values`)。 +- 多个名字用花括号换行列出,是纯排版(回忆第④篇),和写一行没区别。 + +--- + +## 第 7-13 行:动作目标选项 `ActionGoalOptions` + +```typescript +export interface ActionGoalOptions { + action: string; + actionType: string; + args?: Record; + onFeedback?: (feedback: ActionFeedbackMessage) => void; + timeoutMs?: number; +} +``` + +发送一个动作目标要带的信息(和第③篇同名接口几乎一致,这里是 rosbridge 模块自己的版本): + +- `action: string;` —— 动作服务器名,必填。 +- `actionType: string;` —— 动作类型,必填(注意无 `?`,动作类型不能省)。 +- `args?: Record;` —— 目标参数,可选(如目标坐标)。 +- `onFeedback?: (feedback: ActionFeedbackMessage) => void;` —— **可选的进度回调**。它是个函数类型:接收一条进度反馈、返回 `void`。你给了它,就能实时收到进度;不给,就只等最终结果。 +- `timeoutMs?: number;` —— 可选超时(动作很长,下面会看到默认值比服务大得多)。 + +--- + +## 第 15-19 行:`ActionClient` 类骨架 + +```typescript +/** + * Client for sending action goals and receiving feedback/results. + */ +export class ActionClient { + constructor(private client: RosbridgeClient) {} +``` + +- 又一个薄包装类,只存一个底层 `client`(用了第⑦篇讲的 `private` 参数属性简写)。 +- 它对外提供两个方法:`sendGoal`(发目标并等结果)和 `cancelGoal`(取消)。 + +--- + +## 第 21-29 行:`sendGoal` 开头——拿号 + 定超时 + +```typescript + async sendGoal(options: ActionGoalOptions): Promise { + const id = this.client.nextId("action"); + const timeoutMs = options.timeoutMs ?? 120_000; // Actions can be long-running +``` + +- `async sendGoal(options): Promise` —— 异步方法,接收一个选项对象,最终兑现成"动作结果"。 +- `const id = this.client.nextId("action");` —— 和第⑧篇一样,拿唯一号给请求/结果配对。 +- `const timeoutMs = options.timeoutMs ?? 120_000;` —— 定超时: + - **语法小课堂复习:`??`(空值合并)**(第⑥篇讲过)。`options.timeoutMs ?? 120_000` 意思是"如果调用方传了 `timeoutMs` 就用它,没传(是 `undefined`)就用 `120_000`"。 + - `120_000` 毫秒 = 120 秒 = 2 分钟。比服务的 30 秒长 4 倍——注释 `// Actions can be long-running`(动作可能很耗时)正解释了原因。 + - 注意这里**没用第⑧篇那种"参数默认值"写法**(`timeoutMs = 120_000`),而是用 `??` 在函数体内兜底。两种写法效果类似;因为 `timeoutMs` 藏在 `options` 对象里,没法写成参数默认值,只能进来后用 `??` 补。 + +--- + +## 第 31-38 行:登记进度反馈监听(动作独有的部分) + +```typescript + // Register feedback handler if provided + let removeFeedbackHandler: (() => void) | null = null; + if (options.onFeedback) { + const feedbackKey = `__action_feedback__${id}`; + removeFeedbackHandler = this.client.onMessage(feedbackKey, (msg) => { + options.onFeedback!(msg as unknown as ActionFeedbackMessage); + }); + } +``` + +这一段是动作比服务**多出来**的处理,逐行看: + +```typescript +let removeFeedbackHandler: (() => void) | null = null; +``` +- 和第⑦篇订阅器里那个字段同款:一个"可空的退订函数"。先 `null`,待会儿若登记了监听就填进来,最后清理时调用它。 +- 用 `let` 不用 `const`,因为它的值后面要被改(从 `null` 变成函数)。 + +```typescript +if (options.onFeedback) { +``` +- 真值判断(第⑦篇讲过):只有**调用方确实传了 `onFeedback` 回调**,才需要费劲去登记进度监听。没传就整段跳过,省事。 + +```typescript +const feedbackKey = `__action_feedback__${id}`; +``` +- 拼一个**专门给"这次动作的进度"用的频道名**。用了模板字符串(第⑥篇):`__action_feedback__` 前缀 + 这次的 `id`。 +- 为什么要这么个怪名字?因为进度反馈和最终结果是**两路消息**:最终结果按 `id` 走 `registerPending`(下面会写),进度则单独挂在这个 `__action_feedback__` 频道上,互不干扰。`client` 内部会把进度反馈消息路由到这个 key。前后双下划线只是约定俗成表示"内部专用、别乱碰"。 + +```typescript +removeFeedbackHandler = this.client.onMessage(feedbackKey, (msg) => { + options.onFeedback!(msg as unknown as ActionFeedbackMessage); +}); +``` +- `this.client.onMessage(频道, 回调)` —— 在那个进度频道上登记一个回调,并把返回的退订函数存进 `removeFeedbackHandler`(同第⑦篇套路)。 +- 回调体 `options.onFeedback!(msg as unknown as ActionFeedbackMessage)`:每来一条进度,就转交给调用方给的 `onFeedback`。这里有两个新点: + - **语法小课堂:`options.onFeedback!` 里的 `!` 是"非空断言"。** 回忆第⑥篇区分过:`!` 跟在值后面表示"我担保它不是 null/undefined"。`onFeedback` 类型是可选的(`?`,可能 `undefined`),但我们是在 `if (options.onFeedback)` 成立的分支里,明知它一定存在,于是用 `!` 告诉 TS"放心,这里它必有",免得 TS 唠叨。(注意:这 `!` 是断言,**不是**逻辑取反——逻辑取反的 `!` 写在值**前面**,如 `!flag`。) + - **语法小课堂:`msg as unknown as ActionFeedbackMessage` 是"双重类型断言"。** 单个 `as` 是类型断言(第⑥篇)。这里连用两次 `as unknown as X`,是一种"强行改类型"的手法:当源类型和目标类型差得太远、TS 不允许直接 `as` 时,先 `as unknown`(退到"什么都可能"的 `unknown`),再 `as ActionFeedbackMessage`(断成目标类型)。相当于"先清空 TS 的成见,再重新指定"。属于不得已的强转,能少用就少用,这里是为了把宽泛的 `msg` 贴成具体的反馈类型。 + +> 整段小结:**只有当调用方想看进度时,才开一条专用频道接进度,并把退订函数存好备用。** + +--- + +## 第 40-48 行:架好"等最终结果"的 Promise + +```typescript + // Create promise that resolves on action_result + const resultPromise = new Promise((resolve, reject) => { + this.client.registerPending( + id, + (result) => resolve(result as ActionResultMessage), + reject, + timeoutMs, + ); + }); +``` + +这段和第⑧篇 `services.ts` 里那段**几乎一字不差**,只是类型从 `ServiceResponseMessage` 换成 `ActionResultMessage`: +- `new Promise` 建一个待兑现承诺; +- `registerPending(id, 成功回调, reject, timeoutMs)` 登记"按 `id` 等最终结果,超时则失败"。 + +如果第⑧篇看懂了,这里直接跳过即可——**最终结果走的就是和服务一模一样的"按 id 配对"通道**。进度反馈走上面那条独立频道,两不相干。 + +--- + +## 第 50-57 行:发出动作目标 + +```typescript + // Send the goal + this.client.send({ + op: "send_action_goal", + id, + action: options.action, + action_type: options.actionType, + args: options.args, + }); +``` + +- 第⑤篇的 `ActionGoalMessage` 格式: + - `op: "send_action_goal"` —— 操作标签。 + - `id,` —— 带上配对号。 + - `action: options.action` —— 动作名。 + - `action_type: options.actionType` —— **注意命名转换**:选项里是驼峰 `actionType`,这里发给协议时变成下划线 `action_type`(回忆第⑤篇"驼峰↔下划线"——贴协议就得用下划线)。 + - `args: options.args` —— 目标参数。 +- 至此:进度频道开好了、结果承诺张好了、目标也发出去了。剩下就是等。 + +--- + +## 第 59-67 行:等结果 + 无论如何都清理(`try/finally` 登场) + +```typescript + try { + return await resultPromise; + } finally { + // Clean up feedback handler regardless of outcome + if (removeFeedbackHandler) { + removeFeedbackHandler(); + } + } + } +``` + +这是本篇唯一的全新控制结构,重点讲。 + +**语法小课堂:`try { ... } finally { ... }`。** +回忆第②、⑥篇见过 `try/catch`(试着跑,出错就进 `catch`)。这里是它的近亲 `try/finally`: +- `try { ... }` —— 先跑这块。 +- `finally { ... }` —— **不管 `try` 块是正常结束、还是中途出错、还是 `return` 了,`finally` 块都一定会执行。** 它是"善后保证"——保证某些清理动作一定发生。 +- 这里没有 `catch`,意味着"出错我不拦截(让错误照常往外抛),但**抛之前先把清理做了**"。 + +逐句看: + +```typescript +return await resultPromise; +``` +- `await resultPromise` —— 等那个"最终结果"承诺兑现,拿到结果。 + - 成功:动作做完,`registerPending` 触发 `resolve`,这里拿到 `ActionResultMessage`,`return` 出去。 + - 失败/超时:触发 `reject`,`await` 在此**抛出错误**——注意,即便抛错,下面的 `finally` 也照样会先跑。 + +```typescript +} finally { + if (removeFeedbackHandler) { + removeFeedbackHandler(); + } +} +``` +- 无论上面是 `return` 了还是抛错了,都会进到这里。 +- `if (removeFeedbackHandler)` —— 真值判断:当初**有**登记进度监听(不是 null)才需要撤。 +- `removeFeedbackHandler();` —— 调用退订函数,把那条进度频道的监听撤掉。 +- 注释 `regardless of outcome`(无论结果如何)正是 `finally` 的精髓。 + +> **为什么进度清理非得放 `finally`,不能直接写在 `await` 后面?** +> 因为如果动作**失败/超时**,`await` 会抛错,"写在后面的清理代码"就被跳过了,那条进度监听就**泄漏**了(永远挂在那、白占内存)。放进 `finally`,才能保证"成功也好、失败也好,监听一定被撤掉"。这正是 `finally` 存在的意义——**清理类代码的最佳归宿。** + +- 最外层 `}` 结束 `sendGoal`。 + +--- + +## 第 69-80 行:`cancelGoal` 取消动作 + +```typescript + /** + * Cancel an in-progress action goal. + * + * @param action - The action server name + */ + async cancelGoal(action: string): Promise { + this.client.send({ + op: "cancel_action_goal", + id: this.client.nextId("cancel"), + action, + }); + } +} +``` + +- `async cancelGoal(action: string): Promise` —— 取消某个动作,参数就一个动作名(回忆第④篇:只需一条信息时不必包成选项对象),返回 `Promise`。 +- 方法体只发一条第⑤篇的 `ActionCancelMessage`: + - `op: "cancel_action_goal"` —— 操作标签。 + - `id: this.client.nextId("cancel")` —— 给取消请求也配个号(这里取消是"发完即走",没有去 `registerPending` 等回执,所以这个 id 主要用于日志/追踪)。 + - `action,` —— 对象简写,取消哪个动作。 +- 它比 `sendGoal` 简单得多——"喊停"这个动作本身不需要等结果。 +- 最后的 `}` 关闭类。 + +--- + +## 三篇对照:发布 / 服务 / 动作,复杂度递增 + +| | 发布 (⑦) | 服务 (⑧) | 动作 (⑨) | +|---|---|---|---| +| 等回应吗 | 不等(发完即忘) | 等一个响应 | 等最终结果 | +| 配对 id | 无需 | 要(请求↔响应) | 要(目标↔结果) | +| 进度反馈 | 无 | 无 | **有**(独立频道 `__action_feedback__`) | +| 善后清理 | 无 | 无 | **有**(`try/finally` 撤进度监听) | +| 超时默认 | — | 30 秒 | 120 秒(任务长) | + +可以清楚看到:动作 = 服务的请求-响应骨架 + 进度旁路 + finally 清理。一层层叠上去,没有凭空冒出的新东西。 + +--- + +## 整章回顾 + +- `ActionClient` 把"发动作目标"做成两路并行: + - **结果路**:和服务一样,`new Promise` + `registerPending` 按 `id` 等最终 `action_result`。 + - **进度路**:若调用方给了 `onFeedback`,单开一条 `__action_feedback__` 频道实时转交进度。 +- 用 `try { return await … } finally { 撤监听 }` 保证**无论成功失败,进度监听都被清理**——这是 `finally` 最典型的用法。 +- `cancelGoal` 只是发一条取消消息,最简单。 + +至此 rosbridge 的三个小帮手(topics / services / actions)全部讲完。它们各自只懂"组一条消息、交给 client",真正的连接与配对全靠第⑥篇的 `client`。下一篇 `adapter.ts` 会把这三个帮手 + client 拼装起来,正式"实现"第④篇那个 `RosTransport` 统一接口——届时第一次见到 `implements` 关键字。 + +**语法点回顾清单**(本章新增/巩固): +- `try { ... } finally { ... }`:无论是否出错/返回,`finally` 必定执行(清理类代码的归宿) +- `!` 非空断言(值后面)vs `!` 逻辑取反(值前面)的再区分 +- 双重断言 `as unknown as X`:源/目标类型差太远时的强转手法(慎用) +- `??` 在函数体内兜底默认值(vs 第⑧篇的参数默认值写法) +- `let` 用于"值会被改写"的变量(vs `const`) +- 命名转换 `actionType`(驼峰) → `action_type`(下划线贴协议)(巩固第⑤篇) + +下一份:[`transport/rosbridge/adapter.ts` 逐行详解 →](10-rosbridge-adapter.ts.md)(把 client + 三帮手组装成实现 `RosTransport` 的适配器,首见 `implements`) diff --git a/learn/code/10-rosbridge-adapter.ts.md b/learn/code/10-rosbridge-adapter.ts.md new file mode 100644 index 0000000..872007e --- /dev/null +++ b/learn/code/10-rosbridge-adapter.ts.md @@ -0,0 +1,395 @@ +# 逐行详解 ⑩:`transport/rosbridge/adapter.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts](../../extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts) +> +> 推荐阅读顺序第 10 个文件。**这是"零件组装成成品"的一跳**:前面我们有了底层 `client`(⑥)和三个小帮手(⑦⑧⑨),又有了第④篇那张"插座标准" `RosTransport` 接口。这篇就是把零件拼起来,正式声明"我 `RosbridgeTransport` 实现了 `RosTransport` 这份契约"——本篇第一次见到关键字 `implements`。 + +--- + +## 先理解"适配器(adapter)"是什么 + +回忆第④篇的比方:`RosTransport` 是插座标准,rosbridge/local/webrtc 是三种发电方式。但我们之前写的 `client` + 三帮手,它们的"插头形状"并不完全等于插座标准——比如 `client` 有 `send`、帮手有 `publish/subscribe`,但上层工具要的是 `RosTransport` 那 13 个方法。 + +**适配器**就是中间那个"转接头":它对外长成 `RosTransport` 的样子(13 个标准方法),对内则调用 `client` 和三帮手去真正干活。所以这个类很薄——几乎每个方法都是"把 `RosTransport` 的调用翻译成对底层零件的调用"。 + +``` +上层工具 ──(只认 RosTransport 接口)──> RosbridgeTransport(适配器) + │ 内部调用 + ├─ RosbridgeClient(连接/收发) + ├─ TopicPublisher / TopicSubscriber + ├─ callService(函数) + └─ ActionClient +``` + +--- + +## 第 1-21 行:导入一大堆 + +```typescript +import type { RosTransport } from "../transport.js"; +import type { + ConnectionStatus, ConnectionHandler, Subscription, + PublishOptions, SubscribeOptions, ServiceCallOptions, ServiceCallResult, + ActionGoalOptions, ActionResult, TopicInfo, ServiceInfo, ActionInfo, + MessageHandler, +} from "../types.js"; +import { RosbridgeClient } from "./client.js"; +import { TopicPublisher, TopicSubscriber } from "./topics.js"; +import { callService } from "./services.js"; +import { ActionClient } from "./actions.js"; +import type { RosbridgeClientOptions } from "./types.js"; +``` + +注意这里出现了**两种导入**,区别很重要: + +- `import type { ... }` —— 只借**类型**(`RosTransport`、各种 `Options`/`Result`、`ConnectionStatus` 等)。这些只在"标注"里出现,不参与运行(回忆第①篇)。 + - 路径里 `../transport.js`、`../types.js` 用了 `../`(上一级目录),因为本文件在 `rosbridge/` 子目录里,要去上一层拿传输层的公共类型。 +- `import { ... }`(**不带 `type`**)—— 借的是**真正的值/类**:`RosbridgeClient`、`TopicPublisher`、`TopicSubscriber`、`callService`、`ActionClient`。 + - **语法小课堂:`import` vs `import type` 的实质区别。** 带 `type` 的导入编译后会被**完全抹掉**(运行时不存在);不带 `type` 的会真的"加载那个文件、把东西拿进来"。判断标准很简单:**你要 `new` 它、调用它,就用 `import`(值);你只拿它当类型标注,就用 `import type`。** 这里 `RosbridgeClient` 等都要 `new`/调用,所以是值导入。 + - 这些值导入的路径用 `./`(同目录的几个帮手文件)。 + +--- + +## 第 23-30 行:类声明 + 第一次见 `implements` + +```typescript +/** + * RosTransport adapter that wraps the existing RosbridgeClient. + * ... + */ +export class RosbridgeTransport implements RosTransport { +``` + +**语法小课堂:`class X implements Y` —— "类实现接口"。** +- `implements`(实现)后面跟一个接口名。它向 TS 声明:"**我保证提供 `RosTransport` 接口要求的所有方法**,签名一个不差。" +- 如果你漏了某个方法、或某个方法参数/返回类型对不上,TS 会**当场报错**。这就是第④篇说的"接口强制约束实现"——`implements` 就是那道强制开关。 +- 它和 `extends`(第⑤篇接口继承)不同: + - `extends` 是"继承别人的字段/实现"(拿来用)。 + - `implements` 是"承诺满足某个契约"(被检查),它**不会**给你任何现成实现,方法体还得自己一个个写。 +- 直白说:`implements RosTransport` = "TS 啊,请按 `RosTransport` 那张清单挨个检查我,少一个都别放过。" + +下面就是挨个兑现那 13 个方法。 + +--- + +## 第 31-37 行:字段 + 构造函数(组装零件) + +```typescript + private client: RosbridgeClient; + private actionClient: ActionClient; + + constructor(options: RosbridgeClientOptions) { + this.client = new RosbridgeClient(options); + this.actionClient = new ActionClient(this.client); + } +``` + +- 两个私有字段:一个底层 `client`、一个 `actionClient`(动作客户端)。 +- 构造函数: + - `this.client = new RosbridgeClient(options);` —— 用传进来的连接选项 `new` 一个底层客户端(回忆第⑥篇 `RosbridgeClient` 的构造)。 + - `this.actionClient = new ActionClient(this.client);` —— 再 `new` 一个动作客户端,并把刚建的 `client` **喂给它**(回忆第⑨篇 `ActionClient` 构造就吃一个 client)。 +- 这里没用第⑦篇那种"参数属性简写",而是显式写字段 + 在构造体里赋值——因为这两个字段不是直接来自参数,而是 `new` 出来的,没法用简写。 +- **注意发布器/订阅器没有在这里建**——它们在每次 `publish`/`subscribe` 时临时建(见下文),因为每次发布/订阅的话题不同。而 `client`/`actionClient` 是全程共用一个,所以放构造里。 + +--- + +## 第 39-53 行:连接生命周期(直接转发) + +```typescript + async connect(): Promise { + await this.client.connect(); + } + + async disconnect(): Promise { + await this.client.disconnect(); + } + + getStatus(): ConnectionStatus { + return this.client.getStatus(); + } + + onConnection(handler: ConnectionHandler): () => void { + return this.client.onConnection(handler); + } +``` + +这四个方法是最纯粹的"转发(delegation)"——自己不干活,原样转交给 `client`: + +- `connect` / `disconnect` —— `await this.client.connect()`。它们是 `async`,所以用 `await` 等底层做完。(注意:`async` 函数里即便只有一句 `await xxx()`,效果等同于把那个 Promise 返回出去。) +- `getStatus` —— 不是 `async`(回忆第④篇:查状态瞬间完成不用等),直接 `return this.client.getStatus()`。 +- `onConnection` —— 把回调转给 client 登记,并把 client 返回的退订函数 `return` 出去(回忆第④篇"返回一个函数")。 + +> **这就是适配器的典型味道**:方法签名严格按 `RosTransport`(因为 `implements` 在盯着),方法体则一句话甩给底层零件。 + +--- + +## 第 55-58 行:`publish`(临时建发布器) + +```typescript + publish(options: PublishOptions): void { + const publisher = new TopicPublisher(this.client, options.topic, options.type); + publisher.publish(options.msg); + } +``` + +- 接口要求 `publish(options): void`(发完即忘,不等)。 +- 方法体:临时 `new` 一个第⑦篇的 `TopicPublisher`(绑定本次的话题、类型),然后调它的 `.publish(options.msg)` 把消息体发出去。 +- **为什么每次都新建一个发布器?** 因为发布器是"绑定某话题"的轻量对象,建它几乎零成本。不同调用话题不同,临时建最简单,用完即弃。 + +--- + +## 第 60-68 行:`subscribe`(把帮手包装成接口要求的句柄) + +```typescript + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription { + const subscriber = new TopicSubscriber(this.client, options.topic, options.type); + subscriber.subscribe(handler); + return { + unsubscribe() { + subscriber.unsubscribe(); + }, + }; + } +``` + +- 接口要求返回一个 `Subscription` 句柄(回忆第③篇:那个带 `unsubscribe()` 方法的对象)。 +- 方法体: + 1. `new TopicSubscriber(...)` 建一个第⑦篇的订阅器。 + 2. `subscriber.subscribe(handler)` 真正去订阅。 + 3. `return { unsubscribe() { subscriber.unsubscribe(); } };` —— **返回一个现场拼出来的对象**,它正好满足 `Subscription` 接口(有个 `unsubscribe` 方法),方法内部调用订阅器的 `unsubscribe`。 +- **语法小课堂:对象字面量里直接写方法。** `{ unsubscribe() { ... } }` 是"对象里内联一个方法"的简写,等价于 `{ unsubscribe: function() { ... } }`。这里现场造了一个符合 `Subscription` 形状的小对象返回出去。 +- **为什么不直接 `return subscriber`?** 因为 `TopicSubscriber` 有 `subscribe`/`unsubscribe` 等一堆方法,而接口只要"一个能 `unsubscribe` 的东西"。包一层小对象,**只暴露 `unsubscribe`**,把内部的订阅器藏起来——这是"最小暴露"的好习惯。 + +--- + +## 第 70-81 行:`callService`(转发 + 重塑结果形状) + +```typescript + async callService(options: ServiceCallOptions): Promise { + const response = await callService( + this.client, + options.service, + options.args, + options.type, + ); + return { + result: response.result, + values: response.values, + }; + } +``` + +- 调用第⑧篇那个 `callService` 函数(把 `client` 和各参数传进去),`await` 拿到 `response`(类型是 `ServiceResponseMessage`)。 +- 然后**重新组装一个 `ServiceCallResult` 返回**:只挑出 `result` 和 `values` 两个字段。 +- **为什么要重新组装、不直接返回 `response`?** 因为 `response`(rosbridge 内部类型 `ServiceResponseMessage`)字段更多(还带 `op`、`service` 等协议字段),而接口承诺返回的是更干净的 `ServiceCallResult`(只有 `result`+`values`)。适配器在这里**把"内部协议形状"翻译成"对外契约形状"**——这正是适配器的核心职责:屏蔽内部细节。 + +> 注意这里有两个 `callService`:导入进来的**函数** `callService`(第⑧篇的),和当前这个**方法** `callService`(接口要求的)。名字相同但一个是模块级函数、一个是类方法,不冲突。方法体里调用的是那个导入的函数。 + +--- + +## 第 83-96 行:`sendActionGoal`(含一处精巧的回调改造) + +```typescript + async sendActionGoal(options: ActionGoalOptions): Promise { + const response = await this.actionClient.sendGoal({ + action: options.action, + actionType: options.actionType, + args: options.args, + onFeedback: options.onFeedback + ? (feedback) => options.onFeedback!(feedback.values) + : undefined, + }); + return { + result: response.result, + values: response.values, + }; + } +``` + +- 主体和服务一样:调 `this.actionClient.sendGoal(...)`(第⑨篇),`await` 拿结果,再重塑成 `ActionResult` 返回。 +- 重点看 `onFeedback` 这一段——它要解决一个"形状不匹配"问题: + +```typescript +onFeedback: options.onFeedback + ? (feedback) => options.onFeedback!(feedback.values) + : undefined, +``` + +- **语法小课堂:三元运算符 `条件 ? A : B`**(第⑥篇见过,这里再用)。读作"如果 `条件` 成立就取 `A`,否则取 `B`"。 +- 拆解这三段: + - 条件:`options.onFeedback` —— 调用方传了进度回调吗?(真值判断,第⑦篇) + - 成立(`?` 后):`(feedback) => options.onFeedback!(feedback.values)` —— 给 `sendGoal` 一个**改造过的**回调。 + - 不成立(`:` 后):`undefined` —— 没传就给 `undefined`,即不要进度。 +- **那个"改造"是什么意思?** 关键在 `feedback.values`: + - 第⑨篇的 `ActionClient` 给进度回调送来的是**整条** `ActionFeedbackMessage`(含 `op`、`action`、`values` 等)。 + - 但本适配器对外的 `ActionGoalOptions.onFeedback`(第③篇)约定只给**进度数据本身**,也就是 `values` 那部分。 + - 所以这里包一层:底层送来整条 `feedback`,我们只把 `feedback.values` 转交给调用方的回调。又是一次"内部形状→对外形状"的翻译,只不过发生在回调的参数上。 + - `options.onFeedback!` 的 `!` 是非空断言(第⑨篇讲过):在 `? :` 的成立分支里,`onFeedback` 必然存在,用 `!` 让 TS 闭嘴。 + +--- + +## 第 98-100 行:`cancelActionGoal`(转发) + +```typescript + async cancelActionGoal(action: string): Promise { + await this.actionClient.cancelGoal(action); + } +``` + +- 最简单的转发:把取消请求甩给 `actionClient.cancelGoal`(第⑨篇)。 + +--- + +## 第 102-112 行:`listTopics`(首次见数组 `.map`) + +```typescript + async listTopics(): Promise { + const response = await callService( + this.client, + "/rosapi/topics", + {}, + "rosapi/srv/Topics", + ); + const topics = (response.values?.["topics"] as string[]) ?? []; + const types = (response.values?.["types"] as string[]) ?? []; + return topics.map((name, i) => ({ name, type: types[i] ?? "" })); + } +``` + +这是"自省"方法——问机器人"你有哪些话题"。逐行: + +- 调用一个特殊服务 `"/rosapi/topics"`(rosbridge 自带的"查询所有话题"服务),类型 `"rosapi/srv/Topics"`,参数空对象 `{}`。`await` 拿到 `response`。 +- 从响应里取两个并列数组: + +```typescript +const topics = (response.values?.["topics"] as string[]) ?? []; +``` +- 一行里塞了三个语法点,拆开: + - `response.values?.["topics"]` —— **可选链 `?.`**(第⑥篇)+ **方括号取属性**:先安全地拿 `response.values`(它可能 `undefined`),再取里面 `"topics"` 这个键。`?.` 保证 `values` 万一不存在也不会崩,整体返回 `undefined`。 + - **语法小课堂:`对象["键名"]` 和 `对象.键名` 等价**,都是取属性。当键名是变量、或含特殊字符、或像这里来自动态数据时,常用方括号写法。 + - `as string[]` —— 类型断言(第⑥篇):告诉 TS"这个值是字符串数组"。因为 `values` 是 `Record`,取出来是 `unknown`,得断言成具体类型才能用。 + - `?? []` —— 空值合并(第⑥篇):万一前面整串是 `undefined`,就兜底成**空数组**。保证 `topics` 一定是个数组,后面 `.map` 才不会崩。 +- `types` 同理,拿到所有话题的类型数组(和 `topics` 一一对应,第 i 个名字配第 i 个类型)。 + +```typescript +return topics.map((name, i) => ({ name, type: types[i] ?? "" })); +``` +- **语法小课堂:数组的 `.map()` 方法。** `数组.map(函数)` 会**遍历数组每个元素、把它丢给函数、收集每次的返回值、组成一个新数组**。它不改原数组,而是"逐个变形产出新数组"。 + - 这里回调 `(name, i) => (...)` 收两个参数:`name`(当前元素,即话题名)、`i`(它的下标,从 0 开始)。`.map` 的回调第二个参数总是下标。 + - 返回 `({ name, type: types[i] ?? "" })`:把每个话题名变成一个 `TopicInfo` 对象。 + - **语法小课堂:箭头函数返回对象要套圆括号 `({...})`。** 因为箭头函数后面直接跟 `{` 会被当成"函数体的花括号",而不是"对象"。套一层圆括号 `({ ... })` 才表示"我要返回这个对象"。这是新手极易踩的坑。 + - `{ name, ... }` —— `name` 是对象简写(即 `name: name`)。 + - `type: types[i] ?? ""` —— 取第 `i` 个类型;万一对应不上(`types[i]` 是 `undefined`),兜底成空字符串 `""`。 + - 整句:把两个并列数组(名字数组 + 类型数组)"缝"成一个 `TopicInfo` 对象数组。这是处理"平行数组"的经典 `.map` 用法。 + +--- + +## 第 114-124 行:`listServices`(和 listTopics 几乎一样) + +```typescript + async listServices(): Promise { + const response = await callService(this.client, "/rosapi/services", {}, "rosapi/srv/Services"); + const services = (response.values?.["services"] as string[]) ?? []; + const types = (response.values?.["types"] as string[]) ?? []; + return services.map((name, i) => ({ name, type: types[i] ?? "" })); + } +``` + +- 结构和 `listTopics` 一模一样,只是换成查询服务 `"/rosapi/services"`。理解了上一个,这个直接跳过。 + +--- + +## 第 126-147 行:`listActions`(最复杂——靠"猜"凑出动作列表) + +```typescript + async listActions(): Promise { + // rosapi has no built-in action listing. Heuristic: action servers expose + // topics matching */_action/feedback. Extract action names from that pattern. + const topics = await this.listTopics(); + const actions: ActionInfo[] = []; + const feedbackSuffix = "/_action/feedback"; + + for (const topic of topics) { + if (topic.name.endsWith(feedbackSuffix)) { + const actionName = topic.name.slice(0, -feedbackSuffix.length); + // Feedback type is like "pkg/action/Name_FeedbackMessage" + // Extract base action type by stripping "_FeedbackMessage" suffix + let actionType = topic.type; + if (actionType.endsWith("_FeedbackMessage")) { + actionType = actionType.slice(0, -"_FeedbackMessage".length); + } + actions.push({ name: actionName, type: actionType }); + } + } + + return actions; + } +``` + +- 注释先说明难处:rosbridge **没有**"列出所有动作"的现成服务。于是用一个**启发式(heuristic,即"经验性的猜测办法")**:每个动作服务器都会暴露一个名字以 `/_action/feedback` 结尾的话题,**反过来从这些话题名就能倒推出动作名**。 +- `const topics = await this.listTopics();` —— 先复用上面写的方法,拿到所有话题。(一个方法调用本类另一个方法。) +- `const actions: ActionInfo[] = [];` —— 准备一个**空数组**用来装结果。`: ActionInfo[]` 显式标注它是动作信息数组。 +- `const feedbackSuffix = "/_action/feedback";` —— 把要找的后缀存成常量,避免重复写。 + +**`for...of` 遍历每个话题:**(回忆第⑥篇 `for...of`) + +```typescript +if (topic.name.endsWith(feedbackSuffix)) { +``` +- **语法小课堂:`字符串.endsWith(x)`** —— 判断字符串是否以 `x` 结尾,返回 `true`/`false`(回忆第⑥篇见过的 `.startsWith`,这是它的"结尾版")。只挑名字以 `/_action/feedback` 结尾的话题。 + +```typescript +const actionName = topic.name.slice(0, -feedbackSuffix.length); +``` +- **语法小课堂:`字符串.slice(起, 止)` 切片。** 取从下标 `起` 到 `止`(不含 `止`)的一段。 + - `0` —— 从头开始。 + - `-feedbackSuffix.length` —— **负数下标表示"从末尾往回数"**。`feedbackSuffix.length` 是后缀长度(`"/_action/feedback"` 有 17 个字符),`-17` 意思是"切到倒数第 17 个字符为止",**正好把末尾那段后缀砍掉**。 + - 例:`"/nav/_action/feedback"` 砍掉 `/_action/feedback` → 剩 `"/nav"`,这就是动作名。 + +```typescript +let actionType = topic.type; +if (actionType.endsWith("_FeedbackMessage")) { + actionType = actionType.slice(0, -"_FeedbackMessage".length); +} +``` +- 同理,话题的**类型**形如 `"pkg/action/Name_FeedbackMessage"`,把末尾的 `_FeedbackMessage` 砍掉,还原出动作的基础类型。 +- 用 `let`(不是 `const`)因为 `actionType` 的值可能被改写。 +- `-"_FeedbackMessage".length` —— 直接对字符串字面量取 `.length` 当负下标,省得再起个变量。 + +```typescript +actions.push({ name: actionName, type: actionType }); +``` +- **语法小课堂:`数组.push(x)`** —— 往数组末尾追加一个元素。这里把拼好的 `ActionInfo` 塞进结果数组。 + +- 循环结束后 `return actions;` 返回收集到的所有动作。 +- `}` 关闭方法,再一个 `}` 关闭整个类。 + +> **这个方法和前两个 `list` 的区别**:前两个是"调服务直接拿到现成列表再 `.map`";这个是"没有现成服务,只能拿话题列表、用 `for` 循环 + 字符串切割**猜**出来"。注释里 `Heuristic`(启发式)就是诚实地说"这是个经验性的近似办法,不保证 100% 准"。 + +--- + +## 整章回顾 + +`RosbridgeTransport` 是 rosbridge 模式的"成品总装": + +> **对外严格长成 `RosTransport`(靠 `implements` 强制对齐 13 个方法),对内把每个调用翻译给 `client` 和三个帮手,并负责把"内部协议形状"重塑成"对外契约形状"。** + +三类方法模式: +| 模式 | 例子 | 特点 | +|---|---|---| +| 纯转发 | `connect`/`disconnect`/`getStatus`/`onConnection`/`cancelActionGoal` | 一句甩给底层 | +| 转发 + 重塑结果 | `callService`/`sendActionGoal`/`publish`/`subscribe` | 调底层后,把结果/回调改造成接口要求的形状 | +| 自省(查能力) | `listTopics`/`listServices`/`listActions` | 调 rosapi 服务或猜话题,用 `.map`/`for` 整理成信息数组 | + +**语法点回顾清单**(本章新增/巩固): +- `implements`:类承诺实现某接口,TS 强制逐方法检查(vs `extends` 继承) +- `import`(值,要 new/调用)vs `import type`(仅类型,运行时抹除)的判断标准 +- 对象字面量内联方法 `{ unsubscribe() {...} }`、现场造小对象满足接口 +- 三元 `条件 ? A : B`(巩固)、`undefined` 兜底 +- 数组 `.map((元素, 下标) => ...)`:逐个变形产出新数组 +- 箭头函数返回对象要套圆括号 `(({...}))`(易错点) +- `对象["键名"]` 方括号取属性、平行数组"缝合" +- `字符串.endsWith` / `.slice(起, 止)` / 负数下标"从末尾数" +- `数组.push()` 追加元素、`for...of` 收集结果 + +下一份:[`transport/factory.ts` 逐行详解 →](11-transport-factory.ts.md)(用判别联合 + 动态 `import()` 按模式造出对应适配器,并见识 `never` 穷举检查) diff --git a/learn/code/11-transport-factory.ts.md b/learn/code/11-transport-factory.ts.md new file mode 100644 index 0000000..77086c1 --- /dev/null +++ b/learn/code/11-transport-factory.ts.md @@ -0,0 +1,182 @@ +# 逐行详解 ⑪:`transport/factory.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/factory.ts](../../extensions/openclaw-plugin/src/transport/factory.ts) +> +> 推荐阅读顺序第 11 个文件。它只有一个函数 `createTransport`——**"工厂(factory)"**:你把一份配置交给它,它看里头的 `mode` 字段,造出对应的传输适配器(rosbridge / local / webrtc 之一)。这篇会用上第③篇的"判别联合",并第一次见到两个进阶语法:**动态 `import()`** 和 **`never` 穷举检查**。 + +--- + +## 先理解"工厂函数"是什么 + +回忆第③篇:`TransportConfig` 是个判别联合——它要么是 rosbridge 配置、要么是 local、要么是 webrtc,靠 `mode` 字段区分。第⑩篇我们写好了 rosbridge 的适配器 `RosbridgeTransport`。 + +但上层代码不想自己操心"该 `new` 哪个适配器"。于是有了**工厂**:给它配置,它替你判断 `mode`、`new` 出正确的那一个、返回出来。上层只管说"按这份配置给我一个传输",至于底层是哪种,工厂内部消化。 + +``` +config(带 mode 标签) ──> createTransport ──┬─ mode="rosbridge" → new RosbridgeTransport + ├─ mode="local" → new LocalTransport + └─ mode="webrtc" → new WebRTCTransport +``` + +--- + +## 第 1-2 行:导入(全是类型) + +```typescript +import type { TransportConfig } from "./types.js"; +import type { RosTransport } from "./transport.js"; +``` + +- 两个都是 `import type`——只拿类型当标注:`TransportConfig`(入参类型)和 `RosTransport`(返回类型)。 +- **注意**:这里**没有**用普通 `import` 把三个适配器类导进来。这是故意的——下面会用"动态 `import()`"按需加载,原因马上讲。 + +--- + +## 第 4-10 行:函数注释与签名 + +```typescript +/** + * Create a RosTransport instance for the given deployment mode. + * + * Uses dynamic import() to load the correct adapter so that + * unused adapters (and their dependencies) are never loaded. + */ +export async function createTransport(config: TransportConfig): Promise { +``` + +- 注释翻译:「按给定部署模式创建一个 `RosTransport` 实例。**用动态 `import()` 只加载用到的那个适配器**,这样没用到的适配器(及其依赖)永远不会被加载。」——这句注释提前剧透了本篇的设计核心,下面会展开。 +- `export async function createTransport(config: TransportConfig): Promise` —— 导出一个 `async` 函数,吃一份 `TransportConfig`,返回 `Promise`(造适配器涉及动态加载,是异步的,所以包 `Promise`)。 + +--- + +## 第 11 行:用判别联合开 `switch` + +```typescript + switch (config.mode) { +``` + +- 回忆第⑥篇的 `switch`:拿 `config.mode` 的值去逐个 `case` 匹配。 +- 回忆第③篇的**类型收窄**:因为 `TransportConfig` 是判别联合、`mode` 是判别字段,所以进入每个 `case` 后,TS 会**自动确定 `config` 到底是哪种配置**——在 `case "rosbridge"` 里它知道 `config` 是 `RosbridgeTransportConfig`,于是 `config.rosbridge` 能安全访问。这正是判别联合的威力在真实代码里的体现。 + +--- + +## 第 12-15 行:`rosbridge` 分支(首见动态 `import()`) + +```typescript + case "rosbridge": { + const { RosbridgeTransport } = await import("./rosbridge/adapter.js"); + return new RosbridgeTransport(config.rosbridge); + } +``` + +- `case "rosbridge": {` —— 注意 `case` 后面跟了一对花括号 `{ }`。 + - **语法小课堂:给 `case` 套花括号 `{ }` 是为了开一个"块级作用域"。** 因为我们要在 `case` 里用 `const` 声明变量(`RosbridgeTransport`)。如果不套花括号,多个 `case` 里声明同名变量会冲突。套上 `{ }`,每个 `case` 的变量就关在自己的小房间里,互不打架。 + +- `const { RosbridgeTransport } = await import("./rosbridge/adapter.js");` —— 本篇重头戏: + - **语法小课堂:动态 `import()`。** 我们之前见的 `import { X } from "..."`(写在文件顶部)是**静态导入**——文件一加载,所有静态导入的东西立刻全部加载进来。而 `import("...")` 写成**函数调用的样子**,是**动态导入**——它在**代码执行到这一行时**才去加载那个模块,并返回一个 Promise(所以前面要 `await`)。 + - **为什么要动态加载?** 看注释那句话:local 模式依赖 `rclnodejs`、webrtc 模式依赖一堆 WebRTC 库。如果用静态导入把三个适配器都写在文件顶部,那么**无论用户用哪种模式,三个适配器及其全部依赖都会被加载**——哪怕你只用 rosbridge,也被迫加载了 `rclnodejs`(可能根本没装,直接报错)。 + - 用动态 `import()` 后,**只有真正走到某个 `case` 才加载那一个适配器**。用 rosbridge 就只加载 rosbridge 适配器,碰都不碰 local/webrtc。这叫**按需加载(lazy loading)**,省内存、避免为没用的模式安装依赖。 + - `const { RosbridgeTransport } = ...` —— **对象解构**(回忆第⑥篇数组解构,这是对象版):从加载进来的模块对象里,把名为 `RosbridgeTransport` 的导出"拆"出来。等价于"拿到那个模块,取它的 `RosbridgeTransport` 这一项"。 +- `return new RosbridgeTransport(config.rosbridge);` —— `new` 出第⑩篇那个适配器,把 `config.rosbridge`(连接选项)喂给它的构造函数,返回。 + - 这里 `config.rosbridge` 能直接访问,全靠 `case "rosbridge"` 里 TS 已经收窄确定了 `config` 是 rosbridge 配置。 + +--- + +## 第 17-30 行:`local` 分支(含友好的错误处理) + +```typescript + case "local": { + try { + const { LocalTransport } = await import("./local/transport.js"); + return new LocalTransport(config.local); + } catch (e: any) { + if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND") { + throw new Error( + 'Mode A (local) requires the "rclnodejs" package. ' + + "Install it with: pnpm add rclnodejs (with ROS2 workspace sourced)", + ); + } + throw e; + } + } +``` + +- 主体和 rosbridge 一样:动态 `import` local 适配器、`new` 出来返回。但它包了 `try/catch`(回忆第②、⑥篇)来处理一个现实问题——**`rclnodejs` 可能没装**。 +- `catch (e: any)` —— 抓住错误,命名为 `e`。 + - **语法小课堂:`e: any`。** `any` 是 TS 里"放弃类型检查"的逃生类型——标成 `any` 的值你爱怎么用就怎么用,TS 不管。catch 抓到的错误类型默认很模糊,这里标 `any` 图方便,好直接访问 `e.code`。(`any` 应少用,因为它关掉了类型保护,但在 catch 这种"啥错都可能"的场景常见。) +- `if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND")` —— 判断这个错误是不是"模块没找到": + - `e?.code` —— 可选链(第⑥篇):安全取错误的 `code` 字段(万一 `e` 不是对象也不崩)。 + - `=== "ERR_MODULE_NOT_FOUND" || ... === "MODULE_NOT_FOUND"` —— 用 `||`(或,第⑥篇)匹配两种可能的错误码(不同 Node 版本/场景报的码不一样,两个都认)。 +- 若确实是"模块缺失",就 `throw new Error(...)` **抛出一条人话错误**:告诉用户"local 模式需要 `rclnodejs` 包,请这样安装"。 + - 字符串用 `+` 拼接(回忆:`'...' + "..."` 把两段字符串接起来)。这比直接把 Node 原始的 `ERR_MODULE_NOT_FOUND` 甩给用户友好得多。 +- `throw e;` —— 如果错误**不是**模块缺失(是别的问题),就**原样把它再抛出去**,别吞掉。 +- **设计要点**:只有 local 分支做了这层"翻译错误",因为只有它依赖一个"可能没装"的可选包 `rclnodejs`。rosbridge 是核心、依赖必装,不用这层保护。 + +--- + +## 第 32-35 行:`webrtc` 分支 + +```typescript + case "webrtc": { + const { WebRTCTransport } = await import("./webrtc/transport.js"); + return new WebRTCTransport(config.webrtc); + } +``` + +- 和 rosbridge 分支同款:动态加载 webrtc 适配器、`new` 出来返回。(webrtc 目前是存根,第 30 篇会讲,但工厂这里照常写。) + +--- + +## 第 37-40 行:`default` 分支 + `never` 穷举检查(本篇精华) + +```typescript + default: { + const _exhaustive: never = config; + throw new Error(`Unknown transport mode: ${(_exhaustive as TransportConfig).mode}`); + } + } +} +``` + +- `default:` —— `switch` 的兜底分支(前面所有 `case` 都没匹配上才走这)。 +- `const _exhaustive: never = config;` —— **本篇最烧脑、也最妙的一行**。 + +**语法小课堂:`never` 类型与"穷举检查(exhaustiveness check)"。** +- `never` 是 TS 里一个特殊类型,意思是"**不可能存在的值**"。能赋给 `never` 的,只有"类型上根本不可能到达"的东西。 +- 想想这里的逻辑:`config` 的类型是判别联合 `rosbridge | local | webrtc` 三选一。如果上面三个 `case` 已经把三种 `mode` **全覆盖**了,那么走到 `default` 时,`config` 在 TS 看来**剩下的可能性已经为空**——它的类型被收窄成了 `never`(没有任何一种 mode 没处理)。 +- 此时 `const _exhaustive: never = config;` 能编译通过,因为 `config` 确实是 `never`。✅ +- **关键在于"将来加新模式"时**:假设以后有人往 `TransportConfig` 加了第四种 `mode: "bluetooth"`,却**忘了**在这个 `switch` 里加对应 `case`。那么走到 `default` 时,`config` 还可能是 bluetooth 配置——它的类型**不是 `never`** 了(还剩 bluetooth 没处理)。于是 `const _exhaustive: never = config;` 会**编译报错**:"bluetooth 配置不能赋给 never"。 +- **这就是穷举检查的妙处**:它把"漏处理某个 case"从"运行时才发现的 bug"变成"**编译时立刻报错**"。等于让 TS 站岗:以后谁加了新模式忘了在这里处理,代码根本编译不过,强制你补上。`_exhaustive` 这个变量名(前缀 `_` 表示"我只是用来做检查、不真用它")和注释惯例都在表达这个意图。 + +- `throw new Error(\`Unknown transport mode: ${(_exhaustive as TransportConfig).mode}\`);` —— 万一真在运行时走到了这里(比如配置是从外部 JSON 来的、绕过了类型检查、塞了个非法 mode),就抛错报告这个未知 mode。 + - 模板字符串 `` `...${...}` ``(第⑥篇)插值。 + - `(_exhaustive as TransportConfig).mode` —— `_exhaustive` 类型是 `never`,直接访问 `.mode` 不行(`never` 上没有任何属性),所以先 `as TransportConfig` 断言回联合类型,才能取它的 `.mode` 拼进错误信息。这是"为了打印出那个非法值"的小技巧。 +- 两个 `}` 分别关闭 `default` 块和 `switch`,最后 `}` 关闭函数。 + +--- + +## 把工厂的两个设计亮点拎出来 + +这个小函数浓缩了两个值得记住的工程手法: + +1. **动态 `import()` 按需加载**:只加载用到的那一个适配器,避免为没用的模式背上沉重依赖(尤其 local 的 `rclnodejs`)。判断标准:**当某些模块"很重"或"可能没装"、且不一定会用到时,用动态 import 延迟到真要用时再加载。** + +2. **`never` 穷举检查**:在 `switch` 的 `default` 里写 `const _x: never = 那个判别变量;`,让 TS 在"将来漏处理某个分支"时**编译期就报错**。这是判别联合的标准搭档,几乎所有处理判别联合的 `switch` 都该配一个,等于免费上一道保险。 + +--- + +## 整章回顾 + +- `createTransport` 是传输层的"工厂":吃一份 `TransportConfig`,按 `mode` 造出对应适配器返回。 +- 它把第③篇的**判别联合 + 类型收窄**、第⑩篇的**适配器**,和两个新手法(**动态 import**、**`never` 穷举**)串在一起,是"前面零碎知识汇合"的一篇。 +- local 分支额外做了**友好错误翻译**,因为它依赖可选包 `rclnodejs`。 + +**语法点回顾清单**(本章新增/巩固): +- 动态 `import("...")`:执行到才加载、返回 Promise、按需加载(vs 顶部静态 import) +- 对象解构 `const { X } = 模块`(从模块/对象里拆出某项) +- `case "x": { ... }` 给 case 套花括号开块级作用域(好在里面用 `const`) +- `never` 类型 + 穷举检查:`const _e: never = 判别变量` 让漏分支在编译期报错 +- `catch (e: any)`、`e?.code`、`||` 多条件、`throw new Error` 翻译错误、`throw e` 原样重抛 +- 判别联合在 `switch` 里的类型收窄(巩固第③篇) + +下一份:[`service.ts` 逐行详解 →](12-service.ts.md)(把工厂造出的传输接进插件的"服务",管理连接生命周期) diff --git a/learn/code/12-service.ts.md b/learn/code/12-service.ts.md new file mode 100644 index 0000000..6a3dfd3 --- /dev/null +++ b/learn/code/12-service.ts.md @@ -0,0 +1,317 @@ +# 逐行详解 ⑫:`service.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/service.ts](../../extensions/openclaw-plugin/src/service.ts) +> +> 推荐阅读顺序第 12 个文件。前面我们把"造传输"的活儿交给了第⑪篇的工厂 `createTransport`。但工厂只负责"造"——造出来之后,谁来保管这个传输?什么时候连上、什么时候断开?运行中想换一种模式怎么办?这些"**生命周期管理**"全在本篇。它是工厂的上一层调用者,也是整个插件与传输层之间的"中枢"。 + +--- + +## 先理解本篇要解决的三件事 + +1. **保管唯一的传输实例**:整个插件只该有**一个**活着的传输连接,所有工具共用它。本篇用一个"模块级变量"把它存起来。 +2. **管连接的开关**:插件启动时连上 ROS2,插件停止时断开——这叫"服务(service)"的生命周期,交给 OpenClaw 托管。 +3. **运行中热切换模式**:用户发 `/transport webrtc` 想换种连法时,先断旧的、再连新的,过程中还得防止"切到一半又来一个切换请求"。 + +这三件事分别对应文件里的三块:模块级状态、`switchTransport`、`registerService`。 + +--- + +## 第 1-6 行:导入 + +```typescript +import type { TransportConfig } from "./transport/types.js"; +import type { RosTransport } from "./transport/transport.js"; +import { createTransport } from "./transport/factory.js"; +import type { OpenClawPluginApi } from "./plugin-api.js"; +import type { PluginLogger } from "./plugin-api.js"; +import type { RosClawConfig } from "./config.js"; +``` + +- 大部分是 `import type`(只借类型,回忆第⑩篇判断标准):`TransportConfig`(第③篇配置)、`RosTransport`(第④篇接口)、`OpenClawPluginApi`/`PluginLogger`(第①篇宿主 API)、`RosClawConfig`(第②篇校验后的配置)。 +- **只有一个是值导入**:`import { createTransport }`——因为下面要**调用**它(工厂函数),不是只拿它当类型标注。判断标准还是那句:要调用/`new` 的用 `import`,只标注的用 `import type`。 + +--- + +## 第 8-15 行:三个"模块级变量"——本篇的状态核心 + +```typescript +/** Shared transport instance for all tools. */ +let transport: RosTransport | null = null; + +/** Tracks the active transport mode. */ +let currentMode: TransportConfig["mode"] | null = null; + +/** Concurrency guard — prevents overlapping switchTransport calls. */ +let switching = false; +``` + +**语法小课堂:模块级变量(module-level state)。** +- 前面我们见过的变量,要么在函数体里(用完就没),要么是类的字段(挂在某个对象实例上)。这三个变量**直接写在文件最外层**,不属于任何函数或类——它们是"**模块级变量**"。 +- 它的特点:**整个文件共享同一份,且只要程序在跑就一直存在**。第一次加载这个文件时它们被创建,之后这个文件里所有函数读到的都是同一份。 +- 这正好满足需求:"整个插件只有一个传输实例"——把它存成模块级变量,谁需要都来这里拿同一个。这其实是**单例(singleton)模式**的最朴素实现:用模块级变量保管唯一实例。 + +逐个看: + +- `let transport: RosTransport | null = null;` + - 类型是 `RosTransport | null`(联合类型,第③篇):要么是个传输实例,要么是 `null`(还没连或已断开)。 + - 初值 `null`——插件刚加载时还没连接。 + - 用 `let` 不用 `const`,因为它会被反复改写(连上时赋实例、断开时赋回 `null`)。 + +- `let currentMode: TransportConfig["mode"] | null = null;` + - **语法小课堂:`TransportConfig["mode"]` 是"索引访问类型(indexed access type)"。** 这是个新写法:用**方括号 + 字段名**,从一个对象类型里"取出某个字段的类型"。 + - `TransportConfig` 是第③篇那个判别联合,它的 `mode` 字段类型是 `"rosbridge" | "local" | "webrtc"`(三个字面量之一)。 + - `TransportConfig["mode"]` 就等于"把 `mode` 那个字段的类型抠出来",结果正是 `"rosbridge" | "local" | "webrtc"`。 + - **为什么不直接写 `"rosbridge" | "local" | "webrtc"`?** 因为那样要重复抄一遍。用 `TransportConfig["mode"]` 等于说"跟着源头走"——以后 `TransportConfig` 加了第四种 mode,这里**自动跟着变**,不用手改。这是"别重复自己"的类型版技巧。 + - 整体 `TransportConfig["mode"] | null`:当前模式(三选一)或 `null`(没连接)。 + +- `let switching = false;` + - 一个布尔"门闩"。没写类型标注——TS 看到初值 `false` 就**自动推断**它是 `boolean`(回忆第②篇:能推断出来就不必手写)。 + - 它的用途:标记"现在是否正有一个切换操作在进行",防止两个切换撞车。下面 `switchTransport` 会用到。 + +--- + +## 第 17-23 行:`getTransport`——取出传输(取不到就报错) + +```typescript +/** Get the active transport. Throws if not connected. */ +export function getTransport(): RosTransport { + if (!transport) { + throw new Error("Transport not initialized. Is the service running?"); + } + return transport; +} +``` + +- 这是给"所有工具"用的取货口:工具想发指令,先来这里拿传输实例。 +- `if (!transport)` —— **逻辑取反 `!`**(回忆第⑥篇:写在值**前面**是取反)。`transport` 是 `RosTransport | null`,当它是 `null` 时 `!transport` 为 `true`,进入分支。即"还没连接"。 +- `throw new Error(...)` —— 没连接就抛错,提示"传输没初始化,服务在跑吗?"。这比返回 `null` 让调用方自己去判空更省事——**取不到就直接报清楚的错**。 +- `return transport;` —— 走到这里说明 `transport` 不是 `null`。 + - **小细节**:经过上面 `if (!transport) throw` 之后,TS 会**自动收窄**——它知道"能走到这行,`transport` 必然不是 `null`",于是返回类型 `RosTransport`(不含 `null`)成立,不用再写 `!` 断言。这是"提前 throw 帮 TS 收窄"的常见手法。 + +--- + +## 第 25-28 行:`getTransportMode`——查当前模式 + +```typescript +/** Get the current transport mode, or null if no transport is active. */ +export function getTransportMode(): TransportConfig["mode"] | null { + return currentMode; +} +``` + +- 最简单的"读一个模块级变量并返回"。返回类型就是上面那个索引访问类型 `TransportConfig["mode"] | null`。 +- 谁会用它?比如 `/transport` 命令想显示"当前用的是哪种模式",就调它。 + +--- + +## 第 30-65 行:`switchTransport`——运行中热切换(本篇主菜) + +这是全篇最有料的函数:用户在聊天里发 `/transport local` 想换种连法,它负责"断旧连新"。 + +### 第 30-38 行:注释 + 签名 + 防撞车检查 + +```typescript +/** + * Switch the active transport at runtime. + * Disconnects the old transport, creates a new one, and connects it. + * No rollback on failure — the user retries with `/transport`. + */ +export async function switchTransport(config: TransportConfig, logger: PluginLogger): Promise { + if (switching) { + throw new Error("A transport switch is already in progress. Please wait."); + } +``` + +- 注释最后一句很诚实:「**失败不回滚**——用户用 `/transport` 重试即可。」意思是如果切到一半连不上,它不会帮你自动切回旧的,而是让你重来。这是个有意的简化设计,写在注释里以免误会。 +- `async ... (config, logger): Promise` —— 异步函数(连接要等),不返回有用值(`Promise`)。收两个参数:目标配置、记日志用的 `logger`。 +- `if (switching) { throw ... }` —— **并发门闩(concurrency guard)的第一半**: + - 进函数先看 `switching`。如果它已经是 `true`,说明"已经有一个切换在进行中",立刻抛错拒绝,让用户等。 + - **为什么要防这个?** 切换是"先断后连"的多步异步操作,中间有 `await`(会让出执行权)。万一用户连点两次 `/transport`,第二次可能在第一次还没切完时就插进来,两个流程交织会把状态搞乱(比如同时改 `transport`)。门闩保证"同一时刻只有一个切换在跑"。 + +### 第 40-47 行:上闩 + 断开旧传输 + +```typescript + switching = true; + try { + // Disconnect old transport + if (transport) { + await transport.disconnect(); + transport = null; + currentMode = null; + } +``` + +- `switching = true;` —— **门闩的第二半**:把闩落下。从这一刻起,再有切换请求进来就会被上面那个 `if` 挡掉。 +- `try {` —— 注意紧接着开了 `try`。配合下面的 `finally` 保证"无论成败,最后一定把闩抬起来"。(回忆第⑨篇 `try/finally`:清理类代码的归宿。这里"清理"的就是那个门闩。) +- `if (transport) { ... }` —— 如果当前**有**活着的传输,先把它收拾掉: + - `await transport.disconnect();` —— 等它断开(第④/⑩篇的 `disconnect`)。 + - `transport = null;` / `currentMode = null;` —— 把两个模块级状态清空,表示"现在没有活动传输"。 + - 注意顺序:**先断开、再清空引用**。 + +### 第 49-61 行:造新的、连上、记录 + +```typescript + // Create and connect new transport + const newTransport = await createTransport(config); + + newTransport.onConnection((status: string) => { + logger.info(`ROS2 transport status: ${status}`); + }); + + await newTransport.connect(); + + transport = newTransport; + currentMode = config.mode; + + logger.info(`ROS2 transport switched to ${config.mode}`); +``` + +- `const newTransport = await createTransport(config);` —— 调第⑪篇的工厂,按新配置造一个传输。`await` 因为工厂是异步的(动态 import)。 + - **注意这里先存进局部变量 `newTransport`,而不是直接赋给模块级的 `transport`。** 为什么?因为接下来的 `connect` 可能失败。如果直接赋给 `transport`,万一连接失败,`transport` 就指向一个"造出来但没连上"的半成品。先放局部变量,**等连接成功了再赋给模块级变量**,更稳妥。 +- `newTransport.onConnection((status: string) => { logger.info(...) });` —— 登记一个"连接状态变化"的回调(第④/⑩篇的 `onConnection`)。 + - 回调 `(status: string) => { logger.info(\`...${status}\`) }`:每次状态变化,往日志写一行。`status: string` 显式标了参数类型。 + - 这是个"只为副作用(记日志)"的回调,不关心它返回的退订函数,所以没接住返回值。 +- `await newTransport.connect();` —— 真正连上去,等它连成功。**如果这一步抛错,下面三行就不会执行**,`transport` 也就不会被赋成这个连不上的实例——这正是"先存局部变量"的好处兑现。 +- 连成功后才提交状态: + - `transport = newTransport;` —— 现在才把新传输放进模块级变量,正式"上岗"。 + - `currentMode = config.mode;` —— 记下当前模式。 + - `logger.info(...)` —— 日志报告切换成功。 + +### 第 62-65 行:`finally` 抬闩 + +```typescript + } finally { + switching = false; + } +} +``` + +- 不管上面 `try` 块是**成功跑完**、还是**中途抛错**(比如连接失败),`finally` 都一定执行。 +- `switching = false;` —— 把门闩抬起来,允许下一次切换。 +- **为什么非得放 `finally`?** 设想:如果把 `switching = false` 写在 `try` 块末尾,那么一旦连接失败抛错,这行就被跳过,门闩永远落着——之后所有切换请求都会被"已有切换在进行"挡掉,等于把功能锁死了。放进 `finally`,才能保证"哪怕这次切换炸了,门闩也会被抬起,不影响下次"。这是第⑨篇 `finally` 用法的又一个真实范例。 + +> **整个 `switchTransport` 的骨架**:上闩 → `try`{ 断旧 → 造新 → 连新 → 提交状态 } → `finally`{ 抬闩 }。门闩防并发、`finally` 保证闩一定抬起,是这类"有临界区的异步操作"的标准写法。 + +--- + +## 第 67-114 行:`registerService`——把连接生命周期托管给 OpenClaw + +这个函数在插件启动时被调一次(下一篇 `index.ts` 会看到),作用是"告诉 OpenClaw:我有个叫 ros2-transport 的服务,开机时这样连、关机时这样断"。 + +### 第 67-72 行:注释 + 签名 + 取模式 + +```typescript +/** + * Register the ROS2 transport connection as an OpenClaw managed service. + * The service handles connection lifecycle (connect on start, disconnect on stop). + */ +export function registerService(api: OpenClawPluginApi, config: RosClawConfig): void { + const mode = config.transport.mode; +``` + +- 不是 `async`——它本身只是"登记"(瞬间完成),真正的异步连接逻辑写在下面 `start`/`stop` 里。 +- 参数:`api`(第①篇宿主提供的接口)、`config`(第②篇校验后的整份配置)。 +- `const mode = config.transport.mode;` —— 从配置里取出用户选定的传输模式,存个短变量备用。 + +### 第 74-90 行:注册服务 + `start` 的开头 + +```typescript + api.registerService({ + id: "ros2-transport", + + async start(_ctx) { + let transportCfg: TransportConfig; + + switch (mode) { + case "rosbridge": + transportCfg = { mode: "rosbridge", rosbridge: config.rosbridge }; + break; + case "local": + transportCfg = { mode: "local", local: config.local }; + break; + case "webrtc": + transportCfg = { mode: "webrtc", webrtc: config.webrtc }; + break; + } +``` + +- `api.registerService({ ... })` —— 调宿主的"注册服务"方法,传一个对象描述这个服务:它有 `id`、`start`、`stop`。 +- `id: "ros2-transport"` —— 给服务起个唯一名字,OpenClaw 用它来识别/管理。 +- `async start(_ctx) { ... }` —— **对象里直接写一个 async 方法**(回忆第⑩篇 `{ unsubscribe(){...} }` 的内联方法写法,这里是它的 async 版)。这是 OpenClaw 在"启动服务"时会替我们调的钩子。 + - **语法小课堂:参数名前的下划线 `_ctx`。** OpenClaw 调 `start` 时会传一个上下文对象进来,但本函数**用不到它**。给没用到的参数名加 `_` 前缀(`_ctx` 而非 `ctx`)是一种**约定**:明示"我知道有这个参数,但故意不用它"。这能避免某些 lint 工具报"未使用变量"的警告,也让读代码的人一眼明白。 +- `let transportCfg: TransportConfig;` —— **声明一个变量但暂不赋值**。 + - **语法小课堂:先声明、后赋值。** 之前的变量都是"声明即赋值"(`const x = ...`)。这里先写 `let transportCfg: TransportConfig;`(只声明、给了类型、没给值),打算在下面的 `switch` 里**根据模式赋不同的值**。这种"一处声明、多分支赋值"必须用 `let`(`const` 要求声明时就赋值)。 + - 因为没初值,**必须显式写类型** `: TransportConfig`,否则 TS 不知道它是什么。 +- `switch (mode) { ... }` —— 按模式拼出对应的 `TransportConfig`: + - 每个 `case` 都把对应那一节配置包成判别联合的一员。比如 `case "rosbridge"` 里赋 `{ mode: "rosbridge", rosbridge: config.rosbridge }`——`mode` 字段是判别标签,`rosbridge` 字段装那一节配置(回忆第③篇判别联合的结构)。 + - 每个 `case` 末尾 `break;`(回忆第⑥篇:防止"贯穿"到下一个 case)。 + - **注意这个 `switch` 没有 `default`**。因为 `mode` 的类型是 `"rosbridge" | "local" | "webrtc"` 三选一,三个 case 已穷尽所有可能,TS 能确认"`transportCfg` 在每条路径上都会被赋值",于是后面用它时不报"可能未赋值"的错。(这和第⑪篇工厂里那个带 `default` + `never` 的 switch 是两种风格:那里要在运行时兜底非法输入,这里输入已被类型限死,可省 default。) + +### 第 92-103 行:连接(`start` 的主体) + +```typescript + api.logger.info(`Connecting to ROS2 via ${mode} transport...`); + + transport = await createTransport(transportCfg); + + transport.onConnection((status: string) => { + api.logger.info(`ROS2 transport status: ${status}`); + }); + + await transport.connect(); + currentMode = mode; + api.logger.info(`ROS2 transport connected (mode: ${mode})`); +``` + +- 这段和 `switchTransport` 的"造新连新"几乎一样,但这里直接赋给模块级 `transport`(开机首连,没有"旧的"要保护,也无需局部变量过渡)。 +- 流程:记日志 → `createTransport` 造 → `onConnection` 登记状态日志回调 → `connect` 连上 → 记 `currentMode` → 报告连接成功。 +- 注意这里用的是 `api.logger`(宿主提供的日志器),而 `switchTransport` 用的是单独传入的 `logger` 参数——两者其实是同一个东西,只是来源不同(一个从 `api` 上取,一个由调用方传入)。 + +### 第 105-114 行:断开(`stop`) + +```typescript + async stop(_ctx) { + if (transport) { + await transport.disconnect(); + transport = null; + currentMode = null; + api.logger.info("ROS2 transport disconnected"); + } + }, + }); +} +``` + +- `async stop(_ctx)` —— OpenClaw 在"停止服务"(如插件卸载、应用关闭)时调它。 +- `if (transport) { ... }` —— 有活动传输才需要断(没有就什么都不做)。 +- 断开三连:`await disconnect()` → `transport = null` → `currentMode = null`,再记一行日志。和 `switchTransport` 里"断旧"那段完全一致。 +- 最后 `});` 关闭 `registerService` 的参数对象和调用,`}` 关闭函数。 + +> **`start`/`stop` 是一对"生命周期钩子"**:你只管写"开机怎么连、关机怎么断",**何时**调它们由 OpenClaw 决定。这就是"托管服务(managed service)"——把连接的开关交给宿主统一调度,插件不用自己操心时机。 + +--- + +## 整章回顾 + +`service.ts` 是插件与传输层之间的"中枢",干三件事: + +| 部分 | 函数 | 职责 | +|---|---|---| +| 状态保管 | 三个模块级变量 | 用单例方式存唯一的 `transport`、当前 `currentMode`、并发门闩 `switching` | +| 取货口 | `getTransport`/`getTransportMode` | 给工具取传输实例、查当前模式(取不到就抛错) | +| 热切换 | `switchTransport` | 运行中"断旧连新",靠门闩防并发、靠 `finally` 保证抬闩 | +| 生命周期托管 | `registerService` | 把"开机连、关机断"登记成 OpenClaw 的 `start`/`stop` 钩子 | + +一条主线:**所有人都通过这个模块拿到那个唯一的传输**,而连接的开关被收拢到 `start`/`stop`/`switchTransport` 三处统一管理。 + +**语法点回顾清单**(本章新增/巩固): +- 模块级变量(写在文件最外层、全文件共享、程序在跑就一直存在)——单例的朴素实现 +- 索引访问类型 `TransportConfig["mode"]`(从对象类型里取某字段的类型,跟着源头自动变) +- 先声明后赋值 `let x: T;` + 在 `switch` 各分支赋值(穷尽分支时可省 `default`) +- 提前 `throw` 帮 TS 收窄(`if (!x) throw` 之后 x 不再含 null) +- 并发门闩:布尔标志 + `try { 上闩... } finally { 抬闩 }` +- 参数名加 `_` 前缀(`_ctx`)表示"故意不用" +- 对象里内联 `async` 方法(`async start(_ctx){...}`)(巩固第⑩篇内联方法) +- "先存局部变量、成功后再提交到共享状态"的稳妥赋值顺序 + +下一份:[`index.ts` 逐行详解 →](13-index.ts.md)(整个插件的总入口 `register()`——把上面这些注册函数一次性串起来) diff --git a/learn/code/13-index.ts.md b/learn/code/13-index.ts.md new file mode 100644 index 0000000..1ba5e5a --- /dev/null +++ b/learn/code/13-index.ts.md @@ -0,0 +1,192 @@ +# 逐行详解 ⑬:`index.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/index.ts](../../extensions/openclaw-plugin/src/index.ts) +> +> 推荐阅读顺序第 13 个文件,**也是整个插件的"总入口"**。OpenClaw 加载这个插件时,第一个看的就是它。它只有 40 行,没有任何复杂逻辑——它的全部职责就是:**声明"我是谁",并在被加载时把前面(和后面)写好的各个注册函数挨个调一遍,把插件的各部分装配起来。** 读完这篇,整个传输层 + 插件骨架就闭环了。 + +--- + +## 先理解"插件入口"是什么 + +OpenClaw 是宿主程序,RosClaw 是插进去的扩展。宿主怎么知道"这个插件叫什么、有哪些功能"?靠的就是一个约定:**插件必须默认导出(default export)一个对象**,对象里有 `id`、`name`,还有一个 `register` 方法。 + +- 宿主加载插件时,先读这个对象认识它(id/name)。 +- 然后在合适的时机调用它的 `register(api)`,把一个"宿主能力包" `api` 递给插件。 +- 插件就在 `register` 里"安装自己"——注册工具、注册命令、注册服务……全在这一个方法里完成。 + +所以本篇可以一句话概括:**`register` 就是插件的"安装脚本",逐行调用各部分的注册函数。** + +--- + +## 第 1-8 行:导入一堆注册函数 + +```typescript +import type { OpenClawPluginApi } from "./plugin-api.js"; +import { parseConfig } from "./config.js"; +import { registerService } from "./service.js"; +import { registerTools } from "./tools/index.js"; +import { registerSafetyHook } from "./safety/validator.js"; +import { registerRobotContext } from "./context/robot-context.js"; +import { registerEstopCommand } from "./commands/estop.js"; +import { registerTransportCommand } from "./commands/transport.js"; +``` + +- 第 1 行 `import type { OpenClawPluginApi }` —— 唯一的 `import type`,只拿它当 `register` 参数的类型标注(第①篇那个"宿主能力包"接口)。 +- 其余 7 个全是**值导入**(不带 `type`)——因为它们都要被**调用**: + - `parseConfig`(第②篇)—— 把宿主传来的原始配置校验、补默认值。 + - `registerService`(第⑫篇)—— 注册传输连接服务。 + - `registerTools`(第 14 篇会讲)—— 注册给 AI 用的那一批工具。 + - `registerSafetyHook`(第 22 篇)—— 注册"动手前先安全校验"的钩子。 + - `registerRobotContext`(第 23 篇)—— 注册"开聊前给 AI 注入机器人能力清单"的钩子。 + - `registerEstopCommand`(第 24 篇)—— 注册急停命令 `/estop`。 + - `registerTransportCommand`(第 25 篇)—— 注册切换传输命令 `/transport`。 +- **看这串导入就能预读整个插件的功能版图**:一个服务 + 一批工具 + 两个钩子 + 两个命令。本篇把它们装配到一起,而它们各自的实现是后面几篇的内容。 + +--- + +## 第 10-13 行:默认导出一个插件对象 + +```typescript +/** + * RosClaw — OpenClaw plugin for ROS2 robot control via natural language. + */ +export default { + id: "rosclaw", + name: "RosClaw", +``` + +**语法小课堂:`export default` —— "默认导出"。** +- 回忆第①篇我们见过 `export`(具名导出,可以导出很多个,靠名字区分)。这里是另一种:`export default`——**一个文件只能有一个默认导出**,它是"这个文件最主要的那个东西"。 +- 区别在导入端: + - 具名导出 `export function foo()` → 导入时要写花括号且名字对得上:`import { foo } from "..."`。 + - 默认导出 `export default {...}` → 导入时**不写花括号、名字随便起**:`import 随便什么名 from "..."`。 +- OpenClaw 约定"插件的主体用默认导出",所以这里 `export default` 一个对象,宿主加载时就拿这个对象当作"插件本体"。 +- `export default { ... }` 后面直接跟一个对象字面量 `{ id, name, register }`——这就是交给宿主的插件描述对象。 + +逐字段: +- `id: "rosclaw"` —— 插件的唯一标识(机器读,宿主内部用它区分插件)。 +- `name: "RosClaw"` —— 给人看的显示名。 + +--- + +## 第 17-20 行:`register` 方法签名 + 解析配置 + +```typescript + register(api: OpenClawPluginApi): void { + api.logger.info("RosClaw plugin loading..."); + + const config = parseConfig(api.pluginConfig ?? {}); +``` + +- `register(api: OpenClawPluginApi): void {` —— **对象里内联的一个方法**(回忆第⑩/⑫篇的内联方法写法)。它不是 `async`——注册本身是一串瞬间完成的登记动作,不需要等待。 + - 参数 `api: OpenClawPluginApi` —— 宿主把"能力包"递进来:里面有 `logger`(日志)、`pluginConfig`(用户给这个插件的原始配置)、以及 `registerService`/`registerTool`/`registerCommand` 等一系列"登记口"(第①篇详述过)。 + - 返回 `void`——注册完不返回东西。 +- `api.logger.info("RosClaw plugin loading...");` —— 往日志写一行"插件正在加载",方便排查启动过程。 +- `const config = parseConfig(api.pluginConfig ?? {});` —— **整个插件第一件实事**: + - `api.pluginConfig` —— 用户在 OpenClaw 里给这个插件填的原始配置(可能没填,则为 `undefined`)。 + - `?? {}` —— **空值合并**(第⑥篇):万一用户啥都没配(`undefined`),就用一个**空对象 `{}`** 兜底,免得把 `undefined` 传进去。 + - `parseConfig(...)` —— 调第②篇那个 Zod 校验函数:校验字段、补默认值,吐出一份**干净、完整、类型确定**的 `config`。 + - **这一步必须最先做**,因为下面所有注册函数都要吃这份 `config`。先把配置整理好,后面才能放心用。 + +--- + +## 第 22-36 行:逐个调用注册函数(安装各部分) + +```typescript + // Register the rosbridge WebSocket connection as a managed service + registerService(api, config); + + // Register all ROS2 tools with the AI agent + registerTools(api); + + // Register safety validation hook (before_tool_call) + registerSafetyHook(api, config); + + // Register robot capability injection (before_agent_start) + registerRobotContext(api, config); + + // Register direct commands (bypass AI) + registerEstopCommand(api, config); + registerTransportCommand(api, config); +``` + +这是 `register` 的主体——**就是把导入进来的注册函数挨个调一遍**,每个负责安装插件的一块。注释把每块的用途和触发时机都标清楚了: + +- `registerService(api, config);` —— 注册传输连接服务(第⑫篇)。它内部会向 `api.registerService` 登记 `start`/`stop`,让 OpenClaw 在开机/关机时连/断 ROS2。 +- `registerTools(api);` —— 注册给 AI 代理用的那批 ROS2 工具(发布、订阅、调服务、发动作……第 14–21 篇)。 + - **注意它只吃 `api`、不吃 `config`**——因为工具运行时是通过第⑫篇的 `getTransport()` 现取传输的,不需要在注册时拿配置。其余几个都吃 `config`,因为它们的行为受配置影响(如安全规则、能力清单、默认模式)。 +- `registerSafetyHook(api, config);` —— 注册安全校验钩子(第 22 篇)。注释 `before_tool_call` 点明触发点:**每次 AI 要调工具之前**先过一道安全检查。 +- `registerRobotContext(api, config);` —— 注册机器人上下文注入(第 23 篇)。注释 `before_agent_start`:**每次对话开始前**,把"机器人有哪些话题/服务/动作"喂给 AI,让它知道自己能指挥什么。 +- `registerEstopCommand(api, config);` —— 注册 `/estop` 急停命令(第 24 篇)。 +- `registerTransportCommand(api, config);` —— 注册 `/transport` 切换传输命令(第 25 篇),它内部会调第⑫篇的 `switchTransport`。 + - 注释 `bypass AI`(绕过 AI):这两个是**直接命令**——用户打 `/estop` 立刻急停,不经过 AI 理解、不绕一圈,图的就是快和确定。安全相关的操作就该这样直给。 + +> **顺序有讲究吗?** 这些注册彼此独立(都只是"登记",不立刻执行),所以顺序不太敏感。但作者把 `registerService` 放第一个是合理的——传输是地基,其余功能最终都依赖它。 + +--- + +## 第 38-40 行:收尾日志 + 关括号 + +```typescript + api.logger.info("RosClaw plugin loaded successfully"); + }, +}; +``` + +- `api.logger.info("RosClaw plugin loaded successfully");` —— 全部注册完,写一行"加载成功"。配合开头那行 "loading...",一头一尾把加载过程框起来,日志里一看就知道插件有没有顺利装完。 +- `},` —— 关闭 `register` 方法(逗号是因为它是对象的一个属性,后面可能还有别的属性)。 +- `};` —— 关闭 `export default` 那个对象,分号结束语句。 + +--- + +## 把整个插件的启动流程串起来 + +到这里,从"宿主加载"到"插件就绪"的全链路就清晰了: + +``` +OpenClaw 启动 + │ + ├─ 加载 index.ts,拿到 default export 的插件对象(认识 id="rosclaw") + │ + ├─ 调 plugin.register(api) + │ ├─ parseConfig(api.pluginConfig ?? {}) ← 先把配置校验补全(第②篇) + │ ├─ registerService(api, config) ← 登记传输 start/stop(第⑫篇) + │ ├─ registerTools(api) ← 登记 AI 工具(第14-21篇) + │ ├─ registerSafetyHook(api, config) ← 登记动手前安全校验(第22篇) + │ ├─ registerRobotContext(api, config) ← 登记开聊前能力注入(第23篇) + │ ├─ registerEstopCommand(api, config) ← 登记 /estop(第24篇) + │ └─ registerTransportCommand(api, config) ← 登记 /transport(第25篇) + │ + └─ 之后某时刻,宿主启动那个 service → 调第⑫篇的 start() → 真正连上 ROS2 +``` + +注意一个关键点:**`register` 里全是"登记",没有一个"立刻执行"。** 连接不在这里发生(在 service 的 `start` 里),工具不在这里运行(在 AI 调用时)。`register` 只是把各种"将来某时刻该干什么"先报备给宿主。**真正的活儿都是后续被宿主触发的。** 这是插件式架构的典型节奏:先声明能力,宿主按需调度。 + +--- + +## 整章回顾 + +- `index.ts` 是插件总入口,`export default` 一个 `{ id, name, register }` 对象交给 OpenClaw。 +- `register(api)` 是"安装脚本":先 `parseConfig` 整理配置,再把 7 个注册函数挨个调一遍,把服务、工具、钩子、命令全部登记到宿主。 +- 全程只"登记"不"执行"——真正的连接/工具调用/校验都是后续由宿主在恰当时机触发。 +- 读这一篇的导入清单,等于拿到了**整个插件的功能地图**:1 服务 + 1 批工具 + 2 钩子 + 2 命令,正好对应后面第 14–25 篇。 + +**语法点回顾清单**(本章新增/巩固): +- `export default`:默认导出(一文件至多一个,导入端不带花括号、可任意命名)vs 具名 `export` +- 默认导出一个对象字面量当"插件本体" +- 对象里内联普通方法 `register(api): void {...}`(巩固内联方法) +- `api.pluginConfig ?? {}`:空对象兜底缺失配置(巩固 `??`) +- "先解析配置、再用配置注册各部分"的入口编排顺序 +- 插件式架构的节奏:`register` 只登记,不立即执行 + +--- + +## 阶段性里程碑 🎉 + +读完本篇,你已经掌握了 RosClaw 的**完整骨架**: + +> 配置(②)→ 传输接口(③④)→ rosbridge 协议与实现(⑤⑥⑦⑧⑨⑩)→ 工厂(⑪)→ 服务生命周期(⑫)→ 总入口(⑬) + +也就是说,**"插件如何启动、如何连上机器人、消息如何收发"这条主干你已经全程贯通了。** 从下一篇(第 14 篇)起进入**工具层**——那是 AI 真正用来指挥机器人的一个个具体动作(发布、订阅、调服务……),相对独立、也更轻松。骨架已经吃透,后面是给骨架挂"手脚"。 + +下一份:[`tools/index.ts` 逐行详解 →](14-tools-index.ts.md)(工具层的总装配,以及一个工具的通用结构长什么样) diff --git a/learn/code/14-tools-index.ts.md b/learn/code/14-tools-index.ts.md new file mode 100644 index 0000000..19b9b4d --- /dev/null +++ b/learn/code/14-tools-index.ts.md @@ -0,0 +1,168 @@ +# 逐行详解 ⑭:`tools/index.ts`(含「工具通用结构」总览) + +> 对应源文件:[extensions/openclaw-plugin/src/tools/index.ts](../../extensions/openclaw-plugin/src/tools/index.ts) +> +> 推荐阅读顺序第 14 个文件,**工具层的开篇**。从这里开始进入插件最"接地气"的部分——**工具(tool)**:AI 真正用来指挥机器人的一个个具体动作(发布、订阅、调服务、发动作、查参数……)。本篇文件本身只有 21 行(和第⑬篇 `index.ts` 一个套路:把一堆注册函数串起来),所以我们**借这篇先把"一个工具长什么样"的通用结构讲清楚**,后面第 15–21 篇每个具体工具就只讲它的独特部分,不再重复骨架。 + +--- + +## 先理解"工具"在这套系统里的位置 + +回忆项目架构:用户用自然语言对 AI 说"让机器人前进",AI 听懂后,需要一个**实际能动手的接口**去操作机器人。这个接口就是"工具"。 + +- AI 不会直接发 WebSocket、也不懂 rosbridge 协议。它只会"调用工具"——像点菜一样:「我要调用 `ros2_publish` 工具,参数是话题 `/cmd_vel`、消息 `{前进速度: 0.5}`」。 +- 工具内部才去干脏活:拿到传输实例(第⑫篇 `getTransport`)、把 AI 给的参数翻译成 ROS2 指令发出去、把结果整理成 AI 能读的文本返回。 + +``` +AI(听懂"前进")──调用──> ros2_publish 工具 ──getTransport()──> 传输 ──> 机器人 + │ + 把结果整理成文本 ──返回──> AI(再回复用户) +``` + +所以**工具是 AI 与机器人之间的"动作按钮"**。本插件一共注册了一批这样的按钮,`tools/index.ts` 负责把它们全部装上。 + +--- + +## 第 1-8 行:导入 7 个工具的注册函数 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { registerPublishTool } from "./ros2-publish.js"; +import { registerSubscribeTool } from "./ros2-subscribe.js"; +import { registerServiceTool } from "./ros2-service.js"; +import { registerActionTool } from "./ros2-action.js"; +import { registerParamTools } from "./ros2-param.js"; +import { registerIntrospectTool } from "./ros2-introspect.js"; +import { registerCameraTool } from "./ros2-camera.js"; +``` + +- 第 1 行 `import type { OpenClawPluginApi }`——还是那个宿主能力包,只当类型标注。 +- 其余 7 个是值导入(要调用)。**这串导入就是工具层的功能清单**: + - `registerPublishTool`(第 15 篇)—— 发布消息到话题(发指令)。 + - `registerSubscribeTool`(第 16 篇)—— 订阅话题读一条消息(读传感器/状态)。 + - `registerServiceTool`(第 17 篇)—— 调用服务(请求-响应)。 + - `registerActionTool`(第 18 篇)—— 发动作目标(长任务)。 + - `registerParamTools`(第 19 篇)—— 读写参数(注意是 **Tools** 复数,它一口气注册好几个参数相关工具)。 + - `registerIntrospectTool`(第 20 篇)—— 自省:列出机器人有哪些话题/服务/动作。 + - `registerCameraTool`(第 21 篇)—— 取摄像头图像。 +- 路径都是 `./ros2-xxx.js`(同目录的各工具文件)。 + +--- + +## 第 10-21 行:`registerTools`——挨个装上 + +```typescript +/** + * Register all ROS2 tools with the OpenClaw AI agent. + */ +export function registerTools(api: OpenClawPluginApi): void { + registerPublishTool(api); + registerSubscribeTool(api); + registerServiceTool(api); + registerActionTool(api); + registerParamTools(api); + registerIntrospectTool(api); + registerCameraTool(api); +} +``` + +- 和第⑬篇 `register` 的风格完全一致:一个函数,把导入的注册函数挨个调一遍,每个把自己那个工具登记到宿主。 +- 全部只吃 `api`、不吃 `config`——回忆第⑬篇说过:工具运行时通过 `getTransport()` 现取传输,注册时不需要配置。 +- 这个 `registerTools` 就是第⑬篇 `index.ts` 里 `registerTools(api)` 那一行调用的目标。**两层"挨个调"**:`index.ts` 调各大模块的注册函数,其中 `registerTools` 再调各工具的注册函数。一层套一层,结构清爽。 + +到这里这个文件就讲完了。下面进入本篇重点——**工具的通用结构**。 + +--- + +## 工具通用结构:一个工具长什么样(重点,后面都靠它) + +第 15–21 篇每个工具,剥开看都是同一副骨架。我们现在把这副骨架讲透,后面就能专注于各工具的"肉"。 + +回忆第①篇 `plugin-api.ts` 里定义的工具类型 `AgentTool`: + +```typescript +export interface AgentTool { + name: string; + label: string; + description: string; + parameters: TSchema; + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +} +``` + +也就是说,**注册一个工具,就是给宿主递一个满足这个形状的对象**。它有 4 个"说明字段" + 1 个"干活方法": + +### ① `name`——工具的机器名 + +AI 调用时用的标识,如 `"ros2_publish"`。**必须唯一**,且通常用下划线命名(AI 工具调用的惯例)。 + +### ② `label`——给人看的显示名 + +如 `"ROS2 Publish"`。用在 UI/日志里,方便人辨认。机器不靠它。 + +### ③ `description`——给 AI 看的"使用说明" + +**这是工具里最关键的一段文字**,因为它直接决定 AI 会不会、会怎样使用这个工具。 + +- AI 看不到工具的代码,它**只能靠 `description` 判断"什么时候该用这个工具、参数怎么填"**。 +- 所以这段描述要写得像"对一个聪明但不了解你系统的助手交代任务":说清用途,最好举例。后面会看到每个工具的 description 都带 `e.g.`(例如)举例,就是为了引导 AI 正确使用。 +- 一句话:**`description` 是写给 AI 的提示词,不是写给程序员的注释。** 这是"工具开发"区别于普通函数的最大特点。 + +### ④ `parameters`——参数的"形状定义"(TypeBox) + +声明这个工具接受哪些参数、各是什么类型。它的值是一个 `TSchema`(第①篇提过的 TypeBox 类型)。 + +**语法小课堂:TypeBox 是什么、为什么不用普通类型标注。** +- 我们之前定义"对象形状"用的是 `interface`/`type`(第①篇)。但那是**纯类型**——编译后就消失了,运行时不存在(回忆第①/⑩篇:类型会被抹掉)。 +- 而工具参数有个特殊需求:**运行时也得有这份"形状信息"**。因为宿主要把它发给 AI(让 AI 知道该填什么参数),AI 回填后宿主还要**实际校验**值对不对。这些都发生在运行时,光有"编译期类型"不够。 +- TypeBox(`@sinclair/typebox`)就是解决这个的:你用 `Type.Object({...})`、`Type.String()` 这种**函数调用**来描述形状,它产出的是一个**运行时真实存在的对象**(叫 schema/模式),同时 TS 也能从它推出编译期类型。一份定义,运行时和编译期都能用。 +- 这和第②篇的 **Zod** 是"同类不同款":Zod 也是运行时 schema,用在配置校验;TypeBox 用在工具参数(因为 AI 工具生态更认它)。两者思路一样:**用代码描述形状,运行时可用。** +- 具体的 `Type.Object`/`Type.String`/`Type.Optional` 等怎么写,下一篇(第 15)第一次真正用到时细讲。 + +### ⑤ `execute`——真正干活的方法 + +工具被 AI 调用时,宿主就执行这个方法。它的签名(第①篇): + +```typescript +execute(toolCallId: string, params: Record, signal?: AbortSignal): Promise +``` + +- `toolCallId: string`——这次调用的唯一编号(多数工具用不到,所以后面常写成 `_toolCallId`,回忆第⑫篇 `_` 前缀表示故意不用)。 +- `params: Record`——**AI 填进来的参数**。注意类型是 `Record`(键是字符串、值是"未知")——因为参数是从外部(AI)来的,进来时类型不确定,工具内部要自己断言成具体类型再用(后面会看到 `params["topic"] as string`)。 +- `signal?: AbortSignal`——可选的"取消信号"(用户中途喊停时用),多数工具不处理。 +- 返回 `Promise`——异步返回一个"工具结果"。 + +`ToolResult` 的形状(第①篇): + +```typescript +export interface ToolResult { + content: ToolContent[]; // 给 AI 看的内容(文本或图片) + details?: unknown; // 可选的结构化数据(程序/UI 用) +} +``` + +- `content`——一个数组,每项是 `{ type: "text", text: "..." }`(文本)或 `{ type: "image", data, mimeType }`(图片)。**这是 AI 实际"读到"的工具输出**——AI 据此组织给用户的回复。 +- `details`——可选的原始结构化结果,给程序或仪表盘用,AI 不一定细看。 + +> **一句话记住工具骨架**:4 个说明字段告诉宿主和 AI"我是谁、干嘛用、要什么参数",`execute` 在被调用时"拿参数→干活→把结果包成 content 返回"。**后面每个工具,区别只在 `description` 写什么、`parameters` 要哪些、`execute` 里调传输的哪个方法。** 骨架都一样。 + +--- + +## 整章回顾 + +- `tools/index.ts` 是工具层的总装:`registerTools(api)` 把 7 个工具的注册函数挨个调一遍,被第⑬篇 `index.ts` 调用。 +- **工具的通用结构**(贯穿第 15–21 篇):一个 `AgentTool` 对象 = `name`(机器名)+ `label`(显示名)+ `description`(写给 AI 的使用说明,最关键)+ `parameters`(TypeBox 定义的参数形状,运行时可用)+ `execute`(拿参数、干活、返回 `ToolResult`)。 +- 工具是"AI 与机器人之间的动作按钮":AI 只管按按钮、读返回文本,按钮内部用 `getTransport()` 去操作机器人。 + +**语法点回顾清单**(本章新增/巩固): +- 两层"挨个调注册函数"的装配结构(巩固第⑬篇) +- 工具对象的 `AgentTool` 形状:name/label/description/parameters/execute(回扣第①篇) +- `description` 是写给 AI 的提示词(决定 AI 是否/如何使用工具)——工具开发特有 +- TypeBox(`Type.xxx()` 产出运行时可用的 schema)vs `interface`/`type`(编译期即抹除)的区别、与 Zod(第②篇)的对照 +- `execute(_toolCallId, params): Promise` 的签名、`ToolResult` 的 `content`/`details` + +下一份:[`ros2-publish.ts` 逐行详解 →](15-ros2-publish.ts.md)(第一个真实工具:发布消息。第一次真正写 TypeBox 参数、第一次写 `execute` 体) diff --git a/learn/code/15-ros2-publish.ts.md b/learn/code/15-ros2-publish.ts.md new file mode 100644 index 0000000..76fda06 --- /dev/null +++ b/learn/code/15-ros2-publish.ts.md @@ -0,0 +1,191 @@ +# 逐行详解 ⑮:`tools/ros2-publish.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-publish.ts](../../extensions/openclaw-plugin/src/tools/ros2-publish.ts) +> +> 推荐阅读顺序第 15 个文件,**第一个真实的工具**。上一篇我们讲了工具的通用骨架,这篇就用最简单的工具——"发布消息到话题"——把骨架填满:第一次真正写 **TypeBox 参数定义**,第一次写 **`execute` 方法体**。它对应第⑩篇适配器的 `publish`,是"发指令给机器人"的入口。吃透这一篇,后面几个工具都是同款换内容。 + +--- + +## 工具速览 + +- **它干什么**:让 AI 往任意 ROS2 话题发一条消息。最典型用途:发速度指令到 `/cmd_vel` 让机器人动起来。 +- **属于哪类**:发布是"发完即忘"(回忆第⑦篇),不等回应。所以这是最简单的工具。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +- `import { Type } from "@sinclair/typebox";` —— **值导入** TypeBox 的 `Type`。它是个对象,上面挂着 `Type.Object`、`Type.String` 等一堆方法,用来描述参数形状(上一篇讲过 TypeBox 的作用)。要调用它,所以是值导入(不带 `type`)。 +- `import type { OpenClawPluginApi }` —— 宿主能力包,类型标注。 +- `import { getTransport } from "../service.js";` —— **值导入** 第⑫篇那个"取传输实例"的函数。工具干活时靠它拿到传输。注意路径 `../service.js`(工具在 `tools/` 子目录,要回上一层)。 + +--- + +## 第 5-9 行:注释 + 注册函数签名 + +```typescript +/** + * Register the ros2_publish tool with the AI agent. + * Allows publishing messages to any ROS2 topic. + */ +export function registerPublishTool(api: OpenClawPluginApi): void { + api.registerTool({ +``` + +- `registerPublishTool(api)` —— 就是第⑭篇 `registerTools` 里调的那个。它只做一件事:调 `api.registerTool({...})`,把一个工具对象递给宿主。 +- `api.registerTool({` —— 开始传那个 `AgentTool` 对象(第⑭篇讲的 5 件套)。下面逐字段填。 + +--- + +## 第 11-15 行:name / label / description + +```typescript + name: "ros2_publish", + label: "ROS2 Publish", + description: + "Publish a message to a ROS2 topic. Use this to send commands to the robot " + + "(e.g., velocity commands to /cmd_vel, navigation goals, etc.).", +``` + +- `name: "ros2_publish"` —— AI 调用时用的机器名(下划线命名)。 +- `label: "ROS2 Publish"` —— 给人看的显示名。 +- `description:` —— **写给 AI 的使用说明**(第⑭篇强调过:这是最关键的字段)。 + - 内容翻译:「往一个 ROS2 话题发布消息。**用它来给机器人发指令**(例如发速度指令到 `/cmd_vel`、发导航目标等)。」 + - 注意它**举了例子**(`e.g.`)——告诉 AI 典型场景,引导它在"用户想让机器人动"时想到用这个工具。 + - **语法点**:这段字符串用 `+` 把两行拼成一整句(回忆第⑪篇字符串 `+` 拼接)。源码里换行只是为了不超宽,拼起来是一句话。 + +--- + +## 第 16-22 行:`parameters`——第一次写 TypeBox 参数 + +```typescript + parameters: Type.Object({ + topic: Type.String({ description: "The ROS2 topic name (e.g., '/cmd_vel')" }), + type: Type.String({ description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" }), + message: Type.Record(Type.String(), Type.Unknown(), { + description: "The message payload matching the ROS2 message type schema", + }), + }), +``` + +这是本篇第一个新东西。逐个拆 TypeBox 的写法: + +**语法小课堂:`Type.Object({...})` —— 描述一个"对象参数"。** +- `Type.Object({ 字段: 字段类型, ... })` 等于说"这个工具收一个对象,里头有这么几个字段"。括号里每个字段配一个"字段类型描述"。 +- 它和 `interface { topic: string; ... }` 表达的意思类似,但它是**函数调用**、产出**运行时可用的 schema**(第⑭篇讲过为什么要这样)。 + +里头三个字段: + +- `topic: Type.String({ description: "..." })` + - **语法小课堂:`Type.String()` —— 描述"这是个字符串"。** 括号里可以传一个选项对象,这里给了 `description`——**注意这个 description 也是写给 AI 看的**,告诉 AI 这个字段填什么("ROS2 话题名,例如 `/cmd_vel`")。每个字段都配描述 + 例子,AI 才知道怎么填。 +- `type: Type.String({ description: "..." })` + - 同样是字符串:ROS2 消息类型,例 `geometry_msgs/msg/Twist`(Twist 是"线速度+角速度"的标准消息类型)。 +- `message: Type.Record(Type.String(), Type.Unknown(), { description: "..." })` + - **语法小课堂:`Type.Record(键类型, 值类型)` —— 描述一个"键值对对象"。** 回忆第①篇的 `Record`——这就是它的 TypeBox 版: + - `Type.String()` 第一个参数:键是字符串。 + - `Type.Unknown()` 第二个参数:值是"未知/任意"(`Type.Unknown()` = TypeBox 版的 `unknown`)。 + - 第三个参数 `{ description: ... }`:照例配说明。 + - 为什么 `message` 用这么宽松的"任意键值对"?因为**不同话题的消息结构千差万别**(速度消息长一个样、导航目标又另一个样),工具没法预先写死,只能说"给我个对象,里面具体啥字段由消息类型决定",把灵活性留给 AI 按 `type` 去填。 + - 这正对应第①篇说的"消息体是任意结构"的设计。 + +> **小结这段**:`parameters` 用 TypeBox 声明"我要三个参数:话题名(字符串)、消息类型(字符串)、消息体(任意对象)",每个都配了给 AI 看的说明和例子。 + +--- + +## 第 24 行:`execute` 方法签名 + +```typescript + async execute(_toolCallId, params) { +``` + +- `async execute(...)` —— 工具被调用时执行的方法(第⑭篇讲的签名),`async` 因为里面可能要等(虽然发布本身不等,但接口统一要求返回 Promise)。 +- `_toolCallId` —— 这次调用的编号,**本工具用不到**,加 `_` 前缀表示故意不用(回忆第⑫篇)。 +- `params` —— AI 填进来的参数对象(类型 `Record`,第⑭篇说过)。 +- 注意这里**没写参数类型和返回类型**——因为 `api.registerTool` 已知道 `execute` 该长什么样(来自 `AgentTool` 接口),TS 能**自动推断**出 `_toolCallId: string`、`params: Record`、返回 `Promise`。这叫"上下文类型推断":写在已知形状的位置上,类型不必重复写。 + +--- + +## 第 25-27 行:取出并断言参数 + +```typescript + const topic = params["topic"] as string; + const type = params["type"] as string; + const message = params["message"] as Record; +``` + +- 从 `params` 里把三个参数取出来。注意两个点: + - **`params["topic"]` 用方括号取属性**(回忆第⑩篇:`对象["键名"]`)。这里用方括号是因为 `params` 的类型是 `Record`(一个宽泛的字符串键映射),用方括号取键更自然。 + - **`as string` 类型断言**(回忆第⑥篇):`params` 的值类型是 `unknown`(外部来的,不确定),取出来得断言成具体类型才能用。 + - 这里为什么敢直接断言?因为参数已经过 `parameters`(TypeBox schema)校验——宿主在调 `execute` 前会按 schema 验过,所以工具内部可以放心断言成声明的类型。**断言是"我知道它一定是这个类型"的承诺,这里有 schema 校验兜底,承诺站得住。** +- 三行分别拿到话题名、消息类型、消息体。 + +--- + +## 第 29-30 行:取传输 + 发布 + +```typescript + const transport = getTransport(); + transport.publish({ topic, type, msg: message }); +``` + +- `const transport = getTransport();` —— 调第⑫篇那个函数拿到当前活动的传输实例(取不到会抛错,第⑫篇讲过)。 +- `transport.publish({ topic, type, msg: message });` —— 调传输的 `publish`(第④篇接口、第⑩篇适配器实现)发布消息。 + - 参数是个 `PublishOptions` 对象(第③篇):`{ topic, type, msg }`。 + - `topic`、`type` 是**对象简写**(即 `topic: topic`,回忆第⑥篇)。 + - `msg: message` —— **注意这里改了名**:本工具的参数叫 `message`,但 `PublishOptions` 的字段叫 `msg`,所以写 `msg: message`(把 `message` 装进 `msg` 字段)。不能简写,因为名字不同。 +- 发布是"发完即忘",没有 `await`、没有返回值——这正是发布最简单的体现(对比下一篇订阅要等消息)。 + +--- + +## 第 32-37 行:组装并返回结果 + +```typescript + const result = { success: true, topic, type }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- `const result = { success: true, topic, type };` —— 拼一个"结果摘要"对象:`success: true`(发出去了)、回显 `topic`/`type`(对象简写)。 + - 注意:发布"发完即忘",工具**无法真正确认机器人收到没**,所以 `success: true` 只表示"消息已发出",不代表"机器人执行了"。 +- `return { content: [...], details: result };` —— 返回一个 `ToolResult`(第⑭篇讲的形状): + - `content: [{ type: "text", text: JSON.stringify(result) }]` —— 给 AI 看的内容:一个文本项。 + - **`JSON.stringify(result)`**(回忆第⑥篇)—— 把结果对象转成 JSON 字符串文本。**为什么转字符串?** 因为 `content` 的文本项要的是字符串,而 AI 读到的就是这串文本——它会看到 `{"success":true,"topic":"/cmd_vel","type":"..."}`,据此知道"发成功了、发的是哪个话题"。 + - `details: result` —— 把原始结果对象也附上(给程序/UI 用,AI 不必细读)。 + - **content vs details 的分工**:`content` 是"给 AI 读的文本版",`details` 是"给机器用的对象版"。同一份结果,两种形态各取所需。这是几乎所有工具结尾的固定套路。 +- `},` 关闭 `execute`,`});` 关闭 `registerTool` 调用,`}` 关闭注册函数。 + +--- + +## 整章回顾 + +`ros2_publish` 是最简单的工具,完整展示了工具骨架被填满的样子: + +| 字段 | 本工具填的内容 | +|---|---| +| `name` | `"ros2_publish"` | +| `description` | 「发消息到话题、用来给机器人发指令」+ 举例(写给 AI) | +| `parameters` | TypeBox:`topic`(字符串) + `type`(字符串) + `message`(任意对象) | +| `execute` | 取参数(断言)→ `getTransport()` → `transport.publish(...)` → 包成 `ToolResult` 返回 | + +执行流就一句话:**把 AI 给的话题/类型/消息,转成一次 `transport.publish` 发出去,再把"已发送"的摘要包成文本返回给 AI。** + +**语法点回顾清单**(本章新增/巩固): +- TypeBox:`Type.Object({...})`(对象)、`Type.String()`(字符串)、`Type.Record(键, 值)`(键值对)、`Type.Unknown()`(任意值),每项可带 `{ description }`(写给 AI) +- `execute` 的上下文类型推断(参数/返回类型由 `AgentTool` 接口推出,不必重写) +- `params["键"] as 类型`:从 `Record` 取参数并断言(有 schema 校验兜底) +- 对象字段改名 `msg: message`(参数名与目标字段名不同,不能简写) +- `JSON.stringify` 把结果对象转文本放进 `content`、`details` 附原始对象——工具返回的固定套路 +- 发布"发完即忘":无 `await`、无返回,`success` 仅表示"已发出" + +下一份:[`ros2-subscribe.ts` 逐行详解 →](16-ros2-subscribe.ts.md)(订阅一条消息——比发布多了"等一条消息回来",会用上 Promise + 超时竞速) diff --git a/learn/code/16-ros2-subscribe.ts.md b/learn/code/16-ros2-subscribe.ts.md new file mode 100644 index 0000000..2bddc8e --- /dev/null +++ b/learn/code/16-ros2-subscribe.ts.md @@ -0,0 +1,226 @@ +# 逐行详解 ⑯:`tools/ros2-subscribe.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-subscribe.ts](../../extensions/openclaw-plugin/src/tools/ros2-subscribe.ts) +> +> 推荐阅读顺序第 16 个文件。这是"读"方向的工具——**订阅一个话题、等下一条消息回来就返回**(读电量、读传感器、查机器人状态)。它比上一篇发布多了一件麻烦事:**得等一条消息,但又不能无限等**。于是出现一个新模式——用 `Promise` 把"收到消息"和"超时"**两件事竞速**,谁先发生就按谁结束。这把第⑦篇订阅器、第⑧篇 Promise 桥接、第②篇 `setTimeout` 串到了一起。 + +--- + +## 先理解这个工具的难点 + +发布是"发完即忘",最简单。订阅却尴尬: + +- ROS2 话题是"持续不断推消息"的流,但这个工具只想要**下一条**(叫 `subscribe_once`——订阅一次)。 +- 收到一条就该:①把它返回、②立刻退订(别继续收)、③别再等了。 +- 但万一这个话题**半天没消息**呢?不能让工具永远卡着。所以要加**超时**:等太久就放弃、报错。 + +把这两条路("收到消息"和"等超时")做成竞速——这就是本篇的核心。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +和第⑮篇一模一样:TypeBox 的 `Type`、宿主类型、取传输的 `getTransport`。不再赘述。 + +--- + +## 第 5-15 行:注册函数 + 三个说明字段 + +```typescript +/** + * Register the ros2_subscribe_once tool with the AI agent. + * Subscribes to a topic and returns the next message received. + */ +export function registerSubscribeTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_subscribe_once", + label: "ROS2 Subscribe Once", + description: + "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, " + + "check robot state, or get the current value of a topic.", +``` + +- 骨架和第⑮篇同款。 +- `name: "ros2_subscribe_once"` —— 名字里的 `once`(一次)点明语义:只取下一条就走。 +- `description` 翻译:「订阅一个话题并返回下一条消息。**用它来读传感器数据、查机器人状态、或取话题当前值。**」——又是写给 AI 的用途引导 + 举例。 + +--- + +## 第 16-20 行:`parameters`——出现"可选参数" + +```typescript + parameters: Type.Object({ + topic: Type.String({ description: "The ROS2 topic name (e.g., '/battery_state')" }), + type: Type.Optional(Type.String({ description: "The ROS2 message type (e.g., 'sensor_msgs/msg/BatteryState')" })), + timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 5000)" })), + }), +``` + +- `topic: Type.String(...)` —— 话题名,必填(和第⑮篇一样)。 +- `type: Type.Optional(Type.String(...))` —— **新东西**: + - **语法小课堂:`Type.Optional(...)` —— 把一个字段标成"可选"。** 它是 TypeBox 版的 `?`(回忆第①篇可选字段 `type?: string`)。`Type.Optional(Type.String(...))` = "这个字段是字符串,但可以不填"。 + - 为什么消息类型可选?因为有些情况底层能自己推断出类型,AI 不一定要给。 +- `timeout: Type.Optional(Type.Number(...))` —— 可选的超时毫秒数。 + - **语法小课堂:`Type.Number()` —— 描述"这是个数字"。** 和 `Type.String()` 并列的基础类型。 + - 描述里写了 `default: 5000`——但**注意:TypeBox 这里只是在说明文字里告诉 AI"默认 5000",并没有真的设默认值**。真正的兜底在下面 `execute` 里用 `??` 做(马上看到)。 + +--- + +## 第 22-25 行:`execute` 取参数 + +```typescript + async execute(_toolCallId, params) { + const topic = params["topic"] as string; + const msgType = params["type"] as string | undefined; + const timeout = (params["timeout"] as number | undefined) ?? 5000; +``` + +- `topic` —— 必填,断言成 `string`(同第⑮篇)。 +- `msgType = params["type"] as string | undefined;` —— **注意断言成 `string | undefined`**(联合类型),不是单纯 `string`。因为这个参数可选,AI 可能没填,取出来可能是 `undefined`。诚实地把"可能没有"写进类型。 + - 变量名用 `msgType` 而非 `type`,因为 `type` 在 TS 里是关键字(如 `type X = ...`),避免混淆,换个名更稳。 +- `timeout = (params["timeout"] as number | undefined) ?? 5000;` —— **一行里两个语法点**: + - `(params["timeout"] as number | undefined)` —— 先取出超时值,断言成"数字或没有"。外面套圆括号是为了让 `as` 先算完,再交给 `??`。 + - `?? 5000` —— **空值合并**(第⑥篇):如果 AI 没填(`undefined`),就兜底成 `5000`(5 秒)。**这才是"默认 5000"真正生效的地方**——上面 TypeBox 的描述只是"告知",这里才是"执行"。 + +--- + +## 第 27 行:取传输 + +```typescript + const transport = getTransport(); +``` + +- 老规矩,拿到活动传输实例。 + +--- + +## 第 29-42 行:核心——把"收消息"和"超时"做成竞速 + +```typescript + const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: msgType }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); + }, + ); + const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); + }, timeout); + }); +``` + +这是本篇的灵魂,慢慢拆。 + +### 外层:`await new Promise<...>((resolve, reject) => {...})` + +- 回忆第⑥/⑧篇:`new Promise` 建一个"待兑现承诺",给它一个函数,函数收到 `resolve`(成功)和 `reject`(失败)两个开关。 +- `>` —— 这个承诺将来兑现时给出的值是"一个对象"(订阅结果)。 +- 最外面 `await` —— 等这个承诺有结果(要么收到消息成功、要么超时失败)。 +- 和第⑧篇服务调用一个思路:**用 Promise 把异步结果接住**。区别是这里要同时安排两条"结束路径"。 + +### 路径一:订阅,收到消息就成功 + +```typescript +const subscription = transport.subscribe( + { topic, type: msgType }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ success: true, topic, message: msg }); + }, +); +``` + +- `transport.subscribe(选项, 回调)` —— 调传输的订阅(第④篇接口、第⑩篇适配器)。返回一个 `subscription` 句柄(带 `unsubscribe`,第③篇)。 + - 第一个参数 `{ topic, type: msgType }` —— 订阅选项:话题名 + 消息类型。`topic` 简写,`type: msgType` 因为名字不同(参数叫 `msgType`、字段叫 `type`)。 + - 第二个参数是**收到消息时的回调** `(msg) => {...}`。`msg: Record` 是收到的消息体。 +- 回调体三件事,顺序有讲究: + 1. `clearTimeout(timer);` —— **取消那个超时定时器**(回忆第②篇 `clearTimeout`)。既然消息已经来了,就不用再等超时了,把定时器撤掉,免得它待会儿又触发 reject。 + 2. `subscription.unsubscribe();` —— **退订**。只要这一条,订到手立刻退,不再继续收(这就是 `_once` 的含义)。 + 3. `resolve({ success: true, topic, message: msg });` —— **兑现承诺**,把结果交出去:成功标志 + 话题 + 收到的消息。外层 `await` 拿到它。 + +### 路径二:超时,没等到就失败 + +```typescript +const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); +}, timeout); +``` + +- `setTimeout(回调, 毫秒)`(回忆第②篇)—— 设一个定时器:过了 `timeout` 毫秒还没被取消,就执行回调。返回的定时器句柄存进 `timer`。 +- 超时回调干两件事: + 1. `subscription.unsubscribe();` —— 也要退订(都超时了,别再挂着监听白占资源)。 + 2. `reject(new Error(\`Timeout waiting for message on ${topic}\`));` —— **让承诺失败**,抛一个带话题名的清楚错误。外层 `await` 处会抛出这个错。 + +### 两条路径如何"竞速" + +- 两个回调都登记好后,就看**谁先发生**: + - 消息**先到** → 路径一跑:清掉定时器(路径二永不触发)、退订、`resolve` 成功。 + - 一直**没消息** → `timeout` 毫秒后路径二跑:退订、`reject` 失败。 +- **关键:无论走哪条,都会 `unsubscribe`**——保证不留下泄漏的订阅。而 `clearTimeout` 保证"成功路径"不会被"超时路径"误伤。两条路互相清理对方,干干净净。 + +> **一个易混点:`timer` 在回调里用,却在回调下面才声明,不报错吗?** +> 不报错。因为那个收消息的回调**不是立刻执行**的——它要等真有消息才跑。等它跑的时候,下面的 `const timer = setTimeout(...)` **早已执行完**、`timer` 已经有值了。JavaScript 里"晚定义、但用的时候已存在"是没问题的。这是异步回调里常见的写法。 + +--- + +## 第 44-49 行:返回结果 + +```typescript + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- 和第⑮篇结尾**一模一样**的固定套路:把 `result`(上面竞速拿到的成功结果)`JSON.stringify` 成文本放进 `content` 给 AI,再把原始对象放进 `details`。 +- 注意:如果上面是**超时失败**,`await` 那里就直接抛错了,根本走不到这个 `return`——错误会往外抛给宿主,宿主再告诉 AI"工具失败了"。所以这个 `return` 只在成功时执行。 +- 收尾的 `},`、`});`、`}` 依次关闭 `execute`、`registerTool`、注册函数。 + +--- + +## 发布 vs 订阅:工具复杂度对比 + +| | 发布 ⑮ | 订阅 ⑯ | +|---|---|---| +| 等回应吗 | 不等(发完即忘) | **等一条消息** | +| 用 Promise 吗 | 不用 | **用**(接住"将来到的消息") | +| 超时处理 | 无 | **有**(`setTimeout` + `reject`) | +| 清理 | 无 | **有**(两条路都 `unsubscribe` + `clearTimeout`) | +| execute 体 | 三五行 | 一个竞速 Promise | + +可以看到,订阅 = 发布的骨架 + 第⑧篇的 Promise 桥接 + 超时竞速。复杂度都在"等一条消息又不能死等"这件事上。 + +--- + +## 整章回顾 + +- `ros2_subscribe_once` 让 AI 读一个话题的下一条消息,用于读传感器/查状态。 +- 核心是一个**竞速 Promise**:同时安排"收到消息→`resolve` 成功"和"超时→`reject` 失败"两条路,谁先到按谁结束,且两条路都负责退订清理。 +- 参数出现了**可选**(`Type.Optional`)和**默认值兜底**(`?? 5000`),默认值的"说明"在 TypeBox 描述里、"执行"在 `execute` 的 `??` 里——两处别混淆。 + +**语法点回顾清单**(本章新增/巩固): +- `Type.Optional(...)`(可选参数,TypeBox 版的 `?`)、`Type.Number()`(数字) +- 取可选参数断言成 `string | undefined` / `number | undefined`(诚实写"可能没有") +- `(... as number | undefined) ?? 5000`:断言 + 空值合并兜底默认值(默认值真正生效处) +- 竞速 Promise:`subscribe` 回调 `resolve` vs `setTimeout` 回调 `reject`,谁先发生谁结束 +- 成功路径 `clearTimeout` + 失败路径都 `unsubscribe`:两条路互相清理,防泄漏 +- 异步回调里"晚声明的变量(`timer`)在回调真正执行时已存在" +- 工具返回的固定套路 `content`(文本) + `details`(对象)(巩固第⑮篇) +- 变量名避开关键字 `type` → 改用 `msgType` + +下一份:[`ros2-service.ts` 逐行详解 →](17-ros2-service.ts.md)(调用服务的工具——直接复用第⑧篇的请求-响应,比订阅还省心) diff --git a/learn/code/17-ros2-service.ts.md b/learn/code/17-ros2-service.ts.md new file mode 100644 index 0000000..55048d5 --- /dev/null +++ b/learn/code/17-ros2-service.ts.md @@ -0,0 +1,113 @@ +# 逐行详解 ⑰:`tools/ros2-service.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-service.ts](../../extensions/openclaw-plugin/src/tools/ros2-service.ts) +> +> 推荐阅读顺序第 17 个文件。调用服务的工具。好消息:**它几乎没有新语法**——骨架和第⑮⑯篇一模一样,干活的脏活早在第⑧篇 `callService` 里做完了。本篇的价值是让你看到"工具层有多薄":当底层(传输 + 适配器)做扎实后,上层工具就只是"取参数 → 调一个传输方法 → 包结果返回"。我们快速过一遍,重点放在它和发布/订阅的细微差别上。 + +--- + +## 工具速览 + +- **它干什么**:调用一个 ROS2 服务,等回应。用于"请求-响应"类操作:设参数、触发某个行为、查节点状态。 +- **属于哪类**:请求-响应(回忆第③篇打电话的比方、第⑧篇的 `callService`)。比订阅省心——因为"等回应 + 超时"这套逻辑早被 `callService` 封装好了,工具不用自己写竞速。 + +--- + +## 第 1-3 行:导入 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; +``` + +和第⑮⑯篇完全一致:TypeBox、宿主类型、取传输。 + +--- + +## 第 5-22 行:注册 + 说明字段 + 参数 + +```typescript +export function registerServiceTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_service_call", + label: "ROS2 Service Call", + description: + "Call a ROS2 service and return the response. Use this for request/response operations " + + "like setting parameters, triggering behaviors, or querying node state.", + parameters: Type.Object({ + service: Type.String({ description: "The ROS2 service name (e.g., '/spawn_entity')" }), + type: Type.Optional(Type.String({ description: "The ROS2 service type (e.g., 'gazebo_msgs/srv/SpawnEntity')" })), + args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { + description: "The service request arguments", + })), + }), +``` + +- 骨架你已经熟了。`description` 翻译:「调用一个服务并返回响应。**用于请求-响应操作**,比如设参数、触发行为、查节点状态。」 +- 参数三个,全是见过的 TypeBox 写法(第⑮⑯篇): + - `service: Type.String(...)` —— 服务名,必填。 + - `type: Type.Optional(Type.String(...))` —— 服务类型,**可选**(`Type.Optional`,第⑯篇)。 + - `args: Type.Optional(Type.Record(...))` —— 请求参数,**可选的键值对对象**。这里是 `Type.Optional` 套着 `Type.Record`——"可以不填;要填就是个任意对象"。 +- 没有任何新语法,跳过细讲。 + +--- + +## 第 24-41 行:`execute`——取参数、调 callService、包结果 + +```typescript + async execute(_toolCallId, params) { + const service = params["service"] as string; + const type = params["type"] as string | undefined; + const args = params["args"] as Record | undefined; + + const transport = getTransport(); + const response = await transport.callService({ service, type, args }); + + const result = { + success: response.result, + service, + response: response.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +逐段: + +- **取参数**(第⑮⑯篇套路): + - `service` 必填 → 断言 `string`。 + - `type`、`args` 可选 → 断言成 `... | undefined`(诚实写"可能没有",回忆第⑯篇)。 +- `const transport = getTransport();` —— 拿传输。 +- `const response = await transport.callService({ service, type, args });` —— **本工具的核心一行**: + - 调传输的 `callService`(第④篇接口、第⑩篇适配器、底层是第⑧篇那个函数),传一个 `{ service, type, args }` 对象(三个对象简写)。 + - `await` 等响应回来。**注意:这里不用自己写 Promise、不用 setTimeout 超时**——因为这些第⑧篇的 `callService` 内部全做好了(它自带 30 秒超时、自带按 id 配对)。工具只管 `await` 拿结果。**这就是"底层做扎实、上层就轻松"的直接体现**——对比第⑯篇订阅工具得自己手写竞速,这里一行 `await` 搞定。 +- **包结果**: + - `const result = { success: response.result, service, response: response.values };` + - `success: response.result` —— 服务响应里的 `result` 字段是"成功与否"的布尔(第⑤篇 `ServiceCallResult`),这里改名装进 `success`。 + - `service` —— 对象简写,回显服务名。 + - `response: response.values` —— 把服务返回的数据 `values` 装进 `response` 字段。 + - **注意这里的字段改名**:底层的 `result`/`values` 被重命名成对 AI 更友好的 `success`/`response`。回忆第⑩篇适配器也做过"重塑结果形状"——这里工具又做了一层面向 AI 的措辞调整。 + - 最后 `return { content: [...JSON.stringify(result)...], details: result }` —— 还是第⑮篇那个固定收尾套路。 + +--- + +## 整章回顾 + +- `ros2_service_call` 是"调服务"工具,本质就是 `execute` 里一句 `await transport.callService(...)`,前面取参数、后面包结果。 +- **它最大的教学意义是反衬"分层"的好处**:请求-响应那套麻烦逻辑(配对、等待、超时)沉在第⑧篇,工具层因此薄得只剩"翻译参数、转发、整理结果"。这正是好架构的样子——复杂度被关在底层,上层一目了然。 +- 全篇无新语法,是一篇"巩固 + 体会分层"的轻松章节。 + +**语法点回顾清单**(本章无新增,全是巩固): +- 工具五件套骨架(第⑭篇)、TypeBox 参数(第⑮⑯篇)、`Type.Optional`(第⑯篇) +- 可选参数断言成 `... | undefined`(第⑯篇) +- `await transport.callService(...)`:直接复用第⑧篇封装好的请求-响应(自带超时/配对) +- 结果字段改名 `success: response.result` / `response: response.values`(面向 AI 的措辞) +- `content` + `details` 返回套路(第⑮篇) + +下一份:[`ros2-action.ts` 逐行详解 →](18-ros2-action.ts.md)(发动作目标的工具——和本篇几乎双胞胎,底层换成第⑨篇的 `sendActionGoal`) diff --git a/learn/code/18-ros2-action.ts.md b/learn/code/18-ros2-action.ts.md new file mode 100644 index 0000000..485578e --- /dev/null +++ b/learn/code/18-ros2-action.ts.md @@ -0,0 +1,123 @@ +# 逐行详解 ⑱:`tools/ros2-action.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-action.ts](../../extensions/openclaw-plugin/src/tools/ros2-action.ts) +> +> 推荐阅读顺序第 18 个文件。发动作目标的工具。它和上一篇 `ros2-service.ts` 几乎是**双胞胎**——只把底层从 `callService` 换成第⑨篇的 `sendActionGoal`。**完全没有新语法。** 我们更快地过一遍,重点只说"动作工具和服务工具差在哪",以及一个值得注意的取舍。 + +--- + +## 工具速览 + +- **它干什么**:给动作服务器发一个目标,用于**长任务**:导航到某处、机械臂运动等(回忆第③⑨篇:动作 = 长任务 + 进度反馈 + 可中途取消)。 +- **和服务工具的关系**:骨架一字不差,底层方法换成 `sendActionGoal`。 + +--- + +## 第 1-22 行:导入 + 说明字段 + 参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_action_goal tool with the AI agent. + * Sends action goals with progress feedback streaming. + */ +export function registerActionTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_action_goal", + label: "ROS2 Action Goal", + description: + "Send a goal to a ROS2 action server and stream feedback. " + + "Use this for long-running operations like navigation or arm movements.", + parameters: Type.Object({ + action: Type.String({ description: "The ROS2 action server name (e.g., '/navigate_to_pose')" }), + actionType: Type.String({ description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" }), + goal: Type.Record(Type.String(), Type.Unknown(), { + description: "The action goal parameters", + }), + }), +``` + +- 导入、骨架同前。`description` 翻译:「给动作服务器发目标并流式接收反馈。**用于长任务**,如导航或机械臂运动。」 +- 三个参数(都是熟面孔): + - `action: Type.String(...)` —— 动作服务器名,必填。 + - `actionType: Type.String(...)` —— 动作类型,**必填**(注意没套 `Type.Optional`——回忆第⑨篇:动作类型不能省)。 + - `goal: Type.Record(...)` —— 目标参数(任意对象),**必填**(注意也没 `Optional`——发动作总得给个目标)。 +- 和服务工具的细微差别:服务的 `type`/`args` 都是可选,这里 `actionType`/`goal` 都必填。这反映了"动作必须明确目标"的语义。 + +--- + +## 第 24-45 行:`execute` + +```typescript + async execute(_toolCallId, params) { + const action = params["action"] as string; + const actionType = params["actionType"] as string; + const goal = params["goal"] as Record; + + const transport = getTransport(); + const actionResult = await transport.sendActionGoal({ + action, + actionType, + args: goal, + }); + + const result = { + success: actionResult.result, + action, + result: actionResult.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- **取参数**:三个都必填,全断言成确定类型(不带 `| undefined`,因为没一个是可选的)。 +- `const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });` + - 调第⑨篇的 `sendActionGoal`(经第⑩篇适配器),`await` 等动作做完拿最终结果。 + - 参数对象:`action`、`actionType` 对象简写;`args: goal` —— **改名**(本工具参数叫 `goal`,但 `ActionGoalOptions` 的字段叫 `args`,所以 `args: goal`,回忆第⑮篇 `msg: message` 同理)。 + - **注意一个"缺失"**:第⑨/③篇的 `sendActionGoal` 支持 `onFeedback`(进度回调),但**这里没传**。也就是说,这个工具虽然 description 说"stream feedback(流式反馈)",实现上却只 `await` 等最终结果、没有真正把中途进度转给 AI。 + - 为什么?因为工具的 `execute` 是"调用一次、返回一次"的模型——它没有一个持续的通道把"导航到 30%…60%…"实时喂给 AI。所以这里务实地只等最终结果。description 里的"stream feedback"更多是底层能力的描述,工具层暂未利用。**这是个诚实值得记下的取舍**:底层支持进度,但工具这一层用"等最终结果"简化了。(将来若做实时仪表盘——第 32 篇的 canvas——才会真正用上进度流。) +- **包结果**: + - `success: actionResult.result` —— 动作结果的成功标志(第⑤篇 `ActionResult` 的 `result`)。 + - `action` —— 回显动作名。 + - `result: actionResult.values` —— 动作返回数据装进 `result` 字段。 + - **小注意**:这里外层结果对象有个字段也叫 `result`(`result: actionResult.values`),别和 `actionResult.result`(成功标志)搞混——前者是"返回数据"、后者是"成功与否",名字撞了但含义不同。服务工具那里把数据叫 `response`,这里叫 `result`,是各文件各自的措辞,不必深究。 + - 收尾还是 `content` + `details` 套路。 + +--- + +## 服务工具 vs 动作工具:双胞胎对照 + +| | 服务工具 ⑰ | 动作工具 ⑱ | +|---|---|---| +| 底层方法 | `callService` | `sendActionGoal` | +| 参数可选性 | `type`/`args` 可选 | `actionType`/`goal` 必填 | +| 等待 | 等响应(秒级) | 等最终结果(可能很久) | +| 进度反馈 | 无此概念 | 底层支持,但**工具未利用** | +| execute 复杂度 | 一行 await | 一行 await(同样薄) | + +> 两个工具骨架完全相同,差别全在"底层调哪个方法""哪些参数必填"。再次印证:**工具层是薄薄的转发层。** + +--- + +## 整章回顾 + +- `ros2_action_goal` 是"发动作目标"工具,`execute` 核心就是 `await transport.sendActionGoal(...)`,和服务工具是双胞胎。 +- 值得记住的一点:它**没有利用底层的进度回调**,只等最终结果——这是"工具调用一次返回一次"模型下的务实简化,description 里的"stream feedback"暂未在工具层兑现。 +- 全篇无新语法。 + +**语法点回顾清单**(本章无新增,全是巩固): +- 工具骨架 + TypeBox 参数(必填 vs `Type.Optional`) +- 全必填参数断言成确定类型(无 `| undefined`) +- `await transport.sendActionGoal(...)` 复用第⑨篇动作骨架 +- 对象字段改名 `args: goal` +- 底层能力(进度回调)在工具层可以选择不用——一个真实的工程取舍 + +下一份:[`ros2-param.ts` 逐行详解 →](19-ros2-param.ts.md)(参数读写——一个文件里注册**两个**工具,且看到"把参数操作翻译成调用标准服务"的巧思) diff --git a/learn/code/19-ros2-param.ts.md b/learn/code/19-ros2-param.ts.md new file mode 100644 index 0000000..520efe3 --- /dev/null +++ b/learn/code/19-ros2-param.ts.md @@ -0,0 +1,210 @@ +# 逐行详解 ⑲:`tools/ros2-param.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-param.ts](../../extensions/openclaw-plugin/src/tools/ros2-param.ts) +> +> 推荐阅读顺序第 19 个文件,工具层里行数最多的一个(83 行),但**不难**——因为它其实是**两个工具**装在一个文件里(读参数 `ros2_param_get` + 写参数 `ros2_param_set`),每个都是熟悉的骨架。本篇真正的新意不在语法,而在一个**设计巧思**:ROS2 没有"直接读写参数"的独立通道,参数读写其实是**调用节点自带的标准服务**完成的。这篇让你看到工具如何"把一种操作翻译成调用某个约定俗成的服务"。 + +--- + +## 先理解"参数"和它的读写方式 + +- ROS2 里每个**节点**(node,机器人上一个运行的程序单元)都可以有一堆**参数**(parameter)——比如最大速度、某个开关。 +- 怎么读写它们?ROS2 有个约定:**每个节点自动提供两个标准服务**: + - `<节点名>/get_parameters` —— 读参数。 + - `<节点名>/set_parameters` —— 写参数。 +- 所以"读参数"本质就是"调用那个节点的 `get_parameters` 服务"。本工具做的,就是把 AI 友好的"读 node 的某参数"翻译成"调 `/get_parameters` 服务、传对参数"。**它是建在第⑰篇服务调用之上的一层"语义包装"。** + +> 这呼应第⑩篇 `listTopics` 调 `/rosapi/topics`、第⑪篇没有现成接口就找替代——ROS2 里大量能力都是"调某个约定服务"实现的。认识这个模式,很多代码就通了。 + +--- + +## 第 1-8 行:导入 + 注册函数(一个函数注册俩工具) + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register ros2_param_get and ros2_param_set tools with the AI agent. + */ +export function registerParamTools(api: OpenClawPluginApi): void { +``` + +- 导入同前。 +- **注意函数名是复数 `registerParamTools`**(带 s)——回忆第⑭篇导入清单时埋的伏笔:它一口气注册**两个**工具。函数体里会有**两次** `api.registerTool(...)` 调用。其他工具文件都是"一个文件一个工具",这个是"一个文件两个工具"。把读、写两个相关工具放一起合情合理。 + +--- + +## 第一个工具:`ros2_param_get`(读参数) + +### 第 9-18 行:说明字段 + 参数 + +```typescript + api.registerTool({ + name: "ros2_param_get", + label: "ROS2 Get Parameter", + description: + "Get the value of a ROS2 parameter from a node. " + + "Use this to check robot configuration values.", + parameters: Type.Object({ + node: Type.String({ description: "The fully qualified node name (e.g., '/turtlebot3/controller')" }), + parameter: Type.String({ description: "The parameter name (e.g., 'max_velocity')" }), + }), +``` + +- `description`:「从一个节点读取某参数的值。用它来查机器人的配置值。」 +- 参数两个,都必填字符串: + - `node` —— **完整节点名**(fully qualified,即带完整路径的,如 `/turtlebot3/controller`)。 + - `parameter` —— 参数名(如 `max_velocity`)。 + +### 第 20-29 行:`execute`——翻译成调 `get_parameters` 服务 + +```typescript + async execute(_toolCallId, params) { + const node = params["node"] as string; + const parameter = params["parameter"] as string; + + const transport = getTransport(); + const response = await transport.callService({ + service: `${node}/get_parameters`, + type: "rcl_interfaces/srv/GetParameters", + args: { names: [parameter] }, + }); +``` + +- 取两个参数 `node`、`parameter`(必填、断言 `string`)。 +- 拿传输后,**核心是这次 `callService`**——把"读参数"翻译成"调标准服务": + - `service: \`${node}/get_parameters\`` —— **用模板字符串拼出服务名**(回忆第⑥篇 `` `${x}` ``)。比如 `node` 是 `/turtlebot3/controller`,拼出来就是 `/turtlebot3/controller/get_parameters`。**这就是"参数读取 = 调那个节点的 get_parameters 服务"的落地。** + - `type: "rcl_interfaces/srv/GetParameters"` —— 这是 ROS2 标准的"读参数服务"类型,写死的(所有节点的 get_parameters 都是这个类型)。 + - `args: { names: [parameter] }` —— 这个标准服务要求传一个 `names` 字段,**它是个数组**(可以一次读多个参数)。这里只读一个,所以包成单元素数组 `[parameter]`(回忆第⑥篇数组字面量)。 + - **小注意**:服务接口要的是"参数名列表",即使只读一个也得放进数组——这是迁就底层服务的固定形状。 + +### 第 31-42 行:包结果返回 + +```typescript + const result = { + success: response.result, + node, + parameter, + value: response.values, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +``` + +- 拼结果:`success`(成功标志)、回显 `node`/`parameter`(对象简写)、`value: response.values`(读到的值装进 `value`)。 +- 还是 `content` + `details` 套路。 +- `});` 关闭第一个工具的注册。**注意这里函数还没结束**——下面紧接着第二个工具。 + +--- + +## 第二个工具:`ros2_param_set`(写参数) + +### 第 44-54 行:说明字段 + 参数 + +```typescript + api.registerTool({ + name: "ros2_param_set", + label: "ROS2 Set Parameter", + description: + "Set the value of a ROS2 parameter on a node. " + + "Use this to change robot configuration at runtime.", + parameters: Type.Object({ + node: Type.String({ description: "The fully qualified node name" }), + parameter: Type.String({ description: "The parameter name" }), + value: Type.Unknown({ description: "The new parameter value" }), + }), +``` + +- **第二次** `api.registerTool(...)`——注册写参数工具。 +- `description`:「在节点上设置某参数的值,用来在运行时改机器人配置。」 +- 参数比读多一个: + - `node`、`parameter` 同上。 + - `value: Type.Unknown({ ... })` —— **新参数:要设的新值**。用 `Type.Unknown()`(任意类型,第⑮篇)——因为参数值可能是数字、字符串、布尔……什么都可能,所以用"任意值"。 + +### 第 56-70 行:`execute`——翻译成调 `set_parameters` 服务 + +```typescript + async execute(_toolCallId, params) { + const node = params["node"] as string; + const parameter = params["parameter"] as string; + const value = params["value"]; + + const transport = getTransport(); + const response = await transport.callService({ + service: `${node}/set_parameters`, + type: "rcl_interfaces/srv/SetParameters", + args: { + parameters: [ + { name: parameter, value }, + ], + }, + }); +``` + +- 取参数:`node`、`parameter` 断言 `string`。 +- `const value = params["value"];` —— **注意这行没有 `as` 断言**!因为 `value` 本就是"任意类型",取出来是 `unknown`,而下面要做的只是把它原样传给服务,不需要断言成某个具体类型。**用不到具体类型时,就不必断言**——这和前面"断言成 string 才能用"形成对照:断言是为了"用这个值的某个具体能力",纯转发则不需要。 +- 核心 `callService`,和读参数对称: + - `service: \`${node}/set_parameters\`` —— 拼出写参数的标准服务名。 + - `type: "rcl_interfaces/srv/SetParameters"` —— 标准的"写参数服务"类型。 + - `args` 这次结构复杂些: + ```typescript + args: { + parameters: [ + { name: parameter, value }, + ], + }, + ``` + - 这个标准服务要的是一个 `parameters` 字段,**也是数组**(可一次设多个)。 + - 数组里每项是 `{ name, value }`——参数名 + 新值。这里设一个,所以单元素数组里放一个 `{ name: parameter, value }`(`value` 对象简写)。 + - **这是个"对象套数组套对象"的嵌套结构**——逐层看就不晕:最外 `args` 是对象 → 里面 `parameters` 是数组 → 数组元素是 `{name, value}` 对象。新手遇到嵌套结构,**从外往里一层层剥**即可。 + +### 第 72-82 行:包结果返回 + +```typescript + const result = { + success: response.result, + node, + parameter, + }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- 结果比读参数少个 `value`(写操作不需要回显值,报告成功 + 是哪个节点的哪个参数即可)。 +- 收尾 `content` + `details`。 +- `});` 关闭第二个工具,最后 `}` 关闭整个 `registerParamTools` 函数(**这才是两个工具都注册完**)。 + +--- + +## 整章回顾 + +- `ros2-param.ts` 在**一个注册函数里注册了两个工具**(读 `ros2_param_get` + 写 `ros2_param_set`),所以函数名是复数 `registerParamTools`。 +- 两个工具的核心巧思是同一个:**参数读写没有专门通道,而是调用节点自带的标准服务** `/get_parameters` 和 `/set_parameters`(类型固定为 `rcl_interfaces/srv/Get/SetParameters`)。工具用**模板字符串拼出服务名**、按标准服务要求的形状(`names` 数组 / `parameters` 数组)组织参数。 +- 认识这个模式("某能力 = 调某约定服务")后,回头看第⑩篇 `listTopics` 调 `/rosapi/topics` 就完全是同一回事。 + +**语法点回顾清单**(本章新增/巩固): +- 一个注册函数里 **两次** `api.registerTool`(一个文件注册多个工具) +- 模板字符串拼服务名 `` `${node}/get_parameters` ``(巩固第⑥篇) +- 迁就底层服务形状:单个值也要包成数组 `names: [parameter]`、嵌套 `parameters: [{ name, value }]` +- 嵌套结构"从外往里逐层剥"的阅读法 +- `Type.Unknown()` 描述"任意类型的值"参数(第⑮篇) +- 纯转发的值**不必断言**(`const value = params["value"];` 没有 `as`)vs 要用具体能力才断言——一个对照 + +--- + +## 工具层进度小结 + +到这里,工具层已讲完 5 个(发布⑮、订阅⑯、服务⑰、动作⑱、参数⑲)。它们共享同一副骨架,差别只在"调底层哪个方法、参数怎么配、结果怎么措辞"。**你已经完全掌握了工具的写法套路。** 剩下两个工具(第 20 自省、第 21 摄像头)会各带一点小新意(自省是"列能力"、摄像头涉及图片返回),但骨架都一样,会更快。 + +下一份:[`ros2-introspect.ts` 逐行详解 →](20-ros2-introspect.ts.md)(自省工具:让 AI 问"机器人有哪些话题/服务/动作"——直接调第⑩篇那几个 list 方法) diff --git a/learn/code/20-ros2-introspect.ts.md b/learn/code/20-ros2-introspect.ts.md new file mode 100644 index 0000000..1aa9308 --- /dev/null +++ b/learn/code/20-ros2-introspect.ts.md @@ -0,0 +1,83 @@ +# 逐行详解 ⑳:`tools/ros2-introspect.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-introspect.ts](../../extensions/openclaw-plugin/src/tools/ros2-introspect.ts) +> +> 推荐阅读顺序第 20 个文件,工具层里**最短、最简单的一个**(29 行)。它让 AI 能问"机器人现在有哪些话题"——属于"自省(introspection,自我检查/发现能力)"。本篇唯一的新意是一个小语法:**空参数 `Type.Object({})`**(这个工具不需要任何参数)。其余全是熟套路,几分钟就能过完。 + +--- + +## 工具速览 + +- **它干什么**:列出机器人当前所有话题及其消息类型。让 AI **先了解"机器人能干什么、发什么数据"**,再决定调别的工具。 +- **底层**:直接调第⑩篇适配器的 `listTopics`(那个用 `.map` 把 rosapi 返回缝成 `TopicInfo[]` 的方法)。 + +--- + +## 第 1-16 行:导入 + 说明字段 + 空参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_list_topics tool with the AI agent. + * Allows the agent to discover available ROS2 topics at runtime. + */ +export function registerIntrospectTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_list_topics", + label: "ROS2 List Topics", + description: + "List all available ROS2 topics and their message types. " + + "Use this to discover what data the robot publishes and what commands it accepts.", + parameters: Type.Object({}), +``` + +- 导入、骨架同前。 +- `description`:「列出所有可用话题及其消息类型。**用它来发现机器人发布什么数据、接受什么指令。**」——这段对 AI 尤其重要:它引导 AI 在"不确定机器人有什么"时**先调这个工具摸底**。 +- `parameters: Type.Object({})` —— **新点:空对象参数。** + - **语法小课堂:`Type.Object({})` 表示"这个工具不需要任何参数"。** 回忆第⑮篇 `Type.Object({ topic: ..., ... })` 里花括号装着字段。这里花括号**空着**,就是声明"无参数"。 + - 为什么无参数?因为"列出所有话题"不需要任何输入——AI 直接调用即可。 + - 注意**还是要写 `Type.Object({})`,不能省略 `parameters`**——接口要求每个工具都得有 `parameters`(哪怕是空的),保持形状统一。 + +--- + +## 第 18-28 行:`execute` + +```typescript + async execute(_toolCallId, _params) { + const transport = getTransport(); + const topics = await transport.listTopics(); + + const result = { success: true, topics }; + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- `async execute(_toolCallId, _params)` —— **注意两个参数都加了 `_` 前缀**(`_toolCallId`、`_params`)。回忆第⑫篇:`_` 表示"故意不用"。因为这个工具既不看调用编号、也没有参数要读,两个都用不上,于是都标 `_`。这是目前第一个**连 `params` 都不用**的工具(前面的工具至少要读参数)。 +- `const transport = getTransport();` —— 取传输。 +- `const topics = await transport.listTopics();` —— **核心一行**:调第⑩篇的 `listTopics`,`await` 拿到话题信息数组 `TopicInfo[]`(每项 `{ name, type }`)。所有复杂活(调 rosapi、`.map` 缝合)都在第⑩篇做完了,这里一行取货。 +- `const result = { success: true, topics };` —— 拼结果:成功标志 + 话题数组(`topics` 对象简写)。 +- 收尾 `content` + `details` 套路(第⑮篇)。 + +--- + +## 整章回顾 + +- `ros2_list_topics` 是最简单的工具:无参数(`Type.Object({})`)、`execute` 里一句 `await transport.listTopics()` 取货、包结果返回。 +- 它的意义在"自省":让 AI 能在运行时**主动发现机器人的能力**,是 AI 智能使用其他工具的前提(先知道有哪些话题,才知道往哪发、订哪个)。 +- 注意工具名叫 `list_topics`(只列话题),虽然第⑩篇适配器还有 `listServices`/`listActions`,但本工具只暴露了话题这一个。(这是当前实现的选择,未来可扩展。) + +**语法点回顾清单**(本章新增/巩固): +- `Type.Object({})`:声明"无参数"工具(花括号空着;仍必须写 `parameters`) +- `execute(_toolCallId, _params)`:两个参数都不用时都加 `_` 前缀(巩固第⑫篇) +- `await transport.listTopics()` 直接复用第⑩篇的自省方法 +- `content` + `details` 返回套路(巩固) + +下一份:[`ros2-camera.ts` 逐行详解 →](21-ros2-camera.ts.md)(摄像头取一帧——结构像第⑯篇订阅,但第一次处理"图片/二进制数据") diff --git a/learn/code/21-ros2-camera.ts.md b/learn/code/21-ros2-camera.ts.md new file mode 100644 index 0000000..650c236 --- /dev/null +++ b/learn/code/21-ros2-camera.ts.md @@ -0,0 +1,173 @@ +# 逐行详解 ㉑:`tools/ros2-camera.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/tools/ros2-camera.ts](../../extensions/openclaw-plugin/src/tools/ros2-camera.ts) +> +> 推荐阅读顺序第 21 个文件,**工具层的最后一个**。它让 AI 抓取摄像头的一帧画面("机器人现在看到什么")。结构上它和第⑯篇订阅工具**几乎一样**(同一个竞速 Promise),所以骨架你已经懂了。本篇真正的新东西只有一个概念:**图像数据是怎么在文本协议里传输的**(base64 编码)。我们重点讲这个,其余快速过。 + +--- + +## 工具速览 + +- **它干什么**:从摄像头话题抓一帧图像返回。用户问"你看到什么""拍张照"时用。 +- **底层结构**:和第⑯篇 `subscribe_once` 同款——订阅一次、拿到一帧就退订、带超时竞速。区别在订阅的是**图像话题**、返回的是**图像数据**。 + +--- + +## 先理解"图像怎么通过文本传" + +这里有个新手会困惑的点:rosbridge 走的是 **JSON 文本**协议(回忆第⑤⑥篇),而图像是**二进制数据**(一堆字节)。文本里怎么塞二进制? + +答案是 **base64 编码**: + +- **base64** 是一种"把任意二进制数据编码成纯文本字符串"的标准方法。它用 64 个安全字符(A-Z、a-z、0-9、`+`、`/`)来表示二进制。 +- 一张 JPEG 图片本是二进制,经 base64 编码后变成一长串文本(像 `"/9j/4AAQSkZJRg..."`),就能塞进 JSON 传输了。接收方再 base64 解码还原成图片。 +- 所以本工具拿到的 `data` 字段,就是**图像的 base64 文本**。description 里 "base64-encoded data" 说的正是这个。 + +> 你不需要会写 base64 编解码——底层和宿主会处理。你只要理解:**图像在这套文本协议里以 base64 字符串的形态传输**。 + +--- + +## 第 1-19 行:导入 + 说明字段 + 参数 + +```typescript +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi } from "../plugin-api.js"; +import { getTransport } from "../service.js"; + +/** + * Register the ros2_camera_snapshot tool with the AI agent. + * Grabs a single frame from a camera topic. + */ +export function registerCameraTool(api: OpenClawPluginApi): void { + api.registerTool({ + name: "ros2_camera_snapshot", + label: "ROS2 Camera Snapshot", + description: + "Capture a single image from a ROS2 camera topic. Returns the image as base64-encoded data. " + + "Use this when the user asks what the robot sees or requests a photo.", + parameters: Type.Object({ + topic: Type.Optional(Type.String({ description: "The camera image topic (default: '/camera/image_raw/compressed')" })), + timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 10000)" })), + }), +``` + +- 骨架同前。`description`:「从摄像头话题抓一帧图像,**以 base64 编码数据返回**。当用户问机器人看到什么、或要照片时用它。」 +- 参数两个,都**可选**(`Type.Optional`,第⑯篇): + - `topic` —— 摄像头话题,默认 `/camera/image_raw/compressed`(`compressed` 表示压缩图像,即 JPEG 之类)。 + - `timeout` —— 超时,默认 10000 毫秒(10 秒,比第⑯篇订阅的 5 秒长——图像帧大、可能慢一点)。 + - 默认值同第⑯篇套路:描述里写"default"只是告知,真正兜底在 `execute` 里用 `??`。 + +--- + +## 第 21-23 行:`execute` 取参数(带默认值兜底) + +```typescript + async execute(_toolCallId, params) { + const topic = (params["topic"] as string | undefined) ?? "/camera/image_raw/compressed"; + const timeout = (params["timeout"] as number | undefined) ?? 10000; +``` + +- 两个参数都用第⑯篇学的**断言 + `??` 兜底**写法: + - `topic` 没填就用默认话题字符串。 + - `timeout` 没填就用 `10000`。 +- 注意 `topic` 这里和第⑯篇的不同:第⑯篇 `topic` 是必填(无默认),这里 `topic` 可选且有默认值——因为摄像头话题名通常是约定俗成的那个,AI 不填也能work。 + +--- + +## 第 25-45 行:竞速 Promise(和第⑯篇同款) + +```typescript + const transport = getTransport(); + + const result = await new Promise>((resolve, reject) => { + const subscription = transport.subscribe( + { topic, type: "sensor_msgs/msg/CompressedImage" }, + (msg: Record) => { + clearTimeout(timer); + subscription.unsubscribe(); + resolve({ + success: true, + topic, + format: msg["format"] ?? "jpeg", + data: msg["data"] ?? "", + }); + }, + ); + const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for camera frame on ${topic}`)); + }, timeout); + }); +``` + +**这段的骨架和第⑯篇订阅工具一模一样**——竞速 Promise:订阅收到一帧就 `resolve`、超时就 `reject`,两条路都 `unsubscribe`/`clearTimeout` 清理。如果第⑯篇看懂了,这里只需看两处**不同**: + +1. **订阅类型写死为图像类型**: + ```typescript + { topic, type: "sensor_msgs/msg/CompressedImage" } + ``` + - 第⑯篇的订阅工具 `type` 是 AI 传的;这里直接写死 `sensor_msgs/msg/CompressedImage`(ROS2 标准的压缩图像消息类型)。因为这工具**专门**抓图像,类型固定,不用 AI 操心。 + +2. **收到消息后从中提取图像字段**: + ```typescript + resolve({ + success: true, + topic, + format: msg["format"] ?? "jpeg", + data: msg["data"] ?? "", + }); + ``` + - 第⑯篇是把整条 `msg` 原样返回;这里**只挑出图像相关的两个字段**: + - `format: msg["format"] ?? "jpeg"` —— 图像格式。从消息里取 `format` 字段(方括号取属性,第⑩篇),万一没有就兜底成 `"jpeg"`(`??`,第⑥篇)。 + - `data: msg["data"] ?? ""` —— **图像的 base64 数据**。取消息的 `data` 字段(就是上面讲的 base64 文本),没有就兜底成空字符串。 + - `CompressedImage` 消息里就是 `format` + `data` 两个关键字段,这里精准取出,丢掉其余无关字段。 + +> 除这两处外,竞速、超时、清理逻辑和第⑯篇完全相同,不再重复讲。这再次体现工具的"套路化":换个话题、换组要提取的字段,骨架照搬。 + +--- + +## 第 47-52 行:返回结果 + +```typescript + return { + content: [{ type: "text", text: JSON.stringify(result) }], + details: result, + }; + }, + }); +} +``` + +- **和前面所有工具一样的 `content` + `details` 套路**——把 `result`(含 base64 图像数据)`JSON.stringify` 成文本返回。 +- **一个值得注意的点**:回忆第⑭篇 `ToolResult` 的 `content` 其实支持**图片类型** `{ type: "image", data, mimeType }`。本工具拿到了图像 base64,**理论上可以**返回成 `type: "image"` 让 AI 直接"看图"。但这里**仍用 `type: "text"`** 把整个结果(含 base64 串)当文本返回。 + - 这是当前实现的简化:图像数据作为文本字段塞在 JSON 里,由宿主/上层决定怎么进一步处理(比如渲染)。 + - 这是个可以改进的地方——未来若要让 AI 真正"看见"图像,可以把 `data` 单独拿出来组成 `{ type: "image", data, mimeType: "image/jpeg" }` 的 content 项。**注意到这种"现状 vs 可改进",正是读源码读出门道的表现。** + +--- + +## 整章回顾 + +- `ros2_camera_snapshot` 抓取一帧摄像头图像,**结构完全复用第⑯篇的竞速 Promise**,只改了两处:订阅类型写死为 `CompressedImage`、收到后提取 `format`/`data` 两个图像字段。 +- 关键概念:**图像以 base64 字符串在 JSON 文本协议里传输**(`data` 字段就是 base64 编码的图像)。 +- 当前用 `type: "text"` 返回(把 base64 塞在文本 JSON 里),`ToolResult` 其实支持 `type: "image"`,是个未来可优化点。 + +**语法点回顾清单**(本章新增/巩固): +- base64:把二进制(图像)编码成纯文本,以便在 JSON 文本协议里传输(概念) +- 竞速 Promise + 超时清理(完全复用第⑯篇) +- 订阅类型写死 `"sensor_msgs/msg/CompressedImage"`(专用工具不需 AI 给类型) +- 从消息提取特定字段 `msg["format"] ?? "jpeg"` / `msg["data"] ?? ""`(方括号取属性 + `??` 兜底) +- `ToolResult.content` 支持 `text`/`image` 两型(回扣第⑭篇),本工具用 text + +--- + +## 🎉 工具层全部讲完! + +到这里,**第五部分(工具层)7 个工具文件全部讲完**(14 总装 + 15 发布 + 16 订阅 + 17 服务 + 18 动作 + 19 参数读写 + 20 自省 + 21 摄像头)。回头看,你已经掌握了: + +> **一个工具 = 五件套骨架(name/label/description/parameters/execute)+ TypeBox 参数 + execute 里"取参数→调传输某方法→包成 ToolResult 返回"。** 七个工具的差别只在中间那几行,骨架完全一致。 + +而且工具层让你彻底吃透了 TypeBox、`content/details` 返回、竞速 Promise 等模式。**这是 AI 与机器人之间的全部"动作按钮"。** + +接下来**第六部分(钩子与命令)**换一个主题:不再是"AI 主动调的工具",而是**"在特定时机自动触发的钩子"**(动手前安全校验、开聊前注入能力)和**"用户直接打的命令"**(`/estop`、`/transport`)。会引入一些新概念(钩子机制、命令处理),但有了工具层的基础,理解起来会顺。 + +下一份:[`safety/validator.ts` 逐行详解 →](22-safety-validator.ts.md)(安全校验钩子:每次 AI 要调工具前,先拦一道,检查指令安不安全) diff --git a/learn/code/22-safety-validator.ts.md b/learn/code/22-safety-validator.ts.md new file mode 100644 index 0000000..03647e8 --- /dev/null +++ b/learn/code/22-safety-validator.ts.md @@ -0,0 +1,190 @@ +# 逐行详解 ㉒:`safety/validator.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/safety/validator.ts](../../extensions/openclaw-plugin/src/safety/validator.ts) +> +> 推荐阅读顺序第 22 个文件,**第六部分「钩子与命令」的开篇**。前面的工具是"AI 主动调用"的;从这篇起换一种东西——**钩子(hook)**:它**不是被 AI 调用,而是在某个时机由宿主自动触发**。本篇的钩子叫 `before_tool_call`(每次 AI 要调任何工具**之前**自动触发),用来做**安全校验**:如果 AI 想让机器人开太快,就**拦下来**。第一次见到"钩子"机制、以及一点数学运算语法。 + +--- + +## 先理解"钩子(hook)"是什么 + +- **工具**:AI 说"我要调 `ros2_publish`",宿主才执行——**被动、按需**。 +- **钩子**:你预先告诉宿主"在 XX 时机,请自动帮我跑这段代码"。到点了宿主就触发它——**主动、按时机**。 +- 本篇的 `before_tool_call`(工具调用前)就是个时机钩子:**每次 AI 即将调用任何工具,宿主先暂停,把"它要调什么、参数是什么"交给我们的钩子检查;钩子可以放行,也可以喊停(block)。** + +``` +AI 决定调用某工具 + │ + ▼ +宿主触发 before_tool_call 钩子 ──> 我们的校验代码 + │ │ + │←──── 返回 {block:true} 拦截 ──┤(不安全 → 拦下,工具不执行) + │←──── 返回 void 放行 ──────────┘(安全 → 照常执行工具) + ▼ +(放行时)工具真正执行 +``` + +这是一种"**拦截器**"模式——在动作发生前插一道关卡。安全校验放这里最合适:**在机器人真正动起来之前**就把危险指令挡掉。 + +--- + +## 第 1-9 行:导入 + 注册函数开头 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; + +/** + * Register the before_tool_call safety validation hook. + * Intercepts tool calls and validates them against safety limits. + */ +export function registerSafetyHook(api: OpenClawPluginApi, config: RosClawConfig): void { + const safety = config.safety; +``` + +- 两个 `import type`:宿主接口、配置类型。 +- `registerSafetyHook(api, config)` —— 第⑬篇 `index.ts` 调过它。它吃 `config`(不像工具只吃 `api`),因为安全限值就存在配置里。 +- `const safety = config.safety;` —— 把配置里的安全那一节(含 `maxLinearVelocity`、`maxAngularVelocity` 等,回忆第②篇 Zod schema)取出来存个短变量,下面反复用。 + +--- + +## 第 11 行:注册钩子 `api.on(...)` + +```typescript + api.on("before_tool_call", async (event, _ctx) => { +``` + +**语法小课堂:`api.on(事件名, 处理函数)` —— 登记一个钩子。** +- `api.on(...)` 是宿主提供的"订阅时机"方法(回忆第①篇 plugin-api 里见过它的类型)。 +- 第一个参数 `"before_tool_call"` —— 要挂在哪个时机(事件名)。 +- 第二个参数 `async (event, _ctx) => {...}` —— 时机到了要跑的**处理函数**: + - `event` —— 这次事件的信息。对 `before_tool_call` 来说,它是 `BeforeToolCallEvent`,含 `toolName`(要调哪个工具)和 `params`(参数)。 + - `_ctx` —— 上下文(agentId 等),本钩子用不到,加 `_` 前缀(第⑫篇)。 + - `async` —— 处理函数可以是异步的(虽然本篇没 await,但接口允许)。 +- **这个处理函数的返回值很关键**:返回 `{ block: true, blockReason: "..." }` 就**拦截**这次工具调用;什么都不返回(`void`)就**放行**。下面会看到。 + +--- + +## 第 12-13 行:只关心发布工具 + +```typescript + if (event.toolName === "ros2_publish") { + const msg = event.params["message"] as Record | undefined; +``` + +- `if (event.toolName === "ros2_publish")` —— **只检查发布工具**。因为只有发布(发指令给机器人)才可能让机器人危险地动起来;读数据、列话题之类无害,不必管。其他工具进了这个钩子会直接跳过整段、放行。 +- `const msg = event.params["message"] as ... | undefined;` —— 取出发布工具的 `message` 参数(回忆第⑮篇:发布工具的参数里有 `message`)。 + - `event.params["message"]` —— 方括号取属性(第⑩篇)。 + - `as Record | undefined` —— 断言成"对象或没有"(参数可能缺,诚实写 `| undefined`,第⑯篇)。 + +--- + +## 第 14-18 行:取出速度的两部分 + +```typescript + if (msg) { + // Check velocity limits for Twist messages + const linear = msg["linear"] as Record | undefined; + const angular = msg["angular"] as Record | undefined; +``` + +- `if (msg)` —— 真值判断(第⑦篇):有消息体才往下查。 +- 注释 `Twist messages`:速度指令的标准消息类型 `Twist` 长这样——有 `linear`(线速度,含 x/y/z)和 `angular`(角速度,含 x/y/z)两部分。 +- 取这两部分: + - `linear` —— 线速度对象,断言成 `Record | undefined`(键是字符串如 "x"/"y"/"z",值是数字;可能没有)。 + - `angular` —— 角速度对象,同理。 +- **注意这里的容错思路**:层层都断言成 `| undefined` 并用 `if` 守卫——因为消息是 AI 拼的,不保证一定有这些字段。**不能假设结构完整,每层都防一手。** + +--- + +## 第 19-29 行:校验线速度(出现数学运算) + +```typescript + if (linear) { + const speed = Math.sqrt( + (linear["x"] ?? 0) ** 2 + + (linear["y"] ?? 0) ** 2 + + (linear["z"] ?? 0) ** 2, + ); + if (speed > safety.maxLinearVelocity) { + api.logger.warn(`Blocked: linear velocity ${speed} exceeds limit ${safety.maxLinearVelocity}`); + return { block: true, blockReason: `Linear velocity ${speed.toFixed(2)} m/s exceeds safety limit of ${safety.maxLinearVelocity} m/s` }; + } + } +``` + +- `if (linear)` —— 有线速度才算。 +- **计算合速度**(把 x/y/z 三个方向合成一个总速度大小): + ```typescript + const speed = Math.sqrt( + (linear["x"] ?? 0) ** 2 + + (linear["y"] ?? 0) ** 2 + + (linear["z"] ?? 0) ** 2, + ); + ``` + - **语法小课堂:`**` 是"乘方(指数)运算符"。** `a ** 2` 就是"a 的平方"(a×a)。`** 3` 就是三次方。 + - **语法小课堂:`Math.sqrt(x)` 是"求平方根"。** (回忆第⑥篇见过 `Math.min`/`Math.pow`,`Math` 是内置数学工具箱。) + - `(linear["x"] ?? 0)` —— 取 x 分量,没有就当 0(`??` 兜底,第⑥篇)。y、z 同理。 + - 整体是数学里的**三维向量长度公式** √(x²+y²+z²)——把三个方向的速度合成一个"总速度大小"。这就是机器人实际跑多快。 +- `if (speed > safety.maxLinearVelocity)` —— 合速度超过配置的上限了吗? + - 超了就: + - `api.logger.warn(...)` —— 记一条**警告日志**(`warn` 比 `info` 级别高,回忆第①篇 logger)。 + - `return { block: true, blockReason: "..." };` —— **关键:返回拦截决定。** + - `block: true` —— 告诉宿主"拦下这次工具调用,别执行"。 + - `blockReason: "..."` —— 拦截理由(会反馈给 AI/用户,说明为啥被拦)。 + - **语法小课堂:`speed.toFixed(2)`** —— 把数字格式化成"保留 2 位小数"的字符串。比如 `1.23456` → `"1.23"`。用在给人看的消息里,免得显示一长串小数。 + - **`return` 在这里直接结束整个钩子函数**——一旦决定拦截,立刻返回,后面的角速度检查也不必做了。 + +--- + +## 第 31-37 行:校验角速度 + +```typescript + if (angular) { + const rate = Math.abs(angular["z"] ?? 0); + if (rate > safety.maxAngularVelocity) { + api.logger.warn(`Blocked: angular velocity ${rate} exceeds limit ${safety.maxAngularVelocity}`); + return { block: true, blockReason: `Angular velocity ${rate.toFixed(2)} rad/s exceeds safety limit of ${safety.maxAngularVelocity} rad/s` }; + } + } +``` + +- 和线速度对称,但简单些——角速度只看 `z`(绕竖直轴转,即左右转的快慢,地面机器人主要就这个)。 +- `const rate = Math.abs(angular["z"] ?? 0);` + - **语法小课堂:`Math.abs(x)` 是"绝对值"。** 去掉正负号。因为转向有左右(正负),但"转多快"只看大小,所以取绝对值。`Math.abs(-1.5)` = `1.5`。 +- 超限同样 `return { block: true, blockReason: ... }` 拦截。 + +--- + +## 第 41-43 行:放行(隐式)+ 收尾 + +```typescript + // TODO: Add workspace limit checks for navigation goals + }); +} +``` + +- `// TODO:` —— 标记一个**待办**:以后还要加"导航目标的活动范围限制"(回忆 CLAUDE.md:未实现处用 `// TODO` 标记)。说明安全校验目前只做了速度,是可扩展的。 +- **注意:函数走到这里没有 `return`,等于返回 `void`——也就是"放行"。** 这是关键设计: + - 不安全 → 中途 `return { block: true }` 拦截。 + - 一路没拦 → 自然走到底、返回 `void` → 放行。 + - **"默认放行、命中危险才拦"**,所以无害的工具、安全的速度都会顺畅通过。 +- `});` 关闭 `api.on` 的处理函数和调用,`}` 关闭注册函数。 + +--- + +## 整章回顾 + +- 本篇引入**钩子(hook)**:用 `api.on("before_tool_call", 处理函数)` 在"工具调用前"插一道关卡。**返回 `{block:true, blockReason}` 拦截,返回 `void` 放行。** +- 这个安全钩子只盯 `ros2_publish`,把 `Twist` 速度指令的**线速度合成大小**(`√(x²+y²+z²)`)和**角速度大小**(`|z|`)算出来,超过配置上限就拦下、并给出人话理由。 +- 设计精髓:**默认放行,命中危险才 `return` 拦截**;层层 `if`+`| undefined` 防 AI 拼的消息结构不完整。这是"机器人动起来之前"的最后一道软件保险。 + +**语法点回顾清单**(本章新增/巩固): +- 钩子机制:`api.on(事件名, async (event, ctx) => {...})`,按时机自动触发(vs 工具被动调用) +- `before_tool_call` 钩子:`event.toolName`/`event.params`,返回 `{block, blockReason}` 拦截 / `void` 放行 +- `**`(乘方)、`Math.sqrt`(平方根)、`Math.abs`(绝对值)(巩固 `Math.*`) +- `数字.toFixed(2)`:格式化为保留 2 位小数的字符串 +- 中途 `return` 提前结束钩子(命中即拦)、走到底隐式 `void`(放行) +- 层层 `as ... | undefined` + `if` 守卫,应对不可信的外部结构 + +下一份:[`context/robot-context.ts` 逐行详解 →](23-robot-context.ts.md)(另一个钩子:开聊前把"机器人有哪些能力"注入给 AI,含缓存、`Promise.all` 并行、降级兜底) diff --git a/learn/code/23-robot-context.ts.md b/learn/code/23-robot-context.ts.md new file mode 100644 index 0000000..a645835 --- /dev/null +++ b/learn/code/23-robot-context.ts.md @@ -0,0 +1,390 @@ +# 逐行详解 ㉓:`context/robot-context.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/context/robot-context.ts](../../extensions/openclaw-plugin/src/context/robot-context.ts) +> +> 推荐阅读顺序第 23 个文件,本批最有料的一篇(192 行)。它是另一个钩子——`before_agent_start`(**每次对话开始前**触发)——干一件聪明事:**先问机器人"你现在有哪些话题/服务/动作",再把这份能力清单塞进 AI 的系统提示里**,让 AI 一开口就知道自己能指挥什么。本篇会综合很多前面学过的东西,并第一次见到几个重要新语法:**`Promise.all` 并行**、**带 TTL 的缓存**、**降级兜底(fallback)**、**空 catch**。 + +--- + +## 先理解这篇要解决的问题 + +AI 默认对你的机器人**一无所知**——它不知道有 `/cmd_vel` 这个话题、不知道能调哪些服务。如果不告诉它,它只能瞎猜话题名。 + +解决办法:**在每次对话开始前,动态问一次机器人"你有哪些能力",把结果写成一段说明,塞进 AI 的"系统提示"(system prompt,AI 对话的背景知识)里。** 这样 AI 一上来就掌握了这台机器人的"使用手册"。 + +但天天问、每次问会慢。所以还要: +- **缓存**:问一次记住,60 秒内不重复问。 +- **降级**:万一问失败(没连上等),用一份"硬编码的默认清单"顶上,别让 AI 两眼一抹黑。 + +这三件事(注入、缓存、降级)就是本篇的全部。 + +--- + +## 第 1-4 行:导入 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import type { TopicInfo, ServiceInfo, ActionInfo } from "../transport/types.js"; +import { getTransport } from "../service.js"; +``` + +- 前三个 `import type`:宿主接口、配置、以及三种能力信息类型 `TopicInfo`/`ServiceInfo`/`ActionInfo`(第③篇定义、第⑩篇 `list*` 方法返回的)。 +- `import { getTransport }` —— 值导入,要调用它拿传输。 + +--- + +## 第 6-15 行:缓存的数据结构 + 全局缓存变量 + +```typescript +/** Cached discovery results with TTL. */ +interface DiscoveryCache { + topics: TopicInfo[]; + services: ServiceInfo[]; + actions: ActionInfo[]; + timestamp: number; +} + +const CACHE_TTL_MS = 60_000; // 60s +let cache: DiscoveryCache | null = null; +``` + +- `interface DiscoveryCache { ... }` —— 定义"缓存里存什么"(回忆第①篇 interface):三个能力数组 + 一个 `timestamp`(时间戳)。 + - **`timestamp` 是关键**:记下"这份缓存是什么时候拿的",用来判断它过期没。 +- `const CACHE_TTL_MS = 60_000;` —— **TTL** 常量。 + - **语法小课堂:TTL = Time To Live(存活时间)。** 缓存"保鲜期"。`60_000` 毫秒 = 60 秒(`_` 分位,第⑥篇)。意思是"缓存最多用 60 秒,超过就当过期、重新问"。 + - 用全大写命名 `CACHE_TTL_MS` 是常量的惯例(表示"这是个固定不变的配置值")。 +- `let cache: DiscoveryCache | null = null;` —— **模块级缓存变量**(回忆第⑫篇模块级变量):存上次问到的结果,初始 `null`(还没问过)。`let` 因为会反复改写。 + +--- + +## 第 17-20 行:清缓存的导出函数 + +```typescript +/** Clear the discovery cache so the next agent start re-discovers capabilities. */ +export function clearDiscoveryCache(): void { + cache = null; +} +``` + +- 一个对外的小函数:把 `cache` 设回 `null`,**强制下次重新发现**。 +- 谁会用它?比如换了机器人、或传输重连后——能力可能变了,得作废旧缓存。下面就会看到一处自动调用它的逻辑。 + +--- + +## 第 22-49 行:`registerRobotContext`——注册钩子(主函数) + +### 第 26-28 行:取配置 + +```typescript +export function registerRobotContext(api: OpenClawPluginApi, config: RosClawConfig): void { + const robotName = config.robot.name; + const robotNamespace = config.robot.namespace; +``` + +- 从配置取机器人名字和**命名空间(namespace)**。 + - 命名空间是 ROS2 里给一组话题/服务加的统一前缀(如 `/turtlebot3`)。多机器人时用它区分。下面会用它过滤"只保留属于本机器人的能力"。 + +### 第 30-42 行:传输重连时自动清缓存(含"空 catch") + +```typescript + // Reactive re-discovery: clear cache on transport reconnect + try { + const transport = getTransport(); + transport.onConnection((status: string) => { + if (status === "connected") { + cache = null; // Force re-discovery on next agent start + api.logger.info("Transport reconnected — capability cache cleared"); + } + }); + } catch { + // Transport not initialized yet — will be set up by the service. + // The onConnection handler will be registered when the hook fires. + } +``` + +- 意图(注释 `Reactive re-discovery`):**传输一旦重连成功,就清空缓存**——因为重连可能意味着换了机器人或机器人重启了,旧能力清单不可信了。 +- `try { ... } catch { ... }`: + - `try` 里:拿传输、登记一个 `onConnection` 回调(第④/⑩篇)。回调里判断 `if (status === "connected")` 就 `cache = null` 清缓存 + 记日志。 + - **语法小课堂:`catch { }`——不带括号的 catch。** 回忆第②⑥篇 `catch (e)` 是接住错误对象。这里写 `catch {`(**没有 `(e)`**)表示"我知道可能出错,但**我不关心错误内容**,接住了啥也不干"。这是较新的语法,用在"出错也无所谓"的场景。 + - **为什么这里允许出错?** 注释说清了:`registerRobotContext` 在插件加载时就被调(第⑬篇),那时**传输可能还没初始化**(service 的 `start` 还没跑),`getTransport()` 会抛错(第⑫篇:没初始化就抛)。这没关系——抓住、忽略即可。等钩子真正触发时(对话开始时)传输早就好了。 + - 所以这段是"**尽力而为**":能登记重连监听就登记,登记不上也不影响主流程。 + +### 第 44-48 行:注册 `before_agent_start` 钩子 + +```typescript + api.on("before_agent_start", async (_event, _ctx) => { + const capabilities = await discoverCapabilities(api, robotNamespace); + const context = buildRobotContext(robotName, robotNamespace, capabilities); + return { prependContext: context }; + }); +} +``` + +- `api.on("before_agent_start", ...)` —— 挂在"对话开始前"这个时机(回忆第22篇 `api.on` 用法;这是另一个事件名)。`_event`/`_ctx` 都不用,加 `_`。 +- 钩子体三行,逻辑极清晰: + 1. `const capabilities = await discoverCapabilities(api, robotNamespace);` —— **发现能力**(调下面的辅助函数,带缓存)。 + 2. `const context = buildRobotContext(...);` —— **把能力拼成一段说明文字**(调下面另一个辅助函数)。 + 3. `return { prependContext: context };` —— **返回注入指令**。 + - **语法小课堂:`before_agent_start` 钩子返回 `{ prependContext: 文本 }` 的含义。** `prependContext`(前置上下文)告诉宿主"把这段文本**塞到 AI 系统提示的前面**"。于是 AI 这次对话就带着这份机器人能力清单开场。 + - 这和第22篇钩子返回 `{block}` 是同一套机制(钩子用返回值影响宿主行为),只是这个钩子的返回字段是 `prependContext`(注入内容),那个是 `block`(拦截)。 +- 主函数到此结束。下面是它用到的三个辅助函数。 + +--- + +## 第 51-98 行:`discoverCapabilities`——发现能力(缓存 + 并行 + 降级) + +这是本篇最精华的函数,三个新概念都在这。 + +### 第 55-62 行:签名 + 命中缓存就直接返回 + +```typescript +async function discoverCapabilities( + api: OpenClawPluginApi, + namespace: string, +): Promise { + // Return cached results if still fresh + if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) { + return cache; + } +``` + +- 注意这个函数**没有 `export`**——它是模块内部的辅助函数,只给本文件用(回忆:不导出 = 私有)。 +- `if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS)` —— **缓存命中判断**,拆开看: + - `cache &&` —— 先得有缓存(不是 `null`)。`&&` 短路(第⑥篇):没缓存就不算后面。 + - `Date.now() - cache.timestamp < CACHE_TTL_MS` —— **检查是否还新鲜**。 + - **语法小课堂:`Date.now()` 返回"现在的时间戳"**(从 1970 年至今的毫秒数,一个大整数)。 + - `Date.now() - cache.timestamp` —— 现在减去"缓存拿到的时刻" = **缓存已经存在了多少毫秒**。 + - `< CACHE_TTL_MS` —— 这个年龄小于 60 秒吗?小于就是"还新鲜"。 + - 两者都满足 → `return cache;` **直接返回旧缓存,跳过昂贵的重新发现**。这就是缓存省时间的核心:60 秒内的重复请求秒回。 + +### 第 64-71 行:缓存失效——并行发现三类能力(`Promise.all`) + +```typescript + try { + const transport = getTransport(); + + const [topics, services, actions] = await Promise.all([ + transport.listTopics(), + transport.listServices(), + transport.listActions(), + ]); +``` + +- 缓存没命中(没有或过期),才真去问机器人。包在 `try` 里(下面有 catch 降级)。 +- `const transport = getTransport();` —— 拿传输(这时传输肯定就绪了,因为钩子是对话时才触发的)。 +- **本篇重磅新语法:`Promise.all`。** + ```typescript + const [topics, services, actions] = await Promise.all([ + transport.listTopics(), + transport.listServices(), + transport.listActions(), + ]); + ``` + - **语法小课堂:`Promise.all([承诺1, 承诺2, 承诺3])` —— "并行等待多个异步操作"。** + - 我们要问三件事:列话题、列服务、列动作。每个都是异步的(返回 Promise)。 + - **如果一个个 `await`**(`await listTopics(); await listServices(); ...`),就是**串行**——等第一个完了才发第二个,三个加起来要等三段时间。 + - `Promise.all([...])` 把三个 Promise **同时发起**,然后等**它们全部完成**。总耗时≈最慢的那一个,而不是三个之和。这就是**并行**,快得多。 + - **语法小课堂:用数组解构接住结果 `const [topics, services, actions] = ...`。** `Promise.all` 返回的结果是个数组,**顺序和传入的一一对应**:第 0 个是 `listTopics` 的结果、第 1 个是 `listServices` 的、第 2 个是 `listActions` 的。用数组解构(回忆第⑥篇 `[x, y]`)一次性按位置拆出来,分别命名 `topics`/`services`/`actions`。 + - 一句话:**三件独立的事,与其排队做,不如一起做、一起等。** 这是 `Promise.all` 的典型用武之地。 + +### 第 73-82 行:按命名空间过滤 + 写入缓存 + +```typescript + // Filter by namespace if configured + const filterByNs = (name: string) => + !namespace || name.startsWith(namespace); + + cache = { + topics: topics.filter((t: TopicInfo) => filterByNs(t.name)), + services: services.filter((s: ServiceInfo) => filterByNs(s.name)), + actions: actions.filter((a: ActionInfo) => filterByNs(a.name)), + timestamp: Date.now(), + }; +``` + +- `const filterByNs = (name: string) => !namespace || name.startsWith(namespace);` —— 定义一个**判断函数**(存进变量,回忆第①篇"函数是一等公民"): + - 读作"这个名字该保留吗":`!namespace || name.startsWith(namespace)`。 + - `!namespace` —— 没配命名空间(空字符串等假值)?那就**全保留**(`!` 取反 + 短路 `||`:没命名空间时直接 true)。 + - `name.startsWith(namespace)` —— 配了命名空间,就只保留**以它开头**的名字(`.startsWith`,第⑥篇)。比如命名空间 `/turtlebot3`,就只留 `/turtlebot3/...` 的话题,过滤掉别的机器人的。 +- **语法小课堂:`数组.filter(判断函数)` —— "筛选数组"。** + - 回忆第⑩篇的 `.map`(逐个变形)。`.filter` 是"逐个判断、只留下判断函数返回 `true` 的元素",组成新数组。 + - `topics.filter((t: TopicInfo) => filterByNs(t.name))` —— 遍历每个话题 `t`,用 `filterByNs` 判断它的 `name` 该不该留,留下合格的。services、actions 同理。 +- 把过滤后的三个数组 + 当前时间戳 `Date.now()` 组成新缓存,赋给 `cache`。**这就是"问到了就记下来"**——下次 60 秒内再来就命中上面那个缓存判断。 + +### 第 84-88 行:记日志 + 返回 + +```typescript + api.logger.info( + `Discovered ${cache.topics.length} topics, ${cache.services.length} services, ${cache.actions.length} actions`, + ); + + return cache; +``` + +- 记一行"发现了 N 个话题、M 个服务、K 个动作"(`.length` 取数组长度,模板字符串插值)。 +- `return cache;` —— 返回这份新鲜缓存。 + +### 第 89-98 行:降级兜底(catch) + +```typescript + } catch (err) { + api.logger.warn(`Capability discovery failed, using defaults: ${err}`); + return { + topics: [], + services: [], + actions: [], + timestamp: 0, + }; + } +} +``` + +- 如果上面任何一步出错(没连上、服务报错……),进 `catch`: + - `catch (err)` —— 这次接住错误对象 `err`(和第22篇 `catch {` 不同,这里要用 `err` 写进日志)。 + - 记一条警告,把错误 `${err}` 拼进去(模板字符串里放对象会自动转成文字)。 + - **返回一份"空能力"**:三个空数组 `[]` + `timestamp: 0`。 + - **语法小课堂:`timestamp: 0` 是个小心机。** 0 是最古老的时间戳(1970 年),所以"现在 - 0"远大于 60 秒——这份兜底结果**天生就是"过期的"**,不会被当成有效缓存留用。下次还会重新尝试发现,而不是把这个空结果缓存住。 + - **注意:这里 `return` 的是空能力,但 `cache` 没被赋值**——失败结果不污染缓存。设计很细。 +- **降级思想**:发现失败不崩、不卡,而是返回空,让上层用"硬编码默认清单"顶上(下面 `buildRobotContext` 会处理)。**宁可给 AI 一份不全的清单,也不让它彻底失明。** + +--- + +## 第 100-117 行:`buildRobotContext`——选用动态还是兜底 + +```typescript +function buildRobotContext( + name: string, + namespace: string, + capabilities: DiscoveryCache, +): string { + const { topics, services, actions } = capabilities; + + // If discovery returned results, use them + if (topics.length > 0 || services.length > 0 || actions.length > 0) { + return buildDynamicContext(name, topics, services, actions); + } + + // Fall back to hardcoded defaults if discovery failed + return buildFallbackContext(name, namespace); +} +``` + +- `const { topics, services, actions } = capabilities;` —— **对象解构**(第⑪篇):从能力对象里一次拆出三个数组。 +- `if (三个 length 任一 > 0)` —— **只要发现到了任何能力**,就用 `buildDynamicContext` 拼**真实清单**。 +- 否则(三个都空,说明发现失败了,回忆上面降级返回的空能力)—— 用 `buildFallbackContext` 拼**硬编码的默认清单**。 +- 这就是降级的"分流口":**有真数据用真的,没有就用兜底的。** + +--- + +## 第 119-164 行:`buildDynamicContext`——把真实能力拼成 Markdown 文本 + +```typescript +function buildDynamicContext( + name: string, + topics: TopicInfo[], + services: ServiceInfo[], + actions: ActionInfo[], +): string { + let context = `## Robot: ${name}\n\n`; + context += `You are connected to a ROS2 robot named "${name}". You can control it using the ros2_* tools.\n\n`; +``` + +- 这个函数把能力清单拼成一段 **Markdown 格式的说明文字**(要喂给 AI 读)。 +- `let context = \`## Robot: ${name}\n\n\`;` —— 用 `let` 起一个字符串变量(要反复追加,所以 `let`)。 + - **语法小课堂:`\n` 是"换行符"。** 字符串里的 `\n` 代表按一次回车。`\n\n` 就是空一行(Markdown 里用空行分段)。`## Robot:` 是 Markdown 的二级标题写法。 +- `context += \`...\`;` —— **语法小课堂:`+=` 是"追加赋值"。** `context += x` 等于 `context = context + x`,即"把 x 接到 context 后面"。这里不断 `+=` 往说明里加内容,像往纸上一行行写字。 + +### 第 128-150 行:三段循环(话题/服务/动作各一段) + +```typescript + if (topics.length > 0) { + context += "### Available Topics\n"; + for (const t of topics) { + context += `- \`${t.name}\` (${t.type})\n`; + } + context += "\n"; + } + // services、actions 两段结构完全相同,略 +``` + +- 三段长得一样,看话题这段就懂: + - `if (topics.length > 0)` —— 有话题才写这段(没有就跳过整段,不留空标题)。 + - `context += "### Available Topics\n";` —— 加个小标题。 + - `for (const t of topics) { context += ... }` —— **`for...of` 遍历**(第⑥篇),每个话题追加一行:`` - `话题名` (类型) ``。 + - `` `- \`${t.name}\` (${t.type})\n` `` —— 注意里头的 `` \` `` 是**转义的反引号**:因为整个模板字符串用反引号包着,里面想输出字面的反引号(Markdown 代码块标记)就得写 `\``。输出效果是 `` - `/cmd_vel` (geometry_msgs/msg/Twist) ``。 + - 末尾 `context += "\n";` 再空一行分段。 +- 服务、动作两段一模一样,只换标题和变量。**重复三段而不抽函数**,是因为各段标题不同、且就三段,直接写更直白。 + +### 第 152-163 行:追加固定的安全限值 + 提示 + +```typescript + context += `### Safety Limits +- Maximum linear velocity: 1.0 m/s +- Maximum angular velocity: 1.5 rad/s +- All velocity commands are validated before execution + +### Tips +- Use \`ros2_list_topics\` to discover all available topics +... +- The user can say /estop at any time to immediately stop the robot`; + + return context; +} +``` + +- 最后追加两段**固定文本**:安全限值说明 + 使用提示(告诉 AI 有哪些好用的工具、用户能 `/estop` 急停)。 + - 注意这里用了**多行模板字符串**——反引号里直接换行,所见即所得(不用写 `\n`,直接敲回车)。这是模板字符串的便利。 + - 这段是写给 AI 的"行为引导":让它知道速度有上限、知道可以用 `ros2_list_topics` 等工具、知道急停的存在。 +- `return context;` —— 返回拼好的完整说明。 + +--- + +## 第 166-192 行:`buildFallbackContext`——发现失败时的硬编码清单 + +```typescript +function buildFallbackContext(name: string, namespace: string): string { + const prefix = namespace ? `${namespace}/` : "/"; + + return ` +## Robot: ${name} +... +### Available Topics +- \`${prefix}cmd_vel\` (geometry_msgs/msg/Twist) — Velocity commands +- \`${prefix}odom\` (nav_msgs/msg/Odometry) — Odometry data +... +`.trim(); +} +``` + +- 当发现失败(没连上等),用这份**写死的常见话题清单**顶上,免得 AI 完全没信息。 +- `const prefix = namespace ? \`${namespace}/\` : "/";` —— **三元运算符**(第⑥篇):有命名空间就用 `命名空间/` 当前缀,没有就用 `/`。这样兜底清单里的话题名也能带上正确前缀(如 `/turtlebot3/cmd_vel` 或 `/cmd_vel`)。 +- 中间是一大段写死的话题(cmd_vel、odom、scan、camera、battery_state——典型移动机器人都有的),格式和动态版一致。 +- 末尾 `.trim()` —— **语法小课堂:`字符串.trim()` 去掉首尾空白**(包括开头那个因为反引号换行产生的空行)。因为模板字符串从 `` ` `` 后直接换行会在开头多一个换行,`.trim()` 把它修掉,输出干净。 +- **降级的价值**:哪怕完全没连上机器人,AI 也能拿到一份"典型机器人长这样"的清单先用着,而不是一无所知。这比"失败就什么都不给"友好得多。 + +--- + +## 整章回顾 + +`robot-context.ts` 用 `before_agent_start` 钩子,在每次对话开始前把机器人能力清单注入 AI 的系统提示。三个核心机制: + +| 机制 | 怎么做 | 好处 | +|---|---|---| +| **注入** | 钩子返回 `{ prependContext: 文本 }` | AI 开场就懂这台机器人 | +| **缓存(TTL)** | `Date.now() - timestamp < 60s` 就复用旧结果 | 60 秒内不重复问,省时间;重连时清缓存 | +| **降级(fallback)** | 发现失败返回空能力 → 用硬编码默认清单 | 没连上也不让 AI 失明 | + +并用 `Promise.all` 把"列话题/服务/动作"三件事**并行**做,进一步省时间。 + +**语法点回顾清单**(本章新增/巩固): +- **`Promise.all([...])`:并行等待多个异步操作**,结果数组按序解构 `const [a,b,c] = ...`(重点) +- 带 TTL 的缓存:`Date.now()` 取时间戳、`现在 - timestamp < TTL` 判新鲜、模块级 `cache` 变量 +- `数组.filter(判断函数)`:筛选出判断为 true 的元素(对照第⑩篇 `.map`) +- 降级兜底(fallback):失败返回空/默认,分流口 `if (有数据) 用真的 else 用兜底` +- `catch {}`(不接错误对象,出错也无所谓)vs `catch (err)`(要用错误) +- 字符串拼装:`let s = ...; s += ...`、`\n` 换行、多行模板字符串、`\\\`` 转义反引号、`.trim()` +- `timestamp: 0` 让兜底结果天生"过期"、失败不写 `cache` 不污染缓存(设计细节) +- 三元 `namespace ? ... : "/"`(巩固)、`!namespace || name.startsWith(...)` 短路(巩固) + +下一份:[`commands/estop.ts` 逐行详解 →](24-commands-estop.ts.md)(从"钩子"转到"命令":用户直接打 `/estop` 立刻急停,绕过 AI) diff --git a/learn/code/24-commands-estop.ts.md b/learn/code/24-commands-estop.ts.md new file mode 100644 index 0000000..4c3b3d4 --- /dev/null +++ b/learn/code/24-commands-estop.ts.md @@ -0,0 +1,164 @@ +# 逐行详解 ㉔:`commands/estop.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/commands/estop.ts](../../extensions/openclaw-plugin/src/commands/estop.ts) +> +> 推荐阅读顺序第 24 个文件。这是**第三种交互方式**登场——**命令(command)**。前面有"AI 主动调的工具"、"按时机自动触发的钩子",现在是"**用户直接打的命令**":在聊天里输入 `/estop`,**绕过 AI**,立刻执行。`/estop` 是急停——一键让机器人停下。它只有 40 行、几乎没有新语法,正好用来认识"命令"这个机制。 + +--- + +## 先理解"命令"和它为什么要绕过 AI + +回顾三种交互: + +| 方式 | 谁触发 | 例子 | +|---|---|---| +| 工具(14–21 篇) | **AI** 主动调用 | AI 听懂"前进"后调 `ros2_publish` | +| 钩子(22–23 篇) | **宿主**按时机自动触发 | 每次调工具前自动安全校验 | +| **命令(本篇)** | **用户**直接输入 `/xxx` | 用户打 `/estop` | + +**为什么急停要做成命令、而不是让 AI 处理?** 因为安全!设想机器人正失控,你打字"快停下",AI 要先理解、再决定调哪个工具、再执行——**这中间的延迟在紧急情况下不可接受**,而且 AI 万一理解错了更糟。命令是**直达**的:`/estop` 一进来,**不经过 AI**,代码立刻发停止指令。**确定、即时、可靠**——这正是急停需要的。 + +--- + +## 第 1-11 行:导入 + 注册函数开头 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import { getTransport } from "../service.js"; + +/** + * Register the /estop command. + * This command bypasses the AI agent and immediately sends a zero-velocity + * command to stop the robot. + */ +export function registerEstopCommand(api: OpenClawPluginApi, config: RosClawConfig): void { + const namespace = config.robot.namespace; +``` + +- 导入:宿主接口、配置(都 `import type`)、`getTransport`(值导入,要调用)。 +- 注释直接点明:「此命令**绕过 AI**,立即发一个零速度指令停住机器人。」 +- `registerEstopCommand(api, config)` —— 第⑬篇 `index.ts` 调过它。 +- `const namespace = config.robot.namespace;` —— 取命名空间(第23篇见过,给话题名加前缀用)。 + +--- + +## 第 13-16 行:注册命令 `api.registerCommand` + +```typescript + api.registerCommand({ + name: "estop", + description: "Emergency stop — immediately halt the robot (bypasses AI)", +``` + +**语法小课堂:`api.registerCommand({...})` —— 登记一个命令。** +- 和 `registerTool`(工具)、`api.on`(钩子)并列,这是注册"命令"的口子。传一个 `PluginCommand` 对象(回忆第①篇定义): + - `name: "estop"` —— 命令名。**用户打 `/estop`** 就触发它(前面那个 `/` 是命令前缀,由宿主约定)。 + - `description` —— 命令的说明(显示在帮助列表里,给人看)。 + - 还有个 `handler`(处理函数),下面就是。 +- 对比工具的 `description`(写给 AI):命令的 `description` 是**写给人**的——因为命令是人直接打的,AI 不参与。 + +--- + +## 第 17-19 行:handler 开头 + 取传输 + +```typescript + async handler(_ctx) { + try { + const transport = getTransport(); + const topic = namespace ? `${namespace}/cmd_vel` : "/cmd_vel"; +``` + +- `async handler(_ctx)` —— **命令的处理函数**,用户打 `/estop` 时宿主调它。 + - `_ctx` —— 命令上下文(谁发的、哪个频道等,回忆第①篇 `CommandContext`)。急停不关心这些,加 `_` 前缀(第⑫篇)。 +- `try {` —— 整个操作包在 `try/catch` 里。**为什么急停尤其要 try/catch?** 因为急停**最不能假定一切正常**——可能正好没连上、传输坏了。必须把失败也处理好、给用户明确反馈,而不是默默崩掉(下面 catch 会讲)。 +- `const transport = getTransport();` —— 拿传输(没连上会抛错,进 catch)。 +- `const topic = namespace ? \`${namespace}/cmd_vel\` : "/cmd_vel";` —— 三元运算(第⑥篇)拼出速度话题名:有命名空间就 `/cmd_vel`,没有就 `/cmd_vel`。和第23篇 `prefix` 同款思路。 + +--- + +## 第 22-30 行:发送"零速度"指令(急停的核心) + +```typescript + // Send zero velocity + transport.publish({ + topic, + type: "geometry_msgs/msg/Twist", + msg: { + linear: { x: 0, y: 0, z: 0 }, + angular: { x: 0, y: 0, z: 0 }, + }, + }); +``` + +- **急停的本质就是:发一条"所有速度都是 0"的指令。** 机器人收到"速度归零"就停下。 +- `transport.publish({...})` —— 调发布(第④/⑩篇),和第⑮篇发布工具用的是同一个方法,只是这里参数写死: + - `topic` —— 刚拼的速度话题(对象简写)。 + - `type: "geometry_msgs/msg/Twist"` —— 速度指令的标准类型(第22篇见过 Twist)。 + - `msg: { linear: {x:0,y:0,z:0}, angular: {x:0,y:0,z:0} }` —— **全零的速度**: + - `linear`(前后左右上下)三个方向都 0、`angular`(转向)三个轴都 0。 + - 这是个**嵌套对象**(对象里套对象),逐层看就清楚:`msg` 里有 `linear` 和 `angular`,各自又是 `{x,y,z}`。 + - 对照第22篇安全钩子:那里算速度大小、超限就拦;这里直接把速度全设 0——**两者都围绕 Twist,一个管"别太快"、一个管"立刻停"**。 +- 注意:发布是"发完即忘"(第⑦篇),**不等回应**。急停要的就是这种"立刻发出、不墨迹"。 + +--- + +## 第 32-33 行:成功反馈 + +```typescript + api.logger.warn("ESTOP: Zero velocity command sent"); + return { text: "Emergency stop activated. Robot halted." }; +``` + +- `api.logger.warn(...)` —— 记一条**警告级**日志(急停是大事,用 `warn` 而非 `info`,方便日志里醒目)。 +- `return { text: "..." };` —— **命令处理函数的返回值是 `{ text: 字符串 }`**(回忆第①篇 `CommandResult`)。 + - **语法小课堂:命令返回 `{ text }` 的含义。** 这段文字会**作为回复发回给用户**(在聊天里显示)。所以这里返回"紧急停止已激活,机器人已停止"——让用户看到确认。 + - 对比:工具返回 `{ content, details }`(给 AI 读)、钩子返回 `{ block }`/`{ prependContext }`(影响宿主)。**命令返回 `{ text }`(直接回给用户看)**——因为命令是人机直接对话,回的就是人话。 + +--- + +## 第 34-39 行:失败反馈(catch) + +```typescript + } catch (error) { + api.logger.error(`ESTOP FAILED: ${String(error)}`); + return { text: "Emergency stop failed — transport may be disconnected!" }; + } + }, + }); +} +``` + +- 如果上面任何一步失败(最可能是没连上): + - `api.logger.error(...)` —— 记一条**错误级**日志(`error` 比 `warn` 还高一级,回忆第①篇 logger 的级别)。急停失败是最严重的情况。 + - **语法小课堂:`String(error)`** —— 把错误对象**显式转成字符串**。回忆第23篇模板字符串里 `${err}` 会自动转字;这里用 `String(error)` 显式转,效果类似、更明确。 + - `return { text: "..." };` —— 给用户一句**明确的失败告警**:"急停失败——传输可能断开了!" +- **这个 catch 的设计很重要**:急停失败**绝不能静默**。必须大声告诉用户"没停成、可能断连了",让用户赶紧用别的办法(物理急停按钮等)。**安全相关的失败,宁可吵闹也不能沉默。** +- 收尾 `},` 关 handler、`});` 关 registerCommand、`}` 关注册函数。 + +--- + +## 整章回顾 + +- 本篇引入第三种交互——**命令**:用 `api.registerCommand({ name, description, handler })` 登记,用户打 `/estop` 直接触发 handler,**绕过 AI**。 +- `/estop` 的实现极简:**发一条全零速度的 Twist 指令**让机器人立刻停。 +- 命令返回 `{ text }`,直接作为回复显示给用户(对比工具的 `content`、钩子的 `block`/`prependContext`)。 +- 全程 `try/catch` + 分级日志(成功 `warn`、失败 `error`):**急停这种安全操作,成功要确认、失败要响亮告警,绝不静默。** + +**三种交互方式对照(到此集齐):** + +| | 触发者 | 注册口 | 处理函数返回 | 返回给谁 | +|---|---|---|---|---| +| 工具 | AI | `registerTool` | `{ content, details }` | AI 读 | +| 钩子 | 宿主(按时机) | `api.on` | `{ block }` / `{ prependContext }` | 影响宿主 | +| 命令 | 用户(打 `/xxx`) | `registerCommand` | `{ text }` | 用户看 | + +**语法点回顾清单**(本章新增/巩固): +- `api.registerCommand({ name, description, handler })`:登记命令;`description` 写给人(vs 工具写给 AI) +- 命令 `handler(ctx)` 返回 `{ text }`:作为回复直接显示给用户 +- 急停 = 发全零速度 Twist;嵌套对象 `{ linear:{x,y,z}, angular:{x,y,z} }` +- `String(error)` 显式转字符串(vs 模板串自动转) +- 日志分级 `info`/`warn`/`error`(安全操作用高级别)(巩固第①篇) +- 安全操作的 `try/catch`:成功确认、失败响亮告警、绝不静默 + +下一份:[`commands/transport.ts` 逐行详解 →](25-commands-transport.ts.md)(`/transport` 切换传输——带参数解析、`as const`、类型守卫、配置覆盖,是命令里最丰富的一个) diff --git a/learn/code/25-commands-transport.ts.md b/learn/code/25-commands-transport.ts.md new file mode 100644 index 0000000..be1b53c --- /dev/null +++ b/learn/code/25-commands-transport.ts.md @@ -0,0 +1,383 @@ +# 逐行详解 ㉕:`commands/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/commands/transport.ts](../../extensions/openclaw-plugin/src/commands/transport.ts) +> +> 推荐阅读顺序第 25 个文件,**第六部分的收尾、也是命令里最丰富的一个**(171 行)。`/transport` 让用户在聊天里查看或切换传输模式(如 `/transport webrtc`、`/transport rosbridge ws://...`)。它把第⑫篇的 `switchTransport` 接到用户手里,并第一次综合演示**命令行参数解析**、**`as const` 与类型守卫**、**配置覆盖(override)+ 类型转换**等几个进阶技巧。本篇略长,但都是前面学过的东西的组合,我们分块吃。 + +--- + +## 先把这个命令的用法搞清楚 + +`/transport` 有几种用法: +- `/transport` —— 不带参数:**显示当前**传输模式和状态。 +- `/transport webrtc` —— 切换到 webrtc 模式(用配置里的默认连接参数)。 +- `/transport rosbridge ws://192.168.1.5:9090` —— 切到 rosbridge,并**覆盖**连接地址。 +- `/transport webrtc robotId=robot42` —— 切到 webrtc,并用 `key=value` 形式覆盖某个配置项。 + +所以这个命令要做三件事:**①解析用户输入的参数 ②校验模式合法 ③按参数拼出配置、调 `switchTransport` 切换**。文件里那一堆辅助函数就是分别干这些的。 + +--- + +## 第 1-5 行:导入 + +```typescript +import type { OpenClawPluginApi } from "../plugin-api.js"; +import type { RosClawConfig } from "../config.js"; +import type { TransportConfig } from "../transport/types.js"; +import { getTransport, getTransportMode, switchTransport } from "../service.js"; +import { clearDiscoveryCache } from "../context/robot-context.js"; +``` + +- 三个 `import type`:宿主接口、整份配置、传输配置(第③篇判别联合)。 +- 值导入: + - `getTransport`、`getTransportMode`、`switchTransport` —— 第⑫篇 service 的三个函数(查传输、查模式、切换)。 + - `clearDiscoveryCache` —— 第23篇那个清能力缓存的函数。**为什么这里要它?** 因为切换传输后,机器人能力清单可能变了,得作废旧缓存,让下次对话重新发现。下面会用到。 + +--- + +## 第 7-12 行:合法模式列表 + 类型守卫(本篇第一个新点) + +```typescript +const VALID_MODES = ["rosbridge", "local", "webrtc"] as const; +type Mode = (typeof VALID_MODES)[number]; + +function isValidMode(value: string): value is Mode { + return (VALID_MODES as readonly string[]).includes(value); +} +``` + +这三行信息量很大,逐个拆。 + +### `as const` + +```typescript +const VALID_MODES = ["rosbridge", "local", "webrtc"] as const; +``` +- **语法小课堂:`as const` —— "把值锁成最具体的字面量类型"。** + - 普通写 `const VALID_MODES = ["rosbridge", "local", "webrtc"];`,TS 推断它的类型是 `string[]`(一个普通字符串数组)——只知道"里面是字符串",不记得具体是哪三个。 + - 加 `as const` 后,TS 把它**锁死**成"只读的、由这三个确切字面量组成的元组":`readonly ["rosbridge", "local", "webrtc"]`。即 TS **记住了这个数组就是这仨值、且不可改**。 + - 好处:下面能从它**反推出精确的类型**(而不是泛泛的 string)。 + +### 从数组反推类型 `(typeof X)[number]` + +```typescript +type Mode = (typeof VALID_MODES)[number]; +``` +- **语法小课堂:`(typeof 数组)[number]` —— 从数组值里抠出"元素类型"。** 拆成两步: + - `typeof VALID_MODES` —— 取这个值的类型(回忆第②篇 `typeof 值`:值→类型)。因为有 `as const`,得到的是 `readonly ["rosbridge","local","webrtc"]`。 + - `[number]` —— **用"数字索引"去取它的元素类型**(回忆第⑫篇索引访问类型 `T["mode"]`,这是它的数组版:用 `[number]` 表示"任意下标处的元素")。 + - 结果:`Mode` = `"rosbridge" | "local" | "webrtc"`(三个字面量的联合)。 +- **这套组合拳的价值**:模式列表只写一次(`VALID_MODES`),类型 `Mode` 自动跟着它来。以后改列表,类型自动同步——又是"别重复自己"(回忆第⑫篇同样的精神)。 + +### 类型守卫 `value is Mode` + +```typescript +function isValidMode(value: string): value is Mode { + return (VALID_MODES as readonly string[]).includes(value); +} +``` +- **语法小课堂:返回类型 `value is Mode` 是"类型守卫(type guard)"。** + - 普通函数返回 `boolean`。这里返回 `value is Mode`——它仍是返回 true/false,但**额外告诉 TS:当这函数返回 true 时,传进去的 `value` 就是 `Mode` 类型**。 + - 作用:调用处 `if (isValidMode(x))` 成立的分支里,TS 会把 `x` 从 `string` **收窄成 `Mode`**。这样后面就能安全地把 `x` 当作三选一的模式用。 + - 这是"用一个运行时检查,同时给 TS 喂类型信息"的标准手法。 +- 函数体 `(VALID_MODES as readonly string[]).includes(value)`: + - **语法小课堂:`数组.includes(x)` —— 判断数组里有没有 x**,返回 true/false。这里看 `value` 是不是那三个合法模式之一。 + - `VALID_MODES as readonly string[]` —— 一个小技术细节:`VALID_MODES` 因为 `as const` 是"只含那仨字面量的元组",直接 `.includes(任意字符串)` 会被 TS 嫌弃("这字符串不可能是那仨之一")。先 `as readonly string[]` 把它当普通只读字符串数组看,`.includes` 才能接受任意字符串来查。 + +> 这三行合起来:**定义一份"唯一可信的模式列表",从它派生出类型 `Mode`,再用类型守卫把"运行时检查"和"类型收窄"绑在一起。** 是 TS 里处理"一组合法字符串值"的经典范式。 + +--- + +## 第 14-60 行:注册命令 + handler 主流程 + +### 第 17-23 行:注册 + 取参数 + +```typescript +export function registerTransportCommand(api: OpenClawPluginApi, config: RosClawConfig): void { + api.registerCommand({ + name: "transport", + description: "Show or switch the ROS2 transport mode (rosbridge, webrtc, local)", + + async handler(ctx) { + const args = (ctx.args ?? "").trim(); +``` + +- `registerCommand` 同第24篇。命令名 `transport`、说明写给人。 +- `async handler(ctx)` —— **这次用到了 `ctx`**(不像 estop 加 `_`),因为要读用户输入的参数。 +- `const args = (ctx.args ?? "").trim();` —— 取用户在 `/transport` 后面打的参数串: + - `ctx.args` —— 命令的参数部分(回忆第①篇 `CommandContext` 有 `args?`)。可能没有(`undefined`)。 + - `?? ""` —— 没有就当空字符串(第⑥篇空值合并)。 + - `.trim()` —— 去掉首尾空格(第23篇见过)。比如用户打 `/transport webrtc ` → `args` 变成干净的 `"webrtc"`。 + +### 第 25-28 行:无参数 → 显示状态 + +```typescript + // No args → show current status + if (!args) { + return showStatus(); + } +``` + +- `if (!args)` —— 真值判断:参数是空字符串(用户只打了 `/transport`)就 `!args` 为 true。 +- `return showStatus();` —— 调辅助函数显示当前状态(下面讲),把它的返回值直接返回。 + +### 第 30-38 行:解析模式 + 校验 + +```typescript + // Parse mode from first arg + const parts = args.split(/\s+/); + const modeArg = parts[0]; + + if (!isValidMode(modeArg)) { + return { + text: `Unknown transport mode: "${modeArg}". Valid modes: ${VALID_MODES.join(", ")}`, + }; + } +``` + +- `const parts = args.split(/\s+/);` —— **把参数串按空白切成数组**。 + - **语法小课堂:`字符串.split(分隔符)` —— 按分隔符拆成数组。** 比如 `"webrtc robotId=x".split(...)` → `["webrtc", "robotId=x"]`。 + - **语法小课堂:`/\s+/` 是"正则表达式"。** 两个 `/` 之间是一个**正则**(匹配文本的模式)。`\s` 表示"空白字符"(空格、Tab 等),`+` 表示"一个或多个"。所以 `/\s+/` 匹配"一段连续空白"——用它当分隔符,多个空格也能正确切开。(正则是个大主题,这里只需知道 `/\s+/` = 按空白分割。) +- `const modeArg = parts[0];` —— 第一段就是模式名(数组下标 0,第⑥篇)。 +- `if (!isValidMode(modeArg))` —— **用上面那个类型守卫校验**。不合法就: + - 返回 `{ text: ... }` 报错,列出合法模式。 + - `VALID_MODES.join(", ")` —— **语法小课堂:`数组.join(分隔符)` —— 把数组拼成字符串**(`.split` 的逆操作)。`["rosbridge","local","webrtc"].join(", ")` → `"rosbridge, local, webrtc"`。用来友好地列出选项。 +- **关键**:通过这个 `if` 之后,因为 `isValidMode` 是类型守卫,**TS 已经把 `modeArg` 收窄成 `Mode` 类型**了——后面就能放心把它当三选一用。 + +### 第 40-46 行:拼配置(可能抛错) + +```typescript + // Build TransportConfig from base config + overrides + let transportConfig: TransportConfig; + try { + transportConfig = buildTransportConfig(modeArg, parts.slice(1), config); + } catch (err) { + return { text: `Invalid arguments: ${String(err)}` }; + } +``` + +- `let transportConfig: TransportConfig;` —— 先声明后赋值(第⑫篇)。 +- `buildTransportConfig(modeArg, parts.slice(1), config)` —— 调辅助函数拼出完整传输配置: + - `modeArg` —— 模式(已是 `Mode` 类型)。 + - `parts.slice(1)` —— **`.slice(1)` 取数组从下标 1 到末尾**(回忆第⑩篇 `.slice`,这是数组版)。即"模式名之后的所有参数",也就是那些覆盖项。 + - `config` —— 基础配置。 +- 包在 `try/catch` 里:拼配置时若参数非法(如未知配置键),`buildTransportConfig` 会 `throw`,这里接住、返回友好错误。 + +### 第 48-57 行:执行切换 + +```typescript + // Perform the switch + try { + await switchTransport(transportConfig, api.logger); + clearDiscoveryCache(); + return { text: formatSwitchSuccess(transportConfig) }; + } catch (err) { + return { + text: `Failed to switch transport: ${String(err)}\nYou can retry with /transport ${modeArg}`, + }; + } + }, + }); +} +``` + +- `await switchTransport(transportConfig, api.logger);` —— **调第⑫篇那个热切换函数**(断旧连新、带并发门闩)。命令到这里才真正动手切换。 +- `clearDiscoveryCache();` —— 切换成功后清空能力缓存(第23篇),因为换了传输、能力可能变,强制下次重新发现。 +- `return { text: formatSwitchSuccess(transportConfig) };` —— 返回成功消息(调辅助函数格式化,下面讲)。 +- `catch (err)` —— 切换失败(连不上等):返回友好错误,**还贴心地提示"可以用 `/transport <模式>` 重试"**(回忆第⑫篇 `switchTransport` 注释说"失败不回滚、用户重试"——这里就是那句"重试"的落地)。 +- 注意 `\n` 让错误信息换行(第23篇)。 + +--- + +## 第 62-76 行:`showStatus`——显示当前状态 + +```typescript +function showStatus(): { text: string } { + const mode = getTransportMode(); + + if (!mode) { + return { text: "Transport: not active" }; + } + + try { + const transport = getTransport(); + const status = transport.getStatus(); + return { text: `Transport: ${mode} (${status})` }; + } catch { + return { text: `Transport: ${mode} (unknown status)` }; + } +} +``` + +- 返回类型写成 `{ text: string }`(和命令结果同形)。 +- `const mode = getTransportMode();` —— 查当前模式(第⑫篇,可能 `null`)。 +- `if (!mode)` —— 没有活动传输,回"not active"。 +- 否则 `try` 里查具体连接状态:`getStatus()`(第④/⑩篇),拼成 `Transport: webrtc (connected)` 这样。 +- `catch {}` —— **空 catch**(第23篇):万一查状态出错也无所谓,退回显示 `(unknown status)`。**查状态是只读小事,失败就给个模糊答案,不值得报错。** + +--- + +## 第 78-136 行:`buildTransportConfig`——按模式拼配置 + 应用覆盖 + +这是最长的辅助函数,但骨架是第⑫篇见过的 `switch (mode)`。 + +### 签名 + +```typescript +function buildTransportConfig( + mode: Mode, + overrides: string[], + config: RosClawConfig, +): TransportConfig { + switch (mode) { +``` +- 吃模式、覆盖参数数组、基础配置,返回拼好的 `TransportConfig`。 +- `switch (mode)` —— 按三种模式分别处理。因为 `mode` 是 `Mode`(三选一),三个 case 穷尽,**不用 default**(第⑫篇讲过的"穷尽即可省 default")。 + +### rosbridge 分支(第 87-104 行,最完整) + +```typescript + case "rosbridge": { + const base = { ...config.rosbridge }; + + for (const arg of overrides) { + if (arg.startsWith("ws://") || arg.startsWith("wss://")) { + // Positional URL override + base.url = arg; + } else if (arg.includes("=")) { + const [key, ...rest] = arg.split("="); + const value = rest.join("="); + applyOverride(base, key, value); + } else { + throw new Error(`Unexpected argument: "${arg}"`); + } + } + + return { mode: "rosbridge", rosbridge: base }; + } +``` + +- `const base = { ...config.rosbridge };` —— **语法小课堂:`{ ...对象 }` 是"展开(spread)"——浅拷贝一个对象。** + - `...config.rosbridge` 把原配置的所有字段"摊开"复制进一个新对象 `base`。 + - **为什么要拷贝、不直接改 `config.rosbridge`?** 因为我们要往 `base` 上应用用户的覆盖项,但**不想污染原始配置**(原配置后面可能还要用)。拷一份改副本,是安全的做法。(回忆第⑫篇"先存局部变量再提交"的同款谨慎。) +- `for (const arg of overrides)` —— 遍历每个覆盖参数(第⑥篇 `for...of`)。对每个 `arg` 三种情况: + 1. `if (arg.startsWith("ws://") || arg.startsWith("wss://"))` —— 是个 WebSocket 地址(以 `ws://`/`wss://` 开头,第⑥篇 `.startsWith`)→ **位置式覆盖**:直接当作 url,`base.url = arg`。这让用户能简写 `/transport rosbridge ws://...` 而不必写 `url=ws://...`。 + 2. `else if (arg.includes("="))` —— 含等号 → **key=value 式覆盖**: + - **语法小课堂:`字符串.includes(x)` 判断是否包含子串**(和数组的 `.includes` 同名、用在字符串上)。 + - `const [key, ...rest] = arg.split("=");` —— 按 `=` 切开,**用数组解构 + 剩余元素 `...rest`**: + - **语法小课堂:解构里的 `...rest`(剩余元素)。** `[key, ...rest]` 把第一段给 `key`,**其余所有段收进数组 `rest`**。 + - **为什么要 `...rest` 而不是 `[key, value]`?** 因为值里可能**也含 `=`**(如 `token=ab=cd`)。`split("=")` 会切成 `["token","ab","cd"]`;`key="token"`、`rest=["ab","cd"]`。 + - `const value = rest.join("=");` —— 再用 `=` 把 `rest` 拼回去(`"ab=cd"`)。**先按 = 切、第一段当键、剩下的用 = 拼回当值**——这样值里的 `=` 不会丢。是处理"键=值,值里可能有等号"的标准技巧。 + - `applyOverride(base, key, value);` —— 调下个函数应用这条覆盖(带类型转换)。 + 3. `else { throw new Error(...) }` —— 既不是地址也不是 key=value → 非法参数,抛错(被上面 handler 的 try 接住)。 +- 最后 `return { mode: "rosbridge", rosbridge: base };` —— 拼成判别联合的 rosbridge 成员(第③篇)返回。 + +### webrtc / local 分支(第 106-134 行) + +- 这两个分支结构相同、比 rosbridge 简单:**只接受 key=value 形式**(没有"位置式 url"那种特例),不是 key=value 就抛错。 +- 各自 `{ ...config.webrtc }` / `{ ...config.local }` 拷贝、遍历应用覆盖、返回对应判别联合成员。 +- 重复三段而非强行合并——因为每个模式的特例不同(rosbridge 有 url 简写),分开写更清楚。 + +--- + +## 第 138-158 行:`applyOverride`——把字符串值转成正确类型(很妙) + +```typescript +function applyOverride(obj: Record, key: string, value: string): void { + if (!(key in obj)) { + throw new Error(`Unknown config key: "${key}"`); + } + + const existing = obj[key]; + + // Coerce value to match existing type + if (typeof existing === "number") { + const num = Number(value); + if (Number.isNaN(num)) { + throw new Error(`"${key}" expects a number, got "${value}"`); + } + obj[key] = num; + } else if (typeof existing === "boolean") { + obj[key] = value === "true"; + } else { + obj[key] = value; + } +} +``` + +这个函数解决一个实际问题:**用户输入的永远是字符串**(命令行就是文本),但配置项可能要数字或布尔。得"按原值的类型,把字符串转过去"。 + +- `if (!(key in obj))` —— **语法小课堂:`键 in 对象` 判断对象有没有这个键。** 没有这个配置项就抛错"未知配置键"——防止用户瞎写。 + - `!(key in obj)` 整体加括号取反:"不存在这个键"。 +- `const existing = obj[key];` —— 取出该键**现有的值**,用它的类型当"目标类型"。 +- 按 `existing` 的类型分三种转换(`typeof`,回忆第②⑥篇): + - **是数字**:`const num = Number(value);` —— **`Number("3.5")` 把字符串转成数字**。 + - `if (Number.isNaN(num))` —— **语法小课堂:`Number.isNaN(x)` 判断是不是 NaN(Not a Number,非数字)。** 如果用户填了 `"abc"`,`Number("abc")` 得到 `NaN`,这里就抛错"期望数字"。`NaN` 是"转换失败"的标志。 + - 转换成功就 `obj[key] = num`。 + - **是布尔**:`obj[key] = value === "true";` —— 把字符串 `"true"` 转成布尔 `true`、其余转成 `false`。简单直接。 + - **否则(字符串等)**:`obj[key] = value;` —— 原样存字符串。 +- **这个函数的智慧**:以现有配置值的类型为准,把用户输入的字符串"强制对齐"成对的类型,并在转不动时报错。这样 `/transport rosbridge timeout=5000` 里的 `"5000"` 会正确变成数字 5000,而不是字符串。 + +--- + +## 第 160-171 行:`formatSwitchSuccess`——拼成功消息 + +```typescript +function formatSwitchSuccess(config: TransportConfig): string { + switch (config.mode) { + case "rosbridge": + return `Switched to rosbridge transport (${config.rosbridge.url})`; + case "webrtc": { + const robotId = config.webrtc.robotId; + return `Switched to webrtc transport (robotId: ${robotId})`; + } + case "local": + return `Switched to local transport (domainId: ${config.local?.domainId ?? 0})`; + } +} +``` + +- 按模式拼一句不同的成功提示(`switch` + 判别联合类型收窄,第③⑪篇——每个 case 里能安全访问对应字段)。 +- `config.local?.domainId ?? 0` —— 可选链 + 空值合并(第⑥篇):安全取 `domainId`,没有就显示 0。 +- webrtc 那个 case 套了 `{ }` 块(因为里面用 `const` 声明了 `robotId`,回忆第⑪篇"case 里用 const 要套花括号");另两个 case 直接 return,不需要。 + +--- + +## 整章回顾 + +`/transport` 是命令里最丰富的一个,把"解析→校验→拼配置→切换"串成完整流程: + +| 步骤 | 谁做 | 关键技巧 | +|---|---|---| +| 取参数 | handler | `ctx.args ?? ""`、`.trim()`、`.split(/\s+/)` | +| 校验模式 | `isValidMode` | `as const` + 派生类型 `Mode` + 类型守卫收窄 | +| 拼配置 | `buildTransportConfig` | `{...spread}` 拷贝、`for...of` 应用覆盖、`[key,...rest]` 解析 key=value | +| 类型转换 | `applyOverride` | 按现有值的 `typeof` 把字符串转 number/boolean,`Number.isNaN` 校验 | +| 切换 | handler | `await switchTransport`(第⑫篇)+ `clearDiscoveryCache`(第23篇)+ 失败提示重试 | + +无参数时则走 `showStatus` 显示当前模式与状态。 + +**语法点回顾清单**(本章新增/巩固): +- **`as const`**:把数组/值锁成只读字面量类型(让类型可从值派生) +- **从数组派生类型 `(typeof 数组)[number]`**(元素联合类型) +- **类型守卫 `function f(x): x is T`**:运行时检查 + 让 TS 收窄类型 +- `数组.includes` / `字符串.includes`(是否包含)、`数组.join` / `字符串.split`(拼/拆)、`.slice(1)`(数组切片) +- 正则 `/\s+/`(匹配连续空白,用作分隔符)—— 仅需知其义 +- 解构剩余元素 `const [key, ...rest] = ...`(处理"值里也含 = ") +- `{ ...对象 }` 展开浅拷贝(改副本不污染原配置) +- `键 in 对象`(判断有无该键)、`typeof` 分支转换、`Number(value)` 转数字、`Number.isNaN` 校验、`value === "true"` 转布尔 +- `switch` + 判别联合收窄、case 用 `const` 套 `{}`(巩固第③⑪篇) + +--- + +## 🎉 第六部分(钩子与命令)讲完! + +到这里,**插件的三种交互方式全部讲透**: + +> **工具**(AI 调,14–21)+ **钩子**(按时机自动触发,22–23)+ **命令**(用户直接打,24–25)。 + +加上前面的骨架(1–13),**整个 `@rosclaw/openclaw-plugin` 插件——以 rosbridge 为传输——你已经从头到尾、逐行读完了!** 这是项目的主体和精华。 + +接下来**第七部分**转向**另外两种传输模式**(模式 A 本地 DDS、模式 C WebRTC)——它们和 rosbridge 实现的是同一套 `RosTransport` 接口(第④篇),所以你已经懂它们"该长什么样",读起来会有"换汤不换药"的熟悉感,同时会接触一些新东西(如 `rclnodejs`、WebRTC 信令)。 + +下一份:[`transport/local/transport.ts` 逐行详解 →](26-local-transport.ts.md)(模式 A:在同一台机器上直接用 DDS,不经 rosbridge——本系列最后会补的 `createRequire`/CommonJS 互操作在此登场) diff --git a/learn/code/26-local-transport.ts.md b/learn/code/26-local-transport.ts.md new file mode 100644 index 0000000..b54787c --- /dev/null +++ b/learn/code/26-local-transport.ts.md @@ -0,0 +1,534 @@ +# 逐行详解 ㉖:`transport/local/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/transport.ts](../../extensions/openclaw-plugin/src/transport/local/transport.ts) +> +> 推荐阅读顺序第 26 个文件,**第七部分开篇、也是目前最长的源文件(375 行)**。它是**模式 A** 的传输:当 OpenClaw **直接跑在机器人上**时,不必绕 rosbridge 网络,直接用本地 DDS 总线和 ROS2 对话。它和第⑩篇 `RosbridgeTransport` 实现的是**同一个 `RosTransport` 接口**(第④篇),所以你已经懂它"该有哪些方法"——这篇会有"换汤不换药"的熟悉感。新东西是底层换成了 Node 库 **`rclnodejs`**,由此引出本系列最后一个预告的大语法:**`createRequire`(CommonJS 互操作)**。本篇较长,我们按"接口的几大块"分段,节奏会比前面快——因为方法的"意图"你已在第④⑩篇熟知,重点只看"本地 DDS 怎么实现它"。 + +--- + +## 先理解模式 A 和 rclnodejs + +- **rosbridge 模式(B)**:OpenClaw 在一台电脑、机器人在另一处,中间靠 WebSocket + rosbridge_server 转发(第⑤–⑩篇那一大套)。 +- **本地模式(A)**:OpenClaw **就装在机器人身上**,和 ROS2 节点在同一台机器、同一条 DDS 总线上。这时根本不需要网络中转——直接用 ROS2 的原生通信即可。 +- **`rclnodejs`** 是"ROS2 的 Node.js 客户端库"——它让 JavaScript 能直接创建 ROS2 节点、发布/订阅、调服务。模式 A 就靠它。 +- **代价**:`rclnodejs` 是个**可选的、重型依赖**(需要 ROS2 环境,回忆第⑪篇工厂里那段"local 模式可能没装 rclnodejs"的错误处理)。所以本篇大量地方把它当 `any` 处理、动态加载——下面会看到。 + +--- + +## 第 1-21 行:导入 + `createRequire`(最后的大语法) + +```typescript +import { createRequire } from "node:module"; +import type { RosTransport } from "../transport.js"; +import type { + ConnectionStatus, ConnectionHandler, Subscription, PublishOptions, + SubscribeOptions, ServiceCallOptions, ServiceCallResult, ActionGoalOptions, + ActionResult, TopicInfo, ServiceInfo, ActionInfo, MessageHandler, +} from "../types.js"; +import { EntityCache } from "./entities.js"; +import { toRosMessage, fromRosMessage, loadMessageClass, clearTypeCache } from "./conversion.js"; + +const require = createRequire(import.meta.url); +``` + +- 第 2-17 行:导入 `RosTransport` 接口和一堆类型(和第⑩篇适配器几乎一样的清单),以及两个**同目录帮手文件**: + - `EntityCache`(来自 `./entities.js`,第28篇讲)—— 缓存发布器/订阅器/服务客户端。 + - `toRosMessage`/`fromRosMessage`/`loadMessageClass`/`clearTypeCache`(来自 `./conversion.js`,第27篇讲)—— 在"普通 JS 对象"和"rclnodejs 类型化消息"之间转换。 + - **这两个帮手是本篇的左膀右臂**,这里先记住它们的用途,调用到时点一下,细节留给 27、28 篇。 +- **第 1 行 + 第 21 行是本系列最后一个预告的大语法:** + +```typescript +import { createRequire } from "node:module"; +// ... +const require = createRequire(import.meta.url); +``` +- **语法小课堂:`createRequire` 与 ESM / CommonJS 互操作。** + - 回忆 CLAUDE.md:本项目是**纯 ESM**(用 `import`/`export`)。而 `rclnodejs` 是个老式的 **CommonJS(CJS)** 库——CJS 用的是另一套加载机制:`require("xxx")`(一个函数)。 + - 问题:ESM 文件里**默认没有 `require` 这个函数**(它是 CJS 的东西)。但我们偏偏要加载一个 CJS 库。 + - `createRequire(import.meta.url)` 就是 Node 提供的"桥":它**造出一个能用的 `require` 函数**,让你在 ESM 文件里也能 `require("rclnodejs")` 加载 CJS 模块。 + - `import.meta.url` —— ESM 里表示"当前文件的地址",`createRequire` 需要它来确定"从哪个位置去找模块"。 + - 造好后赋给 `const require = ...`,下面就能像 CJS 那样 `require("rclnodejs")` 了。 + - 一句话:**`createRequire` 是"在新式 ESM 代码里,借用旧式 CJS 的 `require` 加载机制"的官方手段。** 专门用于加载那些还没 ESM 化的老库。 + +> 至此,README 里"后面会补的大语法"全部讲完——`Promise.all`(第23篇)、`createRequire`(本篇)都已登场。 + +--- + +## 第 23-36 行:选项类型 + 内部过滤名单 + +```typescript +export interface LocalTransportOptions { + domainId?: number; +} + +/** Internal ROS2 topics/services to filter from introspection results. */ +const INTERNAL_TOPIC_PREFIXES = ["/rosout", "/parameter_events", "/rosclaw/"]; +const INTERNAL_SERVICE_SUFFIXES = [ + "/describe_parameters", "/get_parameter_types", "/get_parameters", + "/list_parameters", "/set_parameters", "/set_parameters_atomically", +]; +``` + +- `LocalTransportOptions` —— 本地传输的选项,就一个可选 `domainId`。 + - **DDS 的 domainId(域 ID)**:ROS2 用它把不同的机器人/系统隔开——同一个 domainId 的节点才能互相通信。默认 0。 +- 两个常量数组:**自省时要过滤掉的"内部话题/服务"**。 + - ROS2 自己有一堆系统话题(`/rosout` 日志、`/parameter_events` 参数事件)和每个节点自带的参数服务(`/get_parameters` 等)。列话题/服务时这些是"噪音",AI 不关心,所以建两份名单把它们滤掉。下面 `listTopics`/`listServices` 会用到。 + +--- + +## 第 44-59 行:类实现接口 + 一堆 `any` 字段 + 静态门闩 + +```typescript +export class LocalTransport implements RosTransport { + private domainId: number; + private status: ConnectionStatus = "disconnected"; + private connectionHandlers = new Set(); + /** rclnodejs module — loaded dynamically at runtime (optional dep, no types). */ + private rclnodejs: any = null; + private node: any = null; + private entityCache: EntityCache | null = null; + private activeGoals = new Map(); + + /** Singleton guard — rclnodejs.init() must only be called once per process. */ + private static rclInitialized = false; + + constructor(options?: LocalTransportOptions) { + this.domainId = options?.domainId ?? 0; + } +``` + +- `implements RosTransport` —— 和第⑩篇一样,**承诺实现那 13 个方法**(第④/⑩篇 `implements`)。下面方法的"意图"与第⑩篇一一对应。 +- 字段里多处 `any`(回忆第⑪篇 `any` 是"放弃类型检查"):`rclnodejs`、`node`、`activeGoals` 的值都是 `any`。 + - **为什么这么多 `any`?** 注释说了:`rclnodejs` 是可选依赖、**可能没有类型定义**。没类型可标,就用 `any` 让 TS 别管。这是"和无类型的老库打交道"的现实妥协——代价是这部分失去类型保护,得自己小心。 +- `private status: ConnectionStatus = "disconnected";` —— 当前连接状态,初始"断开"。 +- `connectionHandlers = new Set();` —— 存连接状态回调的集合(`Set`,第⑥篇)。 + - **注意:和第⑩篇不同,这里自己管理回调集合**。第⑩篇是把 `onConnection` 转发给底层 client;本地模式没有那样的 client,得自己存回调、状态变了自己挨个通知(见下面 `setStatus`)。 +- `entityCache: EntityCache | null` —— 那个实体缓存(第28篇),连接后才建。 +- `activeGoals = new Map()` —— 记录"正在进行的动作目标",键是动作名、值是目标句柄(用于取消)。 +- `private static rclInitialized = false;` —— **语法小课堂:`static`(静态成员)。** + - 普通字段(如 `this.status`)是**每个实例各一份**。加 `static` 的字段是**整个类共享一份**(挂在类上,不在实例上),所有实例看到同一个。 + - 这里为什么要 static?注释:`rclnodejs.init()` **每个进程只能调一次**(它是全局初始化)。如果建了两个 `LocalTransport` 实例,不能各初始化一次。用 `static rclInitialized` 这个**类级别的门闩**,保证"全进程只初始化一次"(回忆第⑫篇并发门闩,这是它的 static 版)。 + - 访问时写 `LocalTransport.rclInitialized`(用类名,不是 `this`),下面会看到。 +- 构造函数:取 `domainId`,没给就 0(`??`)。 + +--- + +## 第 63-90 行:`connect`——加载 rclnodejs、初始化、建节点 + +```typescript + async connect(): Promise { + if (this.status === "connected") return; + this.setStatus("connecting"); + + try { + this.rclnodejs = require("rclnodejs"); + + if (this.domainId !== 0) { + process.env.ROS_DOMAIN_ID = String(this.domainId); + } + + if (!LocalTransport.rclInitialized) { + await this.rclnodejs!.init(); + LocalTransport.rclInitialized = true; + } + + this.node = this.rclnodejs!.createNode("rosclaw_local"); + this.rclnodejs!.spin(this.node); + this.entityCache = new EntityCache(); + + this.setStatus("connected"); + } catch (err) { + this.setStatus("disconnected"); + throw err; + } + } +``` + +- `if (this.status === "connected") return;` —— 已连就直接返回(幂等:重复调用无害)。 +- `this.setStatus("connecting");` —— 标记"连接中"(`setStatus` 见下面,会通知回调)。 +- `try { ... } catch` —— 整个连接过程包起来,失败就退回"断开"并把错误抛出去。 +- `this.rclnodejs = require("rclnodejs");` —— **用上面造的 `require` 加载 rclnodejs**。如果没装,这里抛错(回忆第⑪篇工厂会把它翻译成友好提示)。 +- `if (this.domainId !== 0) { process.env.ROS_DOMAIN_ID = String(this.domainId); }` —— 非默认域就设环境变量 `ROS_DOMAIN_ID`。 + - **语法小课堂:`process.env.XXX` 是"读写环境变量"。** `process` 是 Node 的全局对象,`process.env` 是环境变量集合。rclnodejs 通过这个环境变量决定用哪个 DDS 域,所以**必须在 `init()` 之前设好**。`String(this.domainId)` 把数字转成字符串(环境变量都是字符串)。 +- `if (!LocalTransport.rclInitialized) { await this.rclnodejs!.init(); ... }` —— **用静态门闩保证只初始化一次**:没初始化过才 `init()`,然后把门闩置 true。 + - `this.rclnodejs!` 的 `!` 是非空断言(第⑨篇):上面刚赋了值,断言它非 null。 +- `this.node = this.rclnodejs!.createNode("rosclaw_local");` —— 创建一个名为 `rosclaw_local` 的 ROS2 节点(我们在 DDS 总线上的"身份")。 +- `this.rclnodejs!.spin(this.node);` —— **`spin`(自转)让节点开始处理消息**。ROS2 里节点必须"spin"起来才能收发消息(相当于"开始监听/泵消息")。 +- `this.entityCache = new EntityCache();` —— 建实体缓存(第28篇)。 +- `this.setStatus("connected");` —— 大功告成,标记已连。 + +--- + +## 第 92-127 行:`disconnect`——层层清理(逆序拆解) + +```typescript + async disconnect(): Promise { + if (this.status === "disconnected") return; + + // Cancel any active action goals + for (const [action] of this.activeGoals) { + try { + await this.cancelActionGoal(action); + } catch { + // Best-effort + } + } + this.activeGoals.clear(); + + if (this.entityCache && this.node) { + this.entityCache.destroyAll(this.node); + this.entityCache = null; + } + + if (this.node) { + this.node.destroy(); + this.node = null; + } + + if (this.rclnodejs && LocalTransport.rclInitialized) { + try { + this.rclnodejs.shutdown(); + } catch { + // May already be shut down + } + LocalTransport.rclInitialized = false; + } + + clearTypeCache(); + this.rclnodejs = null; + this.setStatus("disconnected"); + } +``` + +- 断开要"按建立的逆序"把资源一层层拆掉。逐段: + - **先取消所有进行中的动作**:`for (const [action] of this.activeGoals)` —— 遍历 Map。 + - **语法小课堂:`for (const [action] of 某Map)` 遍历 Map + 解构。** Map 遍历时每项是 `[键, 值]` 对,这里用数组解构 `[action]` **只取键**(动作名),值不要。逐个调 `cancelActionGoal` 取消。 + - 每个取消包 `try { } catch {}`(空 catch,第23篇)——`// Best-effort`(尽力而为):取消失败也不影响继续断开。 + - `this.activeGoals.clear();` —— 清空记录。 + - **销毁实体缓存**:有缓存和节点就 `destroyAll`(第28篇)销毁所有发布器/订阅器/客户端,再置 null。 + - **销毁节点**:`this.node.destroy()`。 + - **关闭 rclnodejs**:`this.rclnodejs.shutdown()`,包 try/catch(可能已关);并把静态门闩复位 `false`(允许将来重新 init)。 + - `clearTypeCache();` —— 清第27篇那个消息类型缓存。 + - `this.rclnodejs = null;` + `setStatus("disconnected")` —— 收尾。 +- **整段是"对称清理"的范例**:connect 里建了什么,disconnect 就逆序拆什么;每步都防御性判断(`if (this.node)` 等)+ 尽力而为(空 catch),保证清理过程本身不会因小错中断。 + +--- + +## 第 129-138 行:`getStatus` / `onConnection` + +```typescript + getStatus(): ConnectionStatus { + return this.status; + } + + onConnection(handler: ConnectionHandler): () => void { + this.connectionHandlers.add(handler); + return () => { + this.connectionHandlers.delete(handler); + }; + } +``` + +- `getStatus` —— 直接返回当前状态字段。 +- `onConnection` —— **自己管理回调集合**(不像第⑩篇转发给 client):把回调加进 `Set`,返回一个退订函数(调用时从 `Set` 删除)。这是"登记回调 + 返回退订函数"的标准款(回忆第④/⑦篇)。 + +--- + +## 第 142-158 行:`publish` / `subscribe`(借助两个帮手) + +```typescript + publish(options: PublishOptions): void { + this.ensureConnected(); + const publisher = this.entityCache!.getPublisher(this.node, options.topic, options.type); + const rosMsg = toRosMessage(options.type, options.msg); + publisher.publish(rosMsg); + } + + subscribe(options: SubscribeOptions, handler: MessageHandler): Subscription { + this.ensureConnected(); + const type = options.type ?? this.resolveTopicType(options.topic); + if (!type) { + throw new Error(`Cannot subscribe to ${options.topic}: type is required when it cannot be inferred`); + } + return this.entityCache!.addSubscription(this.node, options.topic, type, handler); + } +``` + +- `this.ensureConnected();` —— 每个干活方法开头先确认已连接(见下面私有助手),没连就抛错。 +- **`publish`**: + - `this.entityCache!.getPublisher(...)` —— 从缓存拿(或建)一个发布器(第28篇)。 + - `const rosMsg = toRosMessage(options.type, options.msg);` —— **关键转换**:把上层给的"普通 JS 对象消息"转成 rclnodejs 要的"类型化消息实例"(第27篇)。这是本地模式特有的——rosbridge 走 JSON 不用转,本地走 rclnodejs 必须转成它认的类型。 + - `publisher.publish(rosMsg);` —— 发出去。 +- **`subscribe`**: + - `const type = options.type ?? this.resolveTopicType(options.topic);` —— 类型可省时,**尝试从节点的图信息里推断**(`resolveTopicType` 见下面)。 + - 推断不出(`if (!type)`)就抛错——本地模式订阅必须知道类型(不像 rosbridge 服务端有时能帮忙)。 + - `return this.entityCache!.addSubscription(...)` —— 委托给缓存去订阅,返回 `Subscription` 句柄(第28篇)。 + +--- + +## 第 162-196 行:`callService`(手工拼请求 + Promise 包装) + +```typescript + async callService(options: ServiceCallOptions): Promise { + this.ensureConnected(); + + const type = options.type ?? this.resolveServiceType(options.service); + if (!type) { + throw new Error(`Cannot call service ${options.service}: type is required when it cannot be inferred`); + } + + const client = this.entityCache!.getServiceClient(this.node, options.service, type); + + const available = await client.waitForService(5000); + if (!available) { + throw new Error(`Service ${options.service} not available after 5 seconds`); + } + + const ServiceClass = loadMessageClass(type); + const request = new ServiceClass.Request(); + if (options.args) { + for (const [key, value] of Object.entries(options.args)) { + request[key] = value; + } + } + + const response = await this.sendServiceRequest(client, request, 30_000); + + return { + result: true, + values: fromRosMessage(response), + }; + } +``` + +- 推断类型(同 subscribe 思路)、从缓存拿服务客户端。 +- `await client.waitForService(5000)` —— **等服务端就绪(最多 5 秒)**。本地 DDS 里服务端可能还没起来,先等等;等不到就抛错。 +- **手工拼请求对象**: + - `const ServiceClass = loadMessageClass(type);` —— 加载服务类型类(第27篇)。 + - `const request = new ServiceClass.Request();` —— new 一个空请求实例。 + - `if (options.args) { for (const [key, value] of Object.entries(options.args)) { request[key] = value; } }` —— **把参数逐个拷进请求对象**。 + - **语法小课堂:`Object.entries(对象)` —— 把对象拆成 `[键, 值]` 对的数组**,配合 `for...of` + 解构 `[key, value]` 就能遍历对象的每个字段。这里把 `args` 里每个字段赋到 `request` 上。(这是遍历对象所有键值的标准写法。) +- `const response = await this.sendServiceRequest(client, request, 30_000);` —— 调私有助手把"回调式的发请求"包装成 Promise + 30 秒超时(见下面)。 +- 返回 `{ result: true, values: fromRosMessage(response) }` —— **把 rclnodejs 的响应转回普通对象**(`fromRosMessage`,第27篇,与 publish 的 `toRosMessage` 相反方向)。 + - 注意 `result: true` 写死——本地模式里能拿到响应就算成功(不像 rosbridge 响应自带 result 字段)。 + +--- + +## 第 200-257 行:`sendActionGoal`(回调式动作 → Promise + finally 清理) + +```typescript + async sendActionGoal(options: ActionGoalOptions): Promise { + this.ensureConnected(); + + const ActionClass = loadMessageClass(options.actionType); + const actionClient = new (this.rclnodejs!.ActionClient as any)( + this.node, ActionClass, options.action, + ); + + const available = await actionClient.waitForServer(5000); + if (!available) { + actionClient.destroy(); + throw new Error(`Action server ${options.action} not available after 5 seconds`); + } + + const goal = new ActionClass.Goal(); + if (options.args) { + for (const [key, value] of Object.entries(options.args)) { + goal[key] = value; + } + } + + try { + const result = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.activeGoals.delete(options.action); + reject(new Error(`Action ${options.action} timed out after 120 seconds`)); + }, 120_000); + + actionClient.sendGoal( + goal, + (goalHandle: any) => { + this.activeGoals.set(options.action, goalHandle); + }, + (feedback: any) => { + if (options.onFeedback) { + options.onFeedback(fromRosMessage(feedback)); + } + }, + (resultResponse: any) => { + clearTimeout(timer); + this.activeGoals.delete(options.action); + resolve(fromRosMessage(resultResponse)); + }, + ); + }); + + return { result: true, values: result }; + } finally { + actionClient.destroy(); + } + } +``` + +这是本篇最复杂的方法,但**骨架就是第⑨篇 + 第⑯篇的组合**(Promise 包装回调 + 超时 + `try/finally` 清理)。逐看: + +- 加载动作类、`new ActionClient(...)`: + - `new (this.rclnodejs!.ActionClient as any)(...)` —— **语法小课堂:`new (表达式 as any)(...)`。** `ActionClient` 类挂在 rclnodejs 上、是 `any`(无类型)。要 `new` 一个 `any` 的构造器,TS 会犹豫,所以把它 `as any` 后用圆括号包起来再 `new`。是和无类型库打交道时的常见写法。 +- `await actionClient.waitForServer(5000)` —— 等动作服务器就绪,等不到先 `destroy()` 清理再抛错。 +- 拼 `goal` 对象(同 callService 拼 request 的写法:`Object.entries` 逐个赋)。 +- **核心 Promise**(包住 rclnodejs 那套回调): + - `setTimeout(...120_000)` —— 120 秒超时(动作是长任务,第⑨篇同款时长),超时就从 `activeGoals` 删掉并 reject。 + - `actionClient.sendGoal(goal, 回调1, 回调2, 回调3)` —— rclnodejs 的发目标要**三个回调**: + 1. **目标响应回调** `(goalHandle) => this.activeGoals.set(options.action, goalHandle)` —— 服务器接受目标后给个"目标句柄",**存进 `activeGoals` 备用**(取消时要用它)。 + 2. **进度反馈回调** `(feedback) => { if (options.onFeedback) options.onFeedback(fromRosMessage(feedback)); }` —— 每来一条进度,转成普通对象后转交上层的 `onFeedback`(回忆第⑩篇也做过类似的"进度转交")。 + 3. **结果回调** `(resultResponse) => { clearTimeout(timer); this.activeGoals.delete(...); resolve(fromRosMessage(resultResponse)); }` —— 动作完成:清超时、移除记录、`resolve` 最终结果(转成普通对象)。 + - 这就是"把回调式 API 包装成 Promise"(第⑧篇思想)的又一次实践,只是这里有三个回调要安置。 +- `return { result: true, values: result };` —— 成功返回。 +- **`finally { actionClient.destroy(); }`** —— **无论成功/超时/出错,都销毁这个动作客户端**(第⑨篇 `try/finally` 的清理精髓)。动作客户端是一次性的,用完必须销毁,放 finally 万无一失。 + +--- + +## 第 259-265 行:`cancelActionGoal` + +```typescript + async cancelActionGoal(action: string): Promise { + const goalHandle = this.activeGoals.get(action); + if (goalHandle && typeof goalHandle.cancelGoal === "function") { + await goalHandle.cancelGoal(); + this.activeGoals.delete(action); + } + } +``` + +- 从 `activeGoals` 取出那个动作的"目标句柄"(上面回调1存的)。 +- `if (goalHandle && typeof goalHandle.cancelGoal === "function")` —— **双重保险**:句柄存在、且它确实有个 `cancelGoal` 方法(`typeof ... === "function"` 检查,第⑥篇)。因为句柄是 `any`,不确定有没有这方法,先查一下更稳。 +- 有就 `await goalHandle.cancelGoal()` 取消、并从记录里删除。 + +--- + +## 第 269-310 行:三个自省方法(filter + some 过滤内部项) + +```typescript + async listTopics(): Promise { + this.ensureConnected(); + const namesAndTypes: Array<{ name: string; types: string[] }> = + this.node.getTopicNamesAndTypes(); + return namesAndTypes + .filter((t) => !INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix))) + .map((t) => ({ name: t.name, type: t.types[0] ?? "" })); + } +``` + +- `this.node.getTopicNamesAndTypes()` —— rclnodejs **直接从 DDS 图里拿到所有话题名和类型**(比 rosbridge 还方便——本地能直接查图,不用调 rosapi 服务)。 + - 返回类型显式标成 `Array<{ name: string; types: string[] }>`(每项有名字和类型数组)。 +- **链式 `.filter(...).map(...)`**: + - `.filter((t) => !INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix)))` —— 滤掉内部话题。 + - **语法小课堂:`数组.some(判断函数)` —— "有没有任意一个元素满足"**,返回 true/false。`INTERNAL_TOPIC_PREFIXES.some((prefix) => t.name.startsWith(prefix))` 读作"这话题名是否以**任意一个**内部前缀开头"。前面加 `!` 取反 → "不以任何内部前缀开头的才保留"。 + - `.some` 配 `.filter` 是"按一组条件批量过滤"的经典组合。 + - `.map((t) => ({ name: t.name, type: t.types[0] ?? "" }))` —— 转成 `TopicInfo`(第⑩篇见过的 `.map` + 返回对象套圆括号 + `?? ""` 兜底)。取 `types[0]`(第一个类型)。 +- `listServices`(第280行)—— 同款,但**两道 filter**:先滤"内部服务后缀"(`.endsWith` + `.some`),再滤"内部前缀"。链式 filter 可以叠加多个条件。 +- `listActions`(第292行)—— **和第⑩篇 `listActions` 几乎一字不差**:没有现成的列动作接口,用"找 `/_action/feedback` 后缀话题反推动作名"的启发式(第⑩篇详讲过,这里直接复用同一套 `.slice`/`.endsWith` 逻辑)。注释也明说 `Same ... heuristic as rosbridge/adapter.ts`。 + +--- + +## 第 314-374 行:私有助手们 + +### `setStatus`(改状态 + 通知所有回调) + +```typescript + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const handler of this.connectionHandlers) { + handler(status); + } + } +``` +- 改状态字段,然后**遍历回调集合挨个通知**(`for...of` 遍历 Set)。这就是为什么前面 `connect`/`disconnect` 里改状态都走 `setStatus`——好统一触发通知。 + +### `ensureConnected`(前置守卫) + +```typescript + private ensureConnected(): void { + if (this.status !== "connected" || !this.node || !this.entityCache) { + throw new Error("LocalTransport is not connected"); + } + } +``` +- 三个条件任一不满足就抛错。每个干活方法开头都调它,**把"没连接"的情况挡在最前面**,省得后面各方法各自判空。 + +### `resolveTopicType` / `resolveServiceType`(从图里推断类型) + +```typescript + private resolveTopicType(topic: string): string | undefined { + if (!this.node) return undefined; + const namesAndTypes: Array<{ name: string; types: string[] }> = + this.node.getTopicNamesAndTypes(); + const entry = namesAndTypes.find((t) => t.name === topic); + return entry?.types[0]; + } +``` +- 用来在 `subscribe`/`callService` 省略类型时自动推断。 +- **语法小课堂:`数组.find(判断函数)` —— "找出第一个满足条件的元素"**(找不到返回 `undefined`)。和 `.filter`(找全部)不同,`.find` 只要第一个。这里找名字匹配的那一项。 +- `return entry?.types[0];` —— 可选链(第⑥篇):找到就返回它第一个类型,没找到(`entry` 是 undefined)整体返回 undefined。 +- `resolveServiceType` 同款,换成服务。 + +### `sendServiceRequest`(回调式 → Promise + 超时) + +```typescript + private sendServiceRequest(client: any, request: any, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Service call timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + try { + client.sendRequest(request, (response: any) => { + clearTimeout(timer); + if (response) { + resolve(response); + } else { + reject(new Error("Service returned no response")); + } + }); + } catch (err) { + clearTimeout(timer); + reject(err); + } + }); + } +``` +- 又一次"把回调式 API 包成 Promise + 超时"(第⑧/⑯篇思想): + - 设超时定时器 → 超时 reject。 + - `client.sendRequest(request, 回调)` —— rclnodejs 发请求,响应到了走回调:清超时 → 有响应 resolve、没响应 reject。 + - 外层 try/catch:发请求这一步本身就抛错的话,也清超时 + reject。 +- 这是 `callService` 用到的底层助手。 + +--- + +## 整章回顾 + +`LocalTransport`(模式 A)和第⑩篇 `RosbridgeTransport` 实现**同一个 `RosTransport` 接口**,但底层从"WebSocket+JSON"换成"rclnodejs+DDS"。关键差异: + +| 方面 | rosbridge(⑩) | local(本篇) | +|---|---|---| +| 加载方式 | 普通 import | **`createRequire`** 加载 CJS 的 rclnodejs | +| 消息形态 | JSON 对象直接走 | 需 `toRosMessage`/`fromRosMessage` **转换类型化消息**(第27篇) | +| 实体管理 | client 内部管 | 自己用 **`EntityCache`** 缓存发布器/订阅器/客户端(第28篇) | +| 初始化 | 连 WebSocket | `rclnodejs.init()`(**static 门闩保证全进程一次**)+ 建节点 + spin | +| 自省 | 调 rosapi 服务 | 直接 `node.getTopicNamesAndTypes()` 查 DDS 图 + filter/some 过滤内部项 | +| 类型 | 有类型 | 大量 `any`(rclnodejs 可选依赖、无类型) | + +但"每个方法该返回什么、回调式如何包成 Promise、动作如何 try/finally 清理"这些**模式你全在前面学过了**——这正是"实现同一接口"带来的熟悉感。 + +**语法点回顾清单**(本章新增/巩固): +- **`createRequire(import.meta.url)`**:在 ESM 里借用 CJS 的 `require` 加载老库(最后一个预告大语法) +- `process.env.XXX`:读写环境变量;`import.meta.url` 当前文件地址 +- `static` 静态成员(类共享一份)+ 静态门闩(全进程只初始化一次) +- `Object.entries(对象)` + `for...of [key,value]`:遍历对象所有键值 +- `for (const [key] of 某Map)`:遍历 Map 只取键 +- 数组 `.some(判断)`(有无任一满足)、`.find(判断)`(找第一个)、链式 `.filter().map()`、叠加多个 `.filter` +- `new (x as any)(...)`、`typeof x.fn === "function"` 防御性检查(与无类型库打交道) +- 对称清理:connect 建什么、disconnect 逆序拆什么 + 防御判断 + 尽力而为空 catch +- 回调式 API → Promise + 超时(巩固第⑧⑯篇)、动作三回调 + `try/finally` 销毁(巩固第⑨篇) + +下一份:[`transport/local/conversion.ts` 逐行详解 →](27-local-conversion.ts.md)(本篇反复用到的"消息转换"帮手:普通对象 ↔ rclnodejs 类型化消息,含递归赋值) diff --git a/learn/code/27-local-conversion.ts.md b/learn/code/27-local-conversion.ts.md new file mode 100644 index 0000000..4428f17 --- /dev/null +++ b/learn/code/27-local-conversion.ts.md @@ -0,0 +1,267 @@ +# 逐行详解 ㉗:`transport/local/conversion.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/conversion.ts](../../extensions/openclaw-plugin/src/transport/local/conversion.ts) +> +> 推荐阅读顺序第 27 个文件。它是上一篇 `LocalTransport` 反复用到的"翻译官":把**普通 JS 对象**(上层和 `RosTransport` 接口用的 `Record`)和 **rclnodejs 的类型化消息实例**互相转换。难点不在语法,而在第一次正经接触**递归(recursion)**——因为消息会嵌套(如 `Twist.linear` 是个 `Vector3` 子消息),转换得"层层钻进去"。本篇把递归讲透。 + +--- + +## 先理解为什么需要"转换" + +- 上层(工具、接口)说的是"普通话":`{ linear: { x: 0.5 } }` 这种朴素 JS 对象。 +- rclnodejs 说的是"方言":它要的是 `new Twist()` 那种**带类型的消息实例**(有固定字段、有 setter)。 +- 两边语言不通,需要翻译官: + - **发消息时**:普通对象 → rclnodejs 实例(`toRosMessage`)。 + - **收消息时**:rclnodejs 实例 → 普通对象(`fromRosMessage`)。 +- 注释里说这"类似 rosbridge_library 的 `dict_to_msg` / `msg_to_dict`"——同样的翻译活,ROS2 各语言库都得做一遍。 + +--- + +## 第 1-22 行:模块注释 + createRequire + 加载/缓存机制 + +```typescript +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +/** Cached message classes keyed by normalized type string. */ +const typeCache = new Map(); + +function getRclnodejs(): any { + return require("rclnodejs"); +} +``` + +- `createRequire`(第26篇刚学)—— 这个文件也要加载 CJS 的 rclnodejs,所以自己也造一个 `require`。 +- `const typeCache = new Map();` —— **模块级缓存**(第⑫篇模块级变量 + 第⑥篇 Map):键是类型字符串、值是加载来的消息类。 + - **为什么缓存?** 加载一个 ROS2 消息类有开销,同一类型反复用(每次发 Twist 都要它)。缓存后第二次直接取,省事。这和第23篇能力缓存、第28篇实体缓存是同一种"算一次记住"的思路。 +- `getRclnodejs()` —— 一个小包装,返回加载来的 rclnodejs。包一层是为了"想拿 rclnodejs 都走这个口",统一。 + +--- + +## 第 24-35 行:`normalizeType`——把类型字符串规整成统一格式 + +```typescript +function normalizeType(typeStr: string): string { + const parts = typeStr.split("/"); + // Already fully qualified: "pkg/msg/Type" or "pkg/srv/Type" or "pkg/action/Type" + if (parts.length === 3) return typeStr; + // Short form: "pkg/Type" → assume msg + if (parts.length === 2) return `${parts[0]}/msg/${parts[1]}`; + return typeStr; +} +``` + +- 解决一个现实问题:ROS2 类型字符串有几种写法,rclnodejs 要的是**完整三段式** `包/msg/类型`。但上层可能传简写。这个函数把它们**规整成统一格式**。 +- `const parts = typeStr.split("/");` —— 按 `/` 切开(第25篇 `.split`)。如 `"geometry_msgs/msg/Twist"` → `["geometry_msgs", "msg", "Twist"]`。 +- `if (parts.length === 3) return typeStr;` —— **已经是三段**(完整的 `pkg/msg/Type`),原样返回。 +- `if (parts.length === 2) return \`${parts[0]}/msg/${parts[1]}\`;` —— **只有两段**(简写 `pkg/Type`),中间补个 `msg` 凑成三段(默认当消息类型)。模板字符串拼接。 +- 其余情况原样返回(兜底)。 +- **小结**:这是一个"输入容错"——不管上层写完整还是简写,都规整成 rclnodejs 认的样子。 + +--- + +## 第 37-49 行:`loadMessageClass`——加载消息类(带缓存) + +```typescript +export function loadMessageClass(typeStr: string): any { + const normalized = normalizeType(typeStr); + const cached = typeCache.get(normalized); + if (cached) return cached; + + const rclnodejs = getRclnodejs(); + const cls = rclnodejs.require(normalized); + typeCache.set(normalized, cls); + return cls; +} +``` + +- 这是第26篇反复调用的那个 `loadMessageClass`。逻辑是标准的"**缓存读取模式**": + 1. `normalizeType` 规整类型字符串。 + 2. `typeCache.get(normalized)` 查缓存,命中(`if (cached)`)就直接返回。 + 3. 没命中:`rclnodejs.require(normalized)` 真正加载这个消息类,存进缓存,再返回。 +- **这个"先查缓存、没有才算、算完存起来"的三步,和第23篇 `discoverCapabilities` 开头的缓存判断、第28篇 `getPublisher` 一模一样**——是个反复出现的通用套路,认得它即可。 + +--- + +## 第 51-62 行:`toRosMessage`——普通对象 → rclnodejs 实例 + +```typescript +export function toRosMessage(typeStr: string, obj: Record): any { + const MessageClass = loadMessageClass(typeStr); + const msg = new MessageClass(); + assignFields(msg, obj); + return msg; +} +``` + +- 三步:加载类 → `new` 一个空实例 → 把普通对象 `obj` 的字段**赋到实例上**(靠 `assignFields`,下面是重点)→ 返回。 +- 为什么不直接 `Object.assign` 一把梭?因为消息**有嵌套子消息**,得递归处理。这就引出 `assignFields`。 + +--- + +## 第 64-89 行:`assignFields`——递归赋值(本篇核心,递归首秀) + +```typescript +function assignFields(target: any, source: Record): void { + for (const [key, value] of Object.entries(source)) { + if (value === undefined || value === null) continue; + + if (typeof value === "object" && !Array.isArray(value)) { + // Nested sub-message ... + if (target[key] !== undefined && target[key] !== null && typeof target[key] === "object") { + assignFields(target[key], value as Record); + } else { + target[key] = value; + } + } else if (Array.isArray(value)) { + target[key] = value; + } else { + target[key] = value; + } + } +} +``` + +**先讲"递归"这个概念**: + +> **语法小课堂:递归(recursion)—— 函数自己调用自己。** +> 当一个问题"可以拆成同类的小问题"时,就用递归。这里:给一个消息赋值 = 给它的每个字段赋值;而某个字段如果**本身又是个子消息**,那"给子消息赋值"又是同一个问题——于是 `assignFields` **调用 `assignFields` 处理子消息**。像俄罗斯套娃,一层层钻进去,直到字段是简单值(不能再拆)为止。 + +逐行拆: + +- `for (const [key, value] of Object.entries(source))` —— 遍历普通对象的每个字段(`Object.entries`,第26篇)。 +- `if (value === undefined || value === null) continue;` —— 值是空的就**跳过**(`continue`,第⑥篇:跳过本轮、继续下一个字段)。不赋空值。 +- 然后按 `value` 的种类分三路: + 1. **是对象、且不是数组**(`typeof value === "object" && !Array.isArray(value)`,回忆第⑥篇 `typeof`;`Array.isArray` 判断是不是数组)——这说明它是个**嵌套子消息**(如 Twist 的 `linear`): + - `if (target[key] 已存在且也是对象)` —— rclnodejs new 出来的实例,子消息字段通常**已经被初始化成默认子消息实例**了。这种情况就 **`assignFields(target[key], value)` 递归**——钻进这个子消息,把 `value` 的字段赋进去。**这就是递归调用本身。** + - `else { target[key] = value; }` —— 否则(目标没有这个子字段实例)直接赋值。 + 2. **是数组**(`Array.isArray(value)`)—— 直接赋值。注释解释:数组元素可能是基本类型或子消息,但 rclnodejs 的 setter 能自己处理基本类型数组和类型化数组的强转,所以这里**先简单直接赋**(注释也坦承这对"嵌套消息数组"是简化处理——又是一个诚实标注的取舍)。 + 3. **其余**(数字、字符串、布尔等简单值)—— 直接 `target[key] = value;`。**这是递归的"底"**:简单值不能再拆,赋上就结束这一支。 +- **理解递归的关键**:每次遇到"子消息"就往里钻一层(情况1的递归),遇到"简单值/数组"就赋值收尾(情况2、3)。一个嵌套对象就这样被层层赋值填满。 + +> **举个具体例子**:`toRosMessage("Twist", { linear: { x: 0.5 }, angular: { z: 1.0 } })` +> - 外层 `assignFields(twistMsg, {linear:..., angular:...})`: +> - 字段 `linear` 是对象 → 递归 `assignFields(twistMsg.linear, {x:0.5})`: +> - 字段 `x` 是数字 → `twistMsg.linear.x = 0.5`(到底,收尾) +> - 字段 `angular` 是对象 → 递归 `assignFields(twistMsg.angular, {z:1.0})`: +> - 字段 `z` 是数字 → `twistMsg.angular.z = 1.0`(到底) +> - 钻了两层,把嵌套对象完整搬进了类型化消息。 + +--- + +## 第 91-107 行:`fromRosMessage`——rclnodejs 实例 → 普通对象(反方向) + +```typescript +export function fromRosMessage(msg: any): Record { + if (msg === null || msg === undefined) return {}; + + // Preferred path: rclnodejs provides toPlainObject() + if (typeof msg.toPlainObject === "function") { + return msg.toPlainObject() as Record; + } + + // Fallback: manual extraction + return extractFields(msg); +} +``` + +- 反方向:把 rclnodejs 消息转回普通对象。 +- `if (msg === null || msg === undefined) return {};` —— 空的就返回空对象,省得后面崩。 +- **优先用库自带的快捷方式**:`if (typeof msg.toPlainObject === "function")` —— 新版 rclnodejs 自带 `toPlainObject()` 方法,能一键转普通对象。**有就用它**(`typeof ... === "function"` 检查方法存在,第⑥篇/第26篇)。 +- **没有就降级**到手工提取 `extractFields`(下面)。这又是"优先用现成的、不行再兜底"的降级思想(第23篇)。 + +--- + +## 第 109-146 行:`extractFields`——手工递归提取(反方向的递归) + +```typescript +function extractFields(msg: any): Record { + if (msg === null || msg === undefined) return {}; + if (typeof msg !== "object") return {}; + + const result: Record = {}; + + const keys = Object.keys(msg); + for (const key of keys) { + if (key.startsWith("_")) continue; + + const value = msg[key]; + if (typeof value === "function") continue; + + if (value === null || value === undefined) { + result[key] = value; + } else if (Array.isArray(value)) { + result[key] = value.map((item: any) => + typeof item === "object" && item !== null ? extractFields(item) : item, + ); + } else if (typeof value === "object") { + if (value.constructor && value.constructor.name !== "Object") { + result[key] = extractFields(value); + } else { + result[key] = value; + } + } else { + result[key] = value; + } + } + + return result; +} +``` + +和 `assignFields` 镜像——也是递归,但方向相反(从类型化实例**抽**字段到普通对象)。 + +- 开头两个守卫:空的或非对象,返回空对象。 +- `const result = {};` —— 准备一个空普通对象装结果。 +- `const keys = Object.keys(msg);` —— **语法小课堂:`Object.keys(对象)` 取对象所有键名的数组**(`Object.entries` 的"只要键"版)。 +- 遍历每个键,先两道**跳过**: + - `if (key.startsWith("_")) continue;` —— 跳过下划线开头的(内部/私有属性,rclnodejs 实例上有些内部字段,不要)。 + - `if (typeof value === "function") continue;` —— 跳过方法(只要数据,不要函数)。 +- 然后按值的种类分路(和 `assignFields` 对称): + - **空值** → 原样记下。 + - **数组** → `value.map(...)`:对每个元素,**是对象就递归 `extractFields(item)`、否则原样**(`.map` + 三元,第⑩篇)。这处理"子消息数组"——每个子消息也要转成普通对象。 + - **对象** → 判断 `value.constructor.name !== "Object"`: + - **语法小课堂:`value.constructor.name` 看"这个对象是哪个类造出来的"。** 普通 JS 对象 `{}` 的 `constructor.name` 是 `"Object"`;而 rclnodejs 的子消息实例是别的类(如 `"Vector3"`)。 + - 如果**不是普通 Object**(是个类型化子消息)→ 递归 `extractFields(value)` 钻进去提取。 + - 是普通对象 → 直接记下。 + - **其余简单值** → 直接记下(递归的底)。 +- 返回填好的 `result`。 +- **和 `assignFields` 对照看**:一个"往类型化实例里塞"(递归点是"目标字段已是子消息实例"),一个"从类型化实例里抽"(递归点是"值是非普通对象")。两个递归方向相反、判断子消息的依据不同,但"遇嵌套就钻、遇简单值就收"的递归骨架完全一样。 + +--- + +## 第 148-153 行:`clearTypeCache`——清缓存 + +```typescript +export function clearTypeCache(): void { + typeCache.clear(); +} +``` + +- 把类型缓存清空(`Map.clear()`,第⑥篇)。第26篇 `disconnect` 时调它——断开连接就把缓存的消息类释放掉。 + +--- + +## 整章回顾 + +`conversion.ts` 是本地模式的"翻译官",三对核心能力: + +| 函数 | 方向 | 关键 | +|---|---|---| +| `loadMessageClass` | 取消息类 | 规整类型字符串 + `typeCache` 缓存(算一次记住) | +| `toRosMessage` → `assignFields` | 普通对象 → 类型化实例 | **递归**把嵌套字段层层赋进去 | +| `fromRosMessage` → `extractFields` | 类型化实例 → 普通对象 | 优先 `toPlainObject()`,否则**递归**层层抽出来 | + +最大的收获是**递归**:当数据是"可嵌套同类结构"时(消息里套子消息),用"函数调用自己处理子结构"来层层穿透——遇嵌套就钻进去、遇简单值就赋值收尾。 + +**语法点回顾清单**(本章新增/巩固): +- **递归:函数调用自己处理"同类的子结构"**(`assignFields`/`extractFields` 处理嵌套子消息) +- `Array.isArray(x)`(是否数组)、`Object.keys(对象)`(所有键名)、`value.constructor.name`(对象由哪个类造) +- 缓存读取套路:查缓存→命中即返→未命中则算+存(`loadMessageClass`,呼应第23、28篇) +- 降级:优先用库的 `toPlainObject()`,没有才手工 `extractFields`(呼应第23篇) +- `continue` 跳过不需要的字段(空值/方法/下划线私有) +- `.split("/")` + `parts.length` 判断格式、模板串补全(巩固第25篇) +- `typeof x === "function"` 判断方法是否存在(巩固第26篇) + +下一份:[`transport/local/entities.ts` 逐行详解 →](28-local-entities.ts.md)(实体缓存:把 rclnodejs 的发布器/订阅器/服务客户端缓存复用,本地模式三大件中最后一块) diff --git a/learn/code/28-local-entities.ts.md b/learn/code/28-local-entities.ts.md new file mode 100644 index 0000000..c780c75 --- /dev/null +++ b/learn/code/28-local-entities.ts.md @@ -0,0 +1,264 @@ +# 逐行详解 ㉘:`transport/local/entities.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/local/entities.ts](../../extensions/openclaw-plugin/src/transport/local/entities.ts) +> +> 推荐阅读顺序第 28 个文件,**本地模式三大件的最后一块**(前两块是第26篇传输主体、第27篇消息转换)。它叫 `EntityCache`(实体缓存):把 rclnodejs 的**发布器(publisher)、订阅器(subscription)、服务客户端(service client)** 缓存起来复用——同一个话题反复发布时,共用一个发布器,而不是每次新建。本篇没有全新语法,是前面学过的 **Map 缓存 + Set 多订阅者 + 退订清理**的综合应用,读起来会很顺。 + +--- + +## 先理解为什么要"缓存实体" + +- 在 rclnodejs 里,"发布器""订阅器""服务客户端"都是**有成本的 DDS 实体**——创建它们要占资源、要在 DDS 网络上注册。 +- 如果每次 `publish` 都新建一个发布器,发 100 次就建 100 个,浪费且容易出问题。 +- **正确做法**:同一个 `(话题, 类型)` 只建一个发布器,缓存起来,之后复用。订阅器、服务客户端同理。 +- 注释说这"和参考实现里的 `_pubs`/`_subs`/`_srv_clients` 映射是同一套路"——**用 Map 做"键→实体"的缓存**,是各 ROS2 客户端的通用模式。 + +--- + +## 第 1-13 行:模块注释 + 导入 + 两个类型别名 + +```typescript +import type { Subscription } from "../types.js"; +import { loadMessageClass, fromRosMessage } from "./conversion.js"; + +type Node = any; +type MessageHandler = (msg: Record) => void; +``` + +- 导入:`Subscription` 句柄类型(第③篇)、以及第27篇的 `loadMessageClass`(加载消息类)和 `fromRosMessage`(实例转普通对象)。 +- `type Node = any;` —— **语法小课堂:`type X = any` 给 `any` 起个别名。** + - rclnodejs 的节点对象没类型,本该到处写 `any`。这里起个别名 `Node`,**让代码读起来有意义**(看到 `node: Node` 比 `node: any` 更能表达"这是个 ROS2 节点"),虽然实际还是 `any`。这是"给无类型的东西起个有意义的名字"的小技巧——**自我文档化**。 +- `type MessageHandler = (msg: Record) => void;` —— 消息回调的函数类型(收一个普通对象、返回 void)。和第③篇接口里的同名类型一致,这里本地模块自己定义一份。 + +--- + +## 第 15-19 行:类 + 三个缓存 Map + +```typescript +export class EntityCache { + private publishers = new Map(); + private subscriptions = new Map }>(); + private serviceClients = new Map(); +``` + +- 三个私有 Map,对应三类实体: + - `publishers` —— 键是字符串、值是发布器(`any`)。 + - `serviceClients` —— 键是字符串、值是服务客户端。 + - `subscriptions` —— **值的结构更复杂**:`{ handle: any; handlers: Set }`。 + - `handle` —— 那个 rclnodejs 订阅器实体。 + - `handlers` —— 一个 **`Set`**(第⑥篇)装多个回调。 + - **为什么订阅要存一组 handlers?** 因为可能有**多处都想订同一个话题**。我们只建**一个** rclnodejs 订阅器(省资源),但把它收到的消息**分发给所有登记的回调**。所以一个订阅实体配一个回调集合。下面 `addSubscription` 会看到这个设计的妙处。 + +--- + +## 第 20-32 行:`getPublisher`——取或建发布器(缓存套路) + +```typescript + getPublisher(node: Node, topic: string, typeStr: string): any { + const key = `${topic}::${typeStr}`; + const cached = this.publishers.get(key); + if (cached) return cached; + + const MessageClass = loadMessageClass(typeStr); + const publisher = node.createPublisher(MessageClass, topic); + this.publishers.set(key, publisher); + return publisher; + } +``` + +- `const key = \`${topic}::${typeStr}\`;` —— **用"话题::类型"拼一个缓存键**。 + - 为什么把两者拼起来当键?因为唯一标识一个发布器需要话题 + 类型两者。用 `::` 连接(一个不会出现在话题名里的分隔符),拼成单个字符串当 Map 的键。这是"多字段组合键"的常见做法。 +- 接下来是**标准缓存套路**(第27篇 `loadMessageClass`、第23篇能力缓存同款): + - `const cached = this.publishers.get(key); if (cached) return cached;` —— 查缓存,有就返回。 + - 没有:`loadMessageClass(typeStr)` 加载消息类 → `node.createPublisher(...)` 真正建发布器 → `this.publishers.set(key, publisher)` 存进缓存 → 返回。 +- 第26篇 `publish` 里 `this.entityCache!.getPublisher(...)` 调的就是它。 + +--- + +## 第 34-82 行:`addSubscription`——多订阅者共享一个订阅器(本篇精华) + +```typescript + addSubscription(node: Node, topic: string, typeStr: string, handler: MessageHandler): Subscription { + const key = `${topic}::${typeStr}`; + let entry = this.subscriptions.get(key); + + if (!entry) { + const MessageClass = loadMessageClass(typeStr); + const handlers = new Set(); + + const handle = node.createSubscription( + MessageClass, + topic, + (msg: any) => { + const plain = fromRosMessage(msg); + for (const h of handlers) { + h(plain); + } + }, + ); + + entry = { handle, handlers }; + this.subscriptions.set(key, entry); + } + + entry.handlers.add(handler); + + return { + unsubscribe: () => { + entry!.handlers.delete(handler); + if (entry!.handlers.size === 0) { + try { + node.destroySubscription(entry!.handle); + } catch { + // Already destroyed + } + this.subscriptions.delete(key); + } + }, + }; + } +``` + +这是本篇最精巧的方法。它实现"**多个回调共享一个底层订阅器**"。逐段: + +### 第一步:没有就建一个订阅器(含分发逻辑) + +- `let entry = this.subscriptions.get(key);` —— 查这个话题有没有已建的订阅条目。用 `let` 因为可能要赋新值。 +- `if (!entry) { ... }` —— **第一次订这个话题**,才建底层订阅器: + - `const handlers = new Set();` —— 建一个空回调集合。 + - `node.createSubscription(类, 话题, 收到消息的回调)` —— 建**唯一**的 rclnodejs 订阅器。它的回调是关键: + ```typescript + (msg: any) => { + const plain = fromRosMessage(msg); // 类型化消息转普通对象(第27篇) + for (const h of handlers) { // 遍历所有登记的回调 + h(plain); // 挨个分发 + } + } + ``` + - **每来一条消息,转成普通对象后,分发给 `handlers` 里的每一个回调**。这就是"一个订阅器,多个订阅者"的实现核心——底层只收一份,上层每个订阅者都收到。 + - `entry = { handle, handlers };` —— 把订阅器实体和回调集合打包成条目,存进 Map。 + +### 第二步:把本次的回调加入集合 + +- `entry.handlers.add(handler);` —— 不管是新建的还是已有的条目,**都把这次的 `handler` 加进回调集合**。 + - 于是:第一次订阅 → 建订阅器 + 加回调;第二次订同话题 → 复用订阅器,只加回调。**省了重复建订阅器。** + +### 第三步:返回带"智能退订"的句柄 + +- 返回一个 `Subscription`(第③篇:带 `unsubscribe` 的对象,回忆第⑩篇也现造过这种小对象): + ```typescript + unsubscribe: () => { + entry!.handlers.delete(handler); // 先把自己这个回调移除 + if (entry!.handlers.size === 0) { // 如果没有回调了 + try { node.destroySubscription(entry!.handle); } catch { } // 才真正销毁订阅器 + this.subscriptions.delete(key); // 并从缓存移除 + } + } + ``` + - **退订逻辑很讲究**:调用者退订时,**只移除它自己那个回调**(`handlers.delete(handler)`)。 + - `if (entry!.handlers.size === 0)` —— **只有当最后一个回调也退订了**(集合空了,`.size` 是 Set 元素个数),才真正销毁底层订阅器、从缓存删掉。 + - **为什么这样?** 因为订阅器是共享的——只要还有别的订阅者在用,就不能销毁。**等所有人都退订了,才拆掉**。这叫"引用计数"式的清理:共享资源用的人都走光了才释放。 + - `entry!` 的 `!` 非空断言(第⑨篇):闭包里 `entry` 此时必然有值。 + - 销毁包 `try { } catch {}`(空 catch,第23篇):可能已被销毁,无所谓。 + +> **这个方法把"缓存复用"做到了订阅这种"一对多"场景**:底层一个订阅器、上层多个回调,建时复用、退时引用计数清理。比发布器(一对一)精巧不少,值得细品。 + +--- + +## 第 84-96 行:`getServiceClient`——取或建服务客户端 + +```typescript + getServiceClient(node: Node, service: string, typeStr: string): any { + const key = `${service}::${typeStr}`; + const cached = this.serviceClients.get(key); + if (cached) return cached; + + const ServiceClass = loadMessageClass(typeStr); + const client = node.createClient(ServiceClass, service); + this.serviceClients.set(key, client); + return client; + } +``` + +- 和 `getPublisher` **一模一样的缓存套路**,只是换成服务客户端(`node.createClient`)。理解了 `getPublisher` 这个直接跳过。 +- 第26篇 `callService` 里调的就是它。 + +--- + +## 第 98-128 行:`destroyAll`——断开时销毁所有实体 + +```typescript + destroyAll(node: Node): void { + for (const pub of this.publishers.values()) { + try { + node.destroyPublisher(pub); + } catch { + // Best-effort cleanup + } + } + this.publishers.clear(); + + for (const { handle } of this.subscriptions.values()) { + try { + node.destroySubscription(handle); + } catch { + // Best-effort cleanup + } + } + this.subscriptions.clear(); + + for (const client of this.serviceClients.values()) { + try { + node.destroyClient(client); + } catch { + // Best-effort cleanup + } + } + this.serviceClients.clear(); + } +``` + +- 第26篇 `disconnect` 调它,**一次性销毁所有缓存的实体**。三段结构相同: + - **语法小课堂:`Map.values()` 遍历 Map 的所有值。** (对照第26篇 `for (const [key] of map)` 遍历键值对、这里 `for (const x of map.values())` 只遍历值。) + - 遍历每个实体,调对应的销毁方法(`destroyPublisher`/`destroySubscription`/`destroyClient`),各包 `try { } catch {}`(**尽力而为**,第26篇同款——销毁失败不影响销毁其他的)。 + - 销毁完 `.clear()` 清空 Map。 +- 订阅那段用了解构:`for (const { handle } of this.subscriptions.values())` —— **对象解构 `{ handle }`**(第⑪篇):每个值是 `{ handle, handlers }`,这里只取出 `handle`(要销毁的是订阅器实体,回调集合不用管)。 +- **整段是"批量清理"的范例**:遍历每类实体逐个销毁、各自容错、最后清空。和第26篇 `disconnect` 的对称清理精神一致。 + +--- + +## 整章回顾 + +`EntityCache` 用三个 Map 缓存 rclnodejs 的三类实体,避免重复创建: + +| 实体 | 方法 | 特点 | +|---|---|---| +| 发布器 | `getPublisher` | 一对一缓存(标准"查→建→存"套路) | +| 服务客户端 | `getServiceClient` | 同上 | +| 订阅器 | `addSubscription` | **一对多**:一个底层订阅器 + 一组回调,分发给所有人;引用计数式退订 | +| 全部 | `destroyAll` | 断开时批量销毁、尽力而为 | + +最值得记住的是 `addSubscription` 的"**多订阅者共享一个订阅器 + 引用计数清理**"——只在第一个订阅者来时建实体、最后一个走时拆实体,中间复用。 + +**语法点回顾清单**(本章新增/巩固): +- `type X = any` 给 any 起有意义的别名(自我文档化) +- 组合键 `` `${a}::${b}` ``:多字段拼成单个 Map 键 +- 缓存套路"查→命中即返→未命中则建+存"(巩固第23、27篇) +- 一对多分发:`Set<回调>` + 收到消息 `for...of` 挨个调用 +- 引用计数式退订:`handlers.delete(自己)` → `if (size===0)` 才销毁底层实体 +- `Map.values()` 遍历值、`for (const { handle } of ...)` 遍历 + 对象解构 +- 批量清理:逐个 `try{销毁}catch{}` 尽力而为 + `.clear()`(巩固第26篇对称清理) + +--- + +## 🎉 第七部分之模式 A(本地 DDS)讲完! + +本地模式三大件齐活: + +> **传输主体 `LocalTransport`(26)** + **消息转换 `conversion`(27)** + **实体缓存 `entities`(28)**。 + +它们合起来,让 OpenClaw 直接跑在机器人上、经 rclnodejs 用本地 DDS 通信——和 rosbridge 模式实现同一个 `RosTransport` 接口,但底层完全不同。你现在已经看过**两种**传输实现了,对"同一接口、不同实现"有了切身体会。 + +下一批进入**模式 C(WebRTC)**——它目前是**存根(stub)**(回忆 CLAUDE.md:未实现、带 `// TODO` 和正确类型签名)。所以那几篇会侧重"**接口/信令协议怎么定义、存根怎么写**",而非真实逻辑。先从信令协议的类型定义(第29篇)开始。 + +下一份:[`transport/webrtc/signaling-types.ts` 逐行详解 →](29-webrtc-signaling-types.ts.md)(WebRTC 信令消息的类型定义——回到第③⑤篇那种"纯类型契约"的味道) diff --git a/learn/code/29-webrtc-signaling-types.ts.md b/learn/code/29-webrtc-signaling-types.ts.md new file mode 100644 index 0000000..43c6158 --- /dev/null +++ b/learn/code/29-webrtc-signaling-types.ts.md @@ -0,0 +1,178 @@ +# 逐行详解 ㉙:`transport/webrtc/signaling-types.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/signaling-types.ts](../../extensions/openclaw-plugin/src/transport/webrtc/signaling-types.ts) +> +> 推荐阅读顺序第 29 个文件,**第七部分模式 C(WebRTC)的开篇**。它和第③⑤篇一样,是一个**纯类型文件**——只定义"信令服务器协议"的各种消息形状,没有任何执行逻辑。所谓"信令(signaling)",是 WebRTC 建立连接前、两端通过一台中间服务器**交换"如何直连"的信息**的过程。本篇把这些消息的类型一一列出。由于全是熟悉的 `interface`/`extends`,读起来很轻松,正好作为模式 C 的热身。 + +--- + +## 先理解 WebRTC 和"信令" + +- **WebRTC** 是浏览器/设备之间**点对点(peer-to-peer)直连**传输音视频和数据的技术。模式 C 用它的"数据通道(data channel)"来传 ROS2 消息——适合"OpenClaw 在云端、机器人在远处"的部署。 +- 但两端要直连,得先**互相知道对方的网络地址、加密参数**等。这个"交换连接信息"的过程叫**信令**,需要一台双方都能访问的**信令服务器**居中转达。 +- 信令交换的内容主要是: + - **SDP**(会话描述):描述"我支持什么编解码、我的媒体参数"等,分 **offer(提议)** 和 **answer(应答)** 两种。 + - **ICE candidate(候选地址)**:双方可能的网络路径,互相试探哪条能通。 +- 一旦信令完成、直连建好,之后的数据就**不再经过信令服务器**,而是点对点直传。 +- **本篇就是给这些信令消息定义类型**。你不需要精通 WebRTC,只要知道"这些消息是连接握手时交换的"即可。 + +--- + +## 第 1-6 行:模块注释 + +```typescript +/** + * Type definitions for the WebRTC signaling server protocol. + * + * These match the message format used by the webrtc-py signaling server + * (see mock_client.py and mock_robot.py for reference implementations). + */ +``` + +- 说明:这些类型**对齐一个 Python 写的信令服务器(webrtc-py)的消息格式**。 +- **这点很重要**:这些 `interface` 不是凭空设计的,而是**照着一个已存在的服务器协议"描摹"出来的**——所以字段名都是 Python 风格的下划线(`user_id`、`robot_id`),而非 TS 习惯的驼峰。**当 TS 代码要和外部系统(这里是 Python 服务器)通信时,类型就得照对方的格式来**(回忆第⑤篇 rosbridge 协议也是下划线,同理)。 + +--- + +## 第 8-31 行:REST API 的请求/响应类型 + +```typescript +// --- REST API --- + +export interface ConnectRequest { + user_id: string; + robot_id: string; + robot_key: string; +} + +export interface ConnectResponse { + session: { session_id: string }; + room_id: string; +} + +export interface DisconnectResponse { + status: string; +} + +export interface RobotInfo { + robot_id: string; + status: string; + capabilities?: string[]; +} + +export type DiscoverResponse = RobotInfo[]; +``` + +- 连接 WebRTC 前,先通过普通 **REST API**(HTTP 请求)做几件事:发现机器人、请求连接、断开。这几个类型就是那些请求/响应的形状。 +- `ConnectRequest` —— 请求连接时要发的:用户 id、机器人 id、机器人密钥(`robot_key`,身份凭证)。 +- `ConnectResponse` —— 服务器的回复: + - `session: { session_id: string }` —— **内联的嵌套对象类型**(回忆第①篇:字段的类型可以直接写成 `{ ... }`)。表示 `session` 字段是个含 `session_id` 的对象。 + - `room_id` —— 分配的"房间" id(双方在同一房间里交换信令)。 +- `DisconnectResponse` —— 断开的回复,就一个状态字符串。 +- `RobotInfo` —— 一个机器人的信息:id、状态、可选的能力列表(`capabilities?: string[]`,第①篇可选 + 数组)。 +- `export type DiscoverResponse = RobotInfo[];` —— **用 `type` 给"机器人信息数组"起个别名**(回忆第①篇 `type`)。"发现机器人"接口返回的就是一串 `RobotInfo`。 + +--- + +## 第 33-39 行:所有信令消息的"基底"形状 + +```typescript +// --- WebSocket signaling messages --- + +/** Base shape for all signaling messages. */ +export interface SignalingMessage { + type: string; + [key: string]: unknown; +} +``` + +- 接下来是走 **WebSocket** 的信令消息(连接握手期间双向交换)。 +- `SignalingMessage` 是所有信令消息的**共同基底**: + - `type: string` —— 每条消息都有个 `type` 字段标明它是什么(和第⑤篇 rosbridge 的 `op` 同理——判别字段)。 + - `[key: string]: unknown;` —— **语法小课堂:索引签名(index signature)。** + - `[key: string]: unknown` 读作"**除了上面明确写的字段,还允许任意多个字符串键、值为 unknown 的字段**"。 + - 为什么要它?因为不同信令消息字段各不相同,这个基底类型只能确定"一定有 `type`",其余字段开放。具体子类型(下面)再补上各自的字段。 + - 这是"我知道有 `type`,其余字段先放开"的写法。 + +--- + +## 第 41-92 行:客户端→服务器 的消息(`extends` + 字面量收窄) + +挑几个代表讲,其余同理: + +```typescript +// Client → Server + +export interface JoinRoomMessage extends SignalingMessage { + type: "join_room"; + room_id: string; + peer_id: string; + peer_type: "frontend" | "robot"; + session_id: string; +} +``` + +- `interface JoinRoomMessage extends SignalingMessage` —— **继承基底**(回忆第⑤篇 `interface extends`):在"有 `type` + 任意字段"的基础上,补上这条消息特有的字段。 +- `type: "join_room";` —— **关键:把 `type` 从基底的 `string` 收窄成字面量 `"join_room"`**(回忆第⑤篇子接口收紧字段)。这样 TS 一看到 `type === "join_room"` 就知道这条消息是 `JoinRoomMessage`,能安全访问它的 `room_id` 等字段(判别联合的威力,第③篇)。 +- `peer_type: "frontend" | "robot"` —— 字面量联合:对等端只能是"前端"或"机器人"二选一。 +- 其余字段是这条消息要带的数据。 + +后面一连串都是同款套路(`extends SignalingMessage` + `type` 收窄成各自字面量),按用途列举: + +- `RobotConnectMessage`(`"robot_connect"`)—— 机器人上线。 +- `SessionAcceptedMessage`(`"session_accepted"`)—— 会话被接受。 +- `OfferMessage`(`"offer"`)—— **SDP 提议**,`data: { type: string; sdp: string }`(嵌套对象装 SDP 内容)。 +- `AnswerMessage`(`"answer"`)—— **SDP 应答**,结构类似 offer。 +- `IceCandidateMessage`(`"ice_candidate"`)—— **ICE 候选地址**: + ```typescript + data: { + candidate: string; + sdpMid: string | null; + sdpMLineIndex: number | null; + }; + ``` + - 注意这里字段是**驼峰**(`sdpMid`、`sdpMLineIndex`)——因为这部分对齐的是 WebRTC 标准(标准本身用驼峰),而非那个 Python 服务器的下划线习惯。**混用说明"字段名永远跟着它对接的那个协议走"**,不强求统一。 + - `string | null` / `number | null` —— 这些字段**可能是值、也可能明确为 null**(WebRTC 标准里它们可空)。注意是 `| null`(明确的空)而非 `?`(可省略),细微区别:`?` 是"可以没有这个键",`| null` 是"键在、但值是 null"。 +- `HeartbeatMessage`(`"heartbeat"`)—— 心跳,带 `timestamp: number`(保活用,第31篇会看到怎么定时发)。 + +--- + +## 第 94-130 行:服务器→客户端 的消息 + +```typescript +// Server → Client + +export interface PeerJoinedMessage extends SignalingMessage { + type: "peer_joined"; + peer_id: string; + peer_type: "frontend" | "robot"; +} +// ... +``` + +- 同样是 `extends SignalingMessage` + `type` 收窄的一组,方向相反(服务器发给客户端): + - `PeerJoinedMessage`(`"peer_joined"`)/ `PeerLeftMessage`(`"peer_left"`)—— 有对等端加入/离开房间。 + - `SessionInvitationMessage`(`"session_invitation"`)—— 会话邀请。 + - `SessionEndedMessage`(`"session_ended"`)—— 会话结束,`reason?: string`(可选原因)。 + - `HeartbeatRequestMessage`(`"heartbeat_request"`)—— 服务器要求客户端回个心跳。 + - `ErrorMessage`(`"error"`)—— 错误消息,带 `message` + 可选 `code`。 +- 这些都是声明式的类型,无逻辑,按需查阅即可。 + +--- + +## 整章回顾 + +- `signaling-types.ts` 是模式 C 的"协议字典":定义 WebRTC 信令服务器收发的所有消息形状,照着一个 Python 服务器(webrtc-py)的格式描摹。 +- 结构上分两类:**REST API**(连接前的 HTTP 请求/响应)和 **WebSocket 信令消息**(握手期间双向交换,含 SDP offer/answer、ICE candidate、心跳等)。 +- 所有信令消息都 `extends SignalingMessage`(基底有 `type` + 索引签名),各自把 `type` 收窄成字面量——又是第③⑤篇"判别联合"那套,换了个场景。 +- 字段命名**跟着对接的协议走**:对 Python 服务器用下划线、对 WebRTC 标准用驼峰,不强求统一。 + +**语法点回顾清单**(本章新增/巩固): +- 索引签名 `[key: string]: unknown`("除已知字段外,允许任意字符串键") +- `interface X extends 基底` + 把 `type` 从 `string` 收窄成字面量(判别消息身份)(巩固第⑤篇) +- 内联嵌套对象类型 `session: { session_id: string }`(巩固第①篇) +- `type 别名 = T[]`(给数组类型起名,如 `DiscoverResponse`) +- `string | null`(值可为 null)vs `?`(键可省略)的区别 +- 字段命名跟随对接协议(下划线 vs 驼峰混用)(巩固第⑤篇协议命名) + +下一份:[`transport/webrtc/transport.ts` 逐行详解 →](30-webrtc-transport.ts.md)(模式 C 的传输主体——全项目最长的文件,但**它其实是完整实现、不是存根**,我们会澄清这点并抓主干讲) diff --git a/learn/code/30-webrtc-transport.ts.md b/learn/code/30-webrtc-transport.ts.md new file mode 100644 index 0000000..5e1c361 --- /dev/null +++ b/learn/code/30-webrtc-transport.ts.md @@ -0,0 +1,408 @@ +# 逐行详解 ㉚:`transport/webrtc/transport.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/transport.ts](../../extensions/openclaw-plugin/src/transport/webrtc/transport.ts) +> +> 推荐阅读顺序第 30 个文件,**全项目最长的源文件(516 行)**。它是**模式 C** 的传输主体:通过 WebRTC 数据通道把 ROS2 消息传给远程机器人。 +> +> **先澄清一件事**:本系列总览(README)和 CLAUDE.md 早先把模式 C 标为"存根(stub)",但**真读这份代码会发现它其实是相当完整的实现**——它真的用了 `node-datachannel`、`ws` 这些库,有完整的"REST 请求 → 信令握手 → SDP/ICE 交换 → 数据通道开通 → 收发消息"流程。**这是一个文档与代码不一致的地方**,本篇按"它是完整实现"来讲,并在结尾说明这个出入。(注意到这种"文档说存根、代码却完整"的对照,正是我们一路在练的读码眼光。) +> +> 好消息:尽管最长,但它**实现的还是第④篇那个 `RosTransport` 接口**,而且**数据通道上跑的正是第⑤篇那套 rosbridge JSON 协议**——所以接口方法你已在第⑩篇见过、协议你已在第⑤⑥篇见过。真正新的只有"WebRTC 连接怎么建"那一段。我们抓这条主干,熟悉的部分快速带过。 + +--- + +## 先把整体流程记在心里 + +这个类扮演"应答方(answering peer)",连接分几步(类注释也列了): + +``` +1. REST: POST /connect → 拿到 session_id、room_id +2. 开信令 WebSocket → 加入房间 (join_room) +3. 等机器人发来 SDP offer(提议) +4. 建 PeerConnection、设置远端描述、生成 answer(应答) +5. 交换 ICE candidate(候选网络路径) +6. 数据通道打开 → 之后就在上面跑 rosbridge JSON +``` + +**关键洞察**:第 6 步之后,这个类就和第⑩篇 `RosbridgeTransport` 几乎一样了——都是"组一条 rosbridge JSON、发出去、按 id 接响应"。区别只在"消息走 WebRTC 数据通道"而非"走 WebSocket"。所以**前 5 步(建连接)是新内容,第 6 步之后(收发消息)全是旧知识**。 + +--- + +## 第 1-25 行:导入 + +```typescript +import { PeerConnection, DescriptionType, type DataChannel, type RtcConfig } from "node-datachannel"; +import type { RosTransport } from "../transport.js"; +import type { /* 一大批接口类型 */ } from "../types.js"; +import { SignalingClient } from "./signaling-client.js"; +import type { SignalingMessage, OfferMessage, IceCandidateMessage, PeerJoinedMessage } from "./signaling-types.js"; +``` + +- `import { PeerConnection, DescriptionType, type DataChannel, type RtcConfig } from "node-datachannel";` + - **`node-datachannel`** 是个真实的 WebRTC 库(提供 `PeerConnection` 等)。**这是"完整实现"的铁证**——存根不会真导入一个 WebRTC 库。 + - **语法小课堂:一条 import 里混用值导入和 `type` 导入。** `PeerConnection`、`DescriptionType` 是**值**(要 `new`/使用),`type DataChannel`、`type RtcConfig` 前面加了 `type` 表示**只当类型**。可以在同一对花括号里混写——`import { 值, type 类型 } from ...`。比分两行写紧凑。 +- `SignalingClient`(值)—— 第31篇那个信令客户端,本类用它做 REST + WebSocket。 +- 一批信令消息类型(第29篇)。 + +--- + +## 第 27-45 行:选项类型 + 待处理请求类型 + +```typescript +export interface WebRTCTransportOptions { + signalingUrl: string; + apiUrl: string; + robotId: string; + robotKey: string; + iceServers?: RTCIceServerConfig[]; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} +``` + +- `WebRTCTransportOptions` —— 连接所需:信令 WebSocket 地址、REST API 地址、目标机器人 id、机器人密钥、可选的 ICE 服务器(STUN/TURN,帮助穿透网络)。 +- `PendingRequest` —— **一个"待兑现请求"的形状**:存 `resolve`、`reject`(Promise 的两个开关,第⑧篇)和 `timer`(超时定时器句柄)。 + - **语法小课堂:`ReturnType`。** 回忆第⑥篇 `ReturnType`(取函数返回值的类型)+ `typeof 值`。`setTimeout` 的返回值是个定时器句柄,类型在不同环境(浏览器/Node)下不同,与其写死,不如用 `ReturnType` **让 TS 自己推**——"无论它返回啥类型,我这字段就是那个类型"。是处理"跨环境类型"的稳妥写法。 + - 这个 `PendingRequest` 就是第⑥篇 `client.ts` 里 `registerPending` 存的东西,本类自己实现了一份等价物。 + +--- + +## 第 58-76 行:类 + 一堆状态字段 + 构造 + +```typescript +export class WebRTCTransport implements RosTransport { + private options: WebRTCTransportOptions; + private signaling: SignalingClient; + private pc: PeerConnection | null = null; + private dataChannel: DataChannel | null = null; + private status: ConnectionStatus = "disconnected"; + private connectionHandlers = new Set(); + private topicHandlers = new Map>(); + private pendingRequests = new Map(); + private idCounter = 0; + private sessionId: string | null = null; + private roomId: string | null = null; + private peerId: string | null = null; + private robotPeerId: string | null = null; + + constructor(options: WebRTCTransportOptions) { + this.options = options; + this.signaling = new SignalingClient(options.apiUrl, options.signalingUrl); + } +``` + +- `implements RosTransport` —— 老规矩,实现那 13 个方法(第④/⑩篇)。 +- 字段分几类,对照第⑥篇 client + 第26篇 local 就眼熟: + - `pc`(PeerConnection)、`dataChannel` —— WebRTC 的连接和数据通道,连上才有,先 null。 + - `status` / `connectionHandlers` —— 状态 + 连接回调集合(和第26篇 local 一样自己管理)。 + - `topicHandlers = Map>` —— **话题 → 一组回调**。和第28篇 entities 的订阅设计同理:一个话题可能多个订阅者,用 `Set` 装回调。 + - `pendingRequests = Map` —— **id → 待处理请求**。这就是第⑥篇 `registerPending` 那套"按 id 配对响应"的本类版。 + - `idCounter` —— 自增计数器,生成唯一 id(第⑥篇 `nextId` 同款)。 + - `sessionId`/`roomId`/`peerId`/`robotPeerId` —— 信令握手过程中拿到的各种 id。 +- 构造函数:存选项、建一个 `SignalingClient`(第31篇)。 + +--- + +## 第 78-119 行:`connect`——六步建连接(本篇新内容的核心) + +```typescript + async connect(): Promise { + if (this.status === "connected") return; + this.setStatus("connecting"); + + try { + // Step 1: Request connection via REST API + const userId = `frontend_${Date.now()}`; + const connectRes = await this.signaling.requestConnection(this.options.robotId, { + user_id: userId, + robot_id: this.options.robotId, + robot_key: this.options.robotKey, + }); + this.sessionId = connectRes.session.session_id; + this.roomId = connectRes.room_id; + this.peerId = userId; + + // Step 2: Connect signaling WebSocket + await this.signaling.connectWs(); + + // Step 3: Set up message handler before joining room + const connected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("WebRTC connection timed out (30s)")); + }, 30_000); + + this.signaling.onMessage((msg: SignalingMessage) => { + this.handleSignalingMessage(msg, resolve, reject, timeout); + }); + }); + + // Step 4: Join room + this.signaling.joinRoom(this.roomId, userId, "frontend", this.sessionId); + + // Step 5: Wait for data channel to be established + await connected; + this.setStatus("connected"); + } catch (err) { + this.setStatus("disconnected"); + this.cleanup(); + throw err; + } + } +``` + +注释清楚标了步骤,逐步看: + +- **Step 1(REST 请求连接)**: + - `const userId = \`frontend_${Date.now()}\`;` —— 用时间戳拼一个唯一用户 id(`Date.now()`,第23篇)。 + - `await this.signaling.requestConnection(...)` —— 调信令客户端的 REST 接口(第31篇),传机器人 id、密钥。拿回 `session_id`、`room_id`,存进字段。 +- **Step 2(开信令 WebSocket)**:`await this.signaling.connectWs();`(第31篇)。 +- **Step 3(建一个"连接成功"的 Promise + 装消息处理器)**——**这段最巧妙**: + - `const connected = new Promise((resolve, reject) => {...})` —— 建一个 Promise 代表"整个 WebRTC 连接是否建成"。 + - 里面设 30 秒超时(超时 reject)。 + - `this.signaling.onMessage((msg) => this.handleSignalingMessage(msg, resolve, reject, timeout));` —— **登记信令消息处理器,并把 `resolve`/`reject`/`timeout` 一路传进去**。 + - 为什么传进去?因为"连接成功"这个事件,要等到后面收到 offer、建好数据通道、通道 `onOpen` 触发时才算数(在 `handleOffer` 里)。届时调 `resolve` 让这个 Promise 兑现。**这是把"分散在多个回调里才完成的事"汇聚成一个 Promise 的手法**(第⑧篇思想的进阶版:resolve 被传到很深的回调里才调用)。 +- **Step 4(加入房间)**:`this.signaling.joinRoom(...)` 发 join_room 消息(第29、31篇)。这会触发服务器和机器人那边的后续动作(机器人发来 offer)。 +- **Step 5(等数据通道建好)**:`await connected;` —— **卡在这里等那个 Promise**。直到数据通道 `onOpen`(在后面回调链里)调了 `resolve`,才往下 `setStatus("connected")`。 +- `catch` —— 任何一步失败:退回断开、`cleanup()` 清理、抛错。 + +> **这段的精髓**:`connect` 启动握手、然后 `await` 一个 Promise;而让这个 Promise 兑现的 `resolve`,被深埋在"信令消息处理 → 处理 offer → 数据通道 onOpen"这条回调链的末端。**一条 await 等着一串异步回调最终汇合。** + +--- + +## 第 121-147 行:`disconnect` / `getStatus` / `onConnection` + +```typescript + async disconnect(): Promise { + this.cleanup(); + if (this.sessionId) { + try { + await this.signaling.requestDisconnect(this.options.robotId); + } catch { + // Best-effort disconnect notification + } + this.sessionId = null; + } + this.signaling.close(); + this.setStatus("disconnected"); + } +``` + +- `disconnect` —— `cleanup()`(关数据通道和 pc,见下)→ 尽力通知服务器断开(REST,失败无所谓,空 catch)→ 关信令 WebSocket → 标记断开。 +- `getStatus` / `onConnection` —— 和第26篇 local **完全一样**(返回状态字段 / 登记回调返回退订函数)。跳过。 + +--- + +## 第 149-189 行:`publish` / `subscribe`(开始变回熟悉的 rosbridge 味道) + +```typescript + publish(options: PublishOptions): void { + this.sendOverDataChannel({ + op: "publish", + topic: options.topic, + type: options.type, + msg: options.msg, + }); + } +``` + +- **`publish`**:注意它组的是 `{ op: "publish", topic, type, msg }`——**这就是第⑤篇的 rosbridge `PublishMessage` 格式!** 只是不再用 `client.send`(WebSocket),而是 `sendOverDataChannel`(WebRTC 数据通道,见下面私有助手)。 +- **`subscribe`**(第158行): + - 先把回调存进 `topicHandlers`(话题→回调集合,和第28篇 entities 一样的"一对多"管理)。 + - `this.sendOverDataChannel({ op: "subscribe", id: this.nextId("sub"), topic, type, throttle_rate, queue_length });` —— 发 rosbridge 的 `subscribe` 命令(第⑤篇格式)。 + - 返回 `Subscription` 句柄,`unsubscribe` 里:从集合移除本回调,**若集合空了**才真正发 `unsubscribe` 命令并从 Map 删除(**引用计数式退订,和第28篇一模一样的逻辑**)。 +- **看出来了吗**:从这里开始,本类就是"**第⑩篇适配器的逻辑 + 第28篇的订阅管理**"——组 rosbridge JSON、管多订阅者。WebRTC 只是换了条"管子"。 + +--- + +## 第 191-257 行:`callService` / `sendActionGoal` / `cancelActionGoal` + +```typescript + async callService(options: ServiceCallOptions): Promise { + const id = this.nextId("service"); + const response = await new Promise>((resolve, reject) => { + this.registerPending(id, (v) => resolve(v as Record), reject, 30_000); + this.sendOverDataChannel({ + op: "call_service", + id, + service: options.service, + args: options.args, + type: options.type, + }); + }); + return { + result: response.result as boolean, + values: response.values as Record | undefined, + }; + } +``` + +- **`callService`**:这和第⑧篇 `services.ts` 的 `callService` **几乎逐行对应**! + - `nextId` 拿号 → `new Promise` + `registerPending`(先张接球网)→ `sendOverDataChannel` 发请求 → 返回承诺。 + - **完全是第⑧篇那套"拿号→登记回调→发请求→按 id 兑现"五步骨架**,只是发送通道是数据通道、`registerPending` 是本类自己实现的版本。第⑧篇吃透了这里秒懂。 +- **`sendActionGoal`**(第212行):和第⑨篇 `actions.ts` 对应——`registerPending` 等最终结果(120 秒超时)、若有 `onFeedback` 就挂一个 `__action_feedback__` 频道接进度、用 `try/finally` 清理进度频道。**第⑨篇的翻版**。 +- **`cancelActionGoal`**(第251行):发个 `cancel_action_goal` 消息,最简单(第⑨篇同款)。 + +> 三个方法全是第⑧⑨篇知识的"换管子"重演。**这就是"实现同一接口、复用同一协议"的回报:核心逻辑你早就会了。** + +--- + +## 第 259-299 行:三个自省方法 + +- `listTopics` / `listServices` —— 调 `callService("/rosapi/topics"...)`,再用 `.map` 把并列数组缝成信息数组。**和第⑩篇适配器的 `listTopics` 一字不差**(连 `result.values?.["topics"] as string[] ?? []` 都一样)。 +- `listActions` —— 还是那个"找 `/_action/feedback` 后缀话题反推"的启发式(第⑩、26篇见过两遍了)。注释也说 `Same heuristic as RosbridgeTransport`。 +- 完全复用,跳过。 + +--- + +## 第 301-341 行:基础私有助手 + +```typescript + private nextId(prefix = "rosclaw"): string { + return `${prefix}_${++this.idCounter}`; + } + + private sendOverDataChannel(msg: Record): void { + if (!this.dataChannel) { + throw new Error("Data channel is not open"); + } + this.dataChannel.sendMessage(JSON.stringify(msg)); + } + + private registerPending(id, resolve, reject, timeoutMs): void { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pendingRequests.set(id, { resolve, reject, timer }); + } + + private resolvePending(id: string, result: unknown): void { + const pending = this.pendingRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(id); + pending.resolve(result); + } + } +``` + +- `nextId` —— `++this.idCounter` 前置自增(第⑥篇)拼唯一 id。第⑥篇 client 同款。 +- `sendOverDataChannel` —— **本类发消息的唯一出口**:没数据通道就抛错,否则 `JSON.stringify` 后用 `dataChannel.sendMessage` 发出去。**这就是替代了第⑥篇 `client.send` 的那根"管子"**——其余代码都通过它发,所以前面 publish/callService 等才能那么像 rosbridge。 +- `registerPending` / `resolvePending` —— **本类自己实现的第⑥篇那套"按 id 配对"**:登记时设超时 + 存进 `pendingRequests`;响应到了 `resolvePending` 清超时、移除、调 resolve。逻辑和第⑥篇 client 一致。 + +--- + +## 第 343-393 行:`handleDataChannelMessage`——收到数据通道消息的路由 + +```typescript + private handleDataChannelMessage(data: string): void { + let msg: Record; + try { + msg = JSON.parse(data) as Record; + } catch { + return; + } + const op = msg.op as string | undefined; + if (!op) return; + + switch (op) { + case "publish": { /* 找 topicHandlers,分发给所有回调 */ } + case "service_response": { /* 按 id resolvePending */ } + case "action_result": { /* 按 id resolvePending */ } + case "action_feedback": { /* 找 __action_feedback__ 频道分发 */ } + } + } +``` + +- 这是数据通道收到消息时的**总路由**,**和第⑥篇 `client.ts` 的 `handleMessage` 是同一个角色、同一套 `switch(op)` 逻辑**: + - `JSON.parse` 解析(解析失败就忽略,空 catch)。 + - 取 `op` 判别消息种类。 + - `publish` → 找话题的回调集合,挨个分发(订阅消息到了)。 + - `service_response` / `action_result` → 按 `id` 调 `resolvePending`(兑现等待的请求)。 + - `action_feedback` → 找 `__action_feedback__` 频道分发进度。 +- **第⑥篇那个"最难"的 `handleMessage` 你吃透了,这里就是它的 WebRTC 版**——结构完全平行。 + +--- + +## 第 395-493 行:信令与 WebRTC 握手(真正的新内容) + +这两个方法是本篇唯一"前面没见过"的部分——WebRTC 怎么建连接。**它细节多、涉及 WebRTC 专业概念,对初学者只需把握"在干什么",不必逐字抠 API。** 抓大意: + +### `handleSignalingMessage`(第396行)——处理握手期的信令 + +```typescript +switch (msg.type) { + case "peer_joined": { /* 记下机器人的 peer_id */ } + case "offer": { /* 机器人发来 SDP 提议 → 调 handleOffer */ } + case "ice_candidate": { /* 对方的网络候选 → pc.addRemoteCandidate */ } + case "error": { /* 出错 → reject */ } +} +``` +- 按信令消息的 `type`(第29篇那些类型)分别处理:对方加入、收到 offer(转给 `handleOffer`)、收到 ICE 候选(喂给 pc)、出错。 +- `msg as OfferMessage` 等——把基底 `SignalingMessage` 断言成具体子类型再用其字段(判别后收窄的手动版)。 + +### `handleOffer`(第434行)——收到提议、建连接、回应答 + +这是 WebRTC 握手的核心一步,逐段大意: +- **准备 ICE 服务器**:`this.options.iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }]` —— 没配就用 Google 的公共 STUN 服务器兜底(`??`)。`.map` 把配置转成库要的字符串格式。 +- `this.pc = new PeerConnection("rosclaw-frontend", rtcConfig);` —— **建 WebRTC 连接对象**。 +- 然后挂几个回调(这是 WebRTC 编程的典型形态——事件驱动): + - `pc.onLocalCandidate(...)` —— 我方产生网络候选时,通过信令发给机器人(`sendIceCandidate`,第31篇)。 + - `pc.onStateChange(...)` —— 连接状态变化;失败/关闭就标记断开、拒绝所有待处理请求。 + - `pc.onDataChannel((dc) => {...})` —— **机器人开数据通道时触发**,这是关键: + - 存下 `dataChannel = dc`。 + - `dc.onOpen(() => { clearTimeout(timeout); onConnected(); })` —— **通道开通!清超时、调 `onConnected`(就是 connect 里那个 resolve)**——至此 `connect` 的 `await connected` 才放行。**这就是前面说的"resolve 深埋在回调链末端"的落点。** + - `dc.onMessage(...)` —— 收到消息 → 转字符串 → `handleDataChannelMessage`(上面那个路由)。 + - `dc.onClosed(...)` —— 通道关闭 → 清理、拒绝待处理请求。 +- **设远端描述 + 生成应答**:`pc.setRemoteDescription(offer.data.sdp, ...)` 设入机器人的 offer,`pc.localDescription()` 拿到我方 answer,`sendAnswer(...)`(第31篇)发回去。 +- **大意小结**:收到机器人的"提议"→ 建连接对象、挂好各种事件回调 → 把提议设进去、生成"应答"发回 → 之后双方交换 ICE 候选、直到数据通道 `onOpen`,连接告成。**WebRTC 就是这样一套"事件回调驱动的握手",细节交给库,我们只需理解流程。** + +--- + +## 第 495-515 行:清理助手 + +```typescript + private cleanup(): void { + this.rejectAllPending(new Error("Transport disconnected")); + if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; } + if (this.pc) { this.pc.close(); this.pc = null; } + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } +``` + +- `cleanup` —— 拒绝所有还在等的请求、关数据通道、关 pc。和第26篇 local 的对称清理一个精神。 +- `rejectAllPending` —— **断开时把所有"还在等响应"的请求统统 reject**,免得它们永远悬着(调用方的 await 会收到错误而非永久卡住)。 + - `for (const [, pending] of this.pendingRequests)` —— **语法小课堂:解构里跳过某项 `[, pending]`。** Map 遍历每项是 `[键, 值]`,这里**第一个位置空着**(逗号前什么都不写)表示"键我不要",只取值 `pending`。(对照第26篇 `for (const [action] of ...)` 只取键,这里相反,只取值。) + - 挨个清超时 + reject,最后清空 Map。 +- 这是"连接断了,别让等待者吊死"的负责任收尾。 + +--- + +## 整章回顾 + +- `WebRTCTransport`(模式 C)实现第④篇 `RosTransport` 接口,**在 WebRTC 数据通道上跑第⑤篇的 rosbridge JSON 协议**。 +- **它的代码可清晰分成两半**: + - **新内容(前 5 步建连接)**:REST 请求 → 信令 WebSocket → 收 offer → 建 PeerConnection、回 answer → 交换 ICE → 数据通道开通。这是 WebRTC 特有的"事件回调驱动握手",抓流程即可。 + - **旧知识(连上之后收发)**:`publish`/`subscribe`/`callService`/`sendActionGoal`、`registerPending`/`resolvePending`、`handleDataChannelMessage` 的 `switch(op)`——**全是第⑤⑥⑧⑨⑩篇的翻版**,只把"WebSocket 这根管子"换成"数据通道这根管子"(`sendOverDataChannel`)。 +- **关于"存根"**:README/CLAUDE.md 标它为存根,但代码是完整实现(真用 `node-datachannel`/`ws`、握手逻辑齐全)。**这是文档滞后于代码的一处不一致**——读码时以代码为准。 + +**语法点回顾清单**(本章新增/巩固): +- 一条 import 混用值与 `type`:`import { 值, type 类型 } from ...` +- `ReturnType`:让 TS 推断"定时器句柄"的跨环境类型(巩固第⑥篇) +- 把 `resolve`/`reject` 深传进多层回调,让一个 `await` 等一串异步回调汇合(进阶第⑧篇) +- 解构跳过某项 `for (const [, pending] of map)`(只取值,对照第26篇只取键) +- 断开时 `rejectAllPending`:把所有悬而未决的请求 reject,避免调用方吊死 +- 复用:rosbridge JSON 协议(第⑤篇)、按 id 配对(第⑥篇)、请求-响应/动作骨架(第⑧⑨篇)、订阅一对多(第28篇)——全部重演 +- 读码核对文档:发现"标称存根、实为完整实现"的不一致 + +下一份:[`transport/webrtc/signaling-client.ts` 逐行详解 →](31-webrtc-signaling-client.ts.md)(信令客户端:封装 REST 调用 + 信令 WebSocket 生命周期 + 心跳——本篇大量用到的 `this.signaling.xxx` 在那里实现) diff --git a/learn/code/31-webrtc-signaling-client.ts.md b/learn/code/31-webrtc-signaling-client.ts.md new file mode 100644 index 0000000..0bebade --- /dev/null +++ b/learn/code/31-webrtc-signaling-client.ts.md @@ -0,0 +1,290 @@ +# 逐行详解 ㉛:`transport/webrtc/signaling-client.ts` + +> 对应源文件:[extensions/openclaw-plugin/src/transport/webrtc/signaling-client.ts](../../extensions/openclaw-plugin/src/transport/webrtc/signaling-client.ts) +> +> 推荐阅读顺序第 31 个文件,**模式 C 的最后一块、也是整个传输层的收尾**。它是 `SignalingClient`——上一篇 `WebRTCTransport` 反复调用的 `this.signaling.xxx` 都在这里实现。职责很清晰:**封装和信令服务器打交道的两件事——REST API 调用(发现/连接/断开)和信令 WebSocket 的生命周期(连接、收发、心跳)。** 新东西有两个:浏览器/Node 通用的 **`fetch`(发 HTTP 请求)** 和 **`satisfies` 运算符**。同样,这也是真实实现(用了 `ws` 库),不是存根。 + +--- + +## 先理解它和 transport 的分工 + +- `WebRTCTransport`(第30篇)管"WebRTC 连接本身"(PeerConnection、数据通道、SDP/ICE)。 +- `SignalingClient`(本篇)管"**和信令服务器通信**"——即握手期间,借服务器中转消息那部分: + - **REST 部分**:连接前的 HTTP 请求(发现有哪些机器人、请求建立会话、通知断开)。 + - **WebSocket 部分**:握手期间双向交换信令消息(join_room、offer、answer、ICE、心跳)。 +- 第30篇那些 `this.signaling.requestConnection(...)`、`connectWs()`、`joinRoom(...)`、`sendAnswer(...)` 的实现,全在本篇。 + +--- + +## 第 1-14 行:导入 + 回调类型别名 + +```typescript +import WebSocket from "ws"; +import type { /* 一批信令类型 */ } from "./signaling-types.js"; + +export type SignalingMessageHandler = (msg: SignalingMessage) => void; +``` + +- `import WebSocket from "ws";` —— **默认导入** `ws` 库(Node 的 WebSocket 实现;浏览器自带 WebSocket,Node 要装这个库)。注意是默认导入(不带花括号,回忆第13篇 `export default`)。**真导入 `ws` 库 = 真实现,非存根。** +- 一批信令消息类型(第29篇)。 +- `export type SignalingMessageHandler = (msg: SignalingMessage) => void;` —— 给"信令消息回调"的函数类型起个别名(收一条信令消息、返回 void)。 + +--- + +## 第 16-33 行:类 + 字段 + 构造(含 URL 规整) + +```typescript +export class SignalingClient { + private ws: WebSocket | null = null; + private apiUrl: string; + private signalingUrl: string; + private messageHandler: SignalingMessageHandler | null = null; + private heartbeatInterval: ReturnType | null = null; + + constructor(apiUrl: string, signalingUrl: string) { + // Normalize: strip trailing slashes + this.apiUrl = apiUrl.replace(/\/+$/, ""); + this.signalingUrl = signalingUrl.replace(/\/+$/, ""); + } +``` + +- 字段:`ws`(WebSocket 实例,连上才有)、两个 URL、消息回调、心跳定时器句柄。 + - `heartbeatInterval: ReturnType | null` —— 心跳定时器句柄类型(`ReturnType`,和第30篇 `setTimeout` 同理;注意这里是 `setInterval` 反复触发的那种)。 +- 构造函数做了件细致事——**规整 URL**: + - `apiUrl.replace(/\/+$/, "")` —— **语法小课堂:`字符串.replace(正则, 替换串)`。** 把匹配正则的部分替换掉。 + - `/\/+$/` 是个正则:`\/` 是转义的斜杠 `/`(因为 `/` 在正则里有特殊含义,要转义),`+` 是"一个或多个",`$` 是"字符串结尾"。合起来 `/\/+$/` = **"结尾处的一个或多个斜杠"**。 + - 替换成 `""`(空)= **去掉 URL 末尾的斜杠**。 + - 为什么?因为用户配的 URL 可能写成 `https://host/` 或 `https://host`,末尾有没有斜杠不统一。规整掉,后面拼路径(`${apiUrl}/api/...`)才不会出现 `host//api` 这种双斜杠。**这是"输入规整"的好习惯**(呼应第27篇 `normalizeType` 规整类型字符串)。 + +--- + +## 第 35-66 行:REST API 三方法(`fetch` 登场) + +```typescript + async discoverRobots(): Promise { + const res = await fetch(`${this.apiUrl}/api/robots/`); + if (!res.ok) { + throw new Error(`Discovery failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; + } +``` + +- **语法小课堂:`fetch(url, 选项?)` —— 发 HTTP 请求的标准函数。** + - `fetch` 是现代 JS(浏览器和新版 Node 都内置)发网络请求的方法。`await fetch(...)` 拿到一个**响应对象 `res`**。 + - `res.ok` —— 布尔,请求是否成功(HTTP 状态码 200–299)。失败就抛错,带上 `res.status`(状态码如 404)和 `res.statusText`(状态文字)。 + - `res.json()` —— **把响应体解析成 JSON**(也是异步的,返回 Promise)。`as Promise` 断言成我们期望的类型(第29篇)。 +- 这是最简单的一个:GET 请求"发现机器人"列表。 + +```typescript + async requestConnection(robotId: string, request: ConnectRequest): Promise { + const res = await fetch(`${this.apiUrl}/api/robots/${robotId}/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Connection request failed: ${res.status} — ${body}`); + } + return res.json() as Promise; + } +``` + +- `requestConnection` —— **带 body 的 POST 请求**(第30篇 connect 第 1 步调的就是它)。`fetch` 第二参数是选项对象: + - `method: "POST"` —— 请求方法。 + - `headers: { "Content-Type": "application/json" }` —— 告诉服务器"我发的是 JSON"。 + - `body: JSON.stringify(request)` —— 请求体:把 `request` 对象转成 JSON 字符串(第⑥篇 `JSON.stringify`)。 + - 失败时 `await res.text()` 取出错误响应文本,拼进报错(比只给状态码信息更全)。 +- `requestDisconnect`(第58行)—— POST 通知断开,最简单。 +- **三个方法是 `fetch` 的标准用法范例**:GET、带 body 的 POST、检查 `res.ok`、`res.json()`/`res.text()` 取响应。Web 开发里天天用。 + +--- + +## 第 68-117 行:`connectWs`——连信令 WebSocket(Promise 包装事件) + +```typescript + async connectWs(): Promise { + return new Promise((resolve, reject) => { + const wsUrl = `${this.signalingUrl}/ws`; + const timeout = setTimeout(() => { + reject(new Error(`Signaling WebSocket connection to ${wsUrl} timed out`)); + }, 10_000); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + clearTimeout(timeout); + this.startHeartbeat(); + resolve(); + }; + + this.ws.onmessage = (event) => { + const data = typeof event.data === "string" ? event.data : event.data.toString(); + let msg: SignalingMessage; + try { + msg = JSON.parse(data) as SignalingMessage; + } catch { + return; + } + if (msg.type === "heartbeat_request") { + this.send({ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage); + return; + } + if (this.messageHandler) { + this.messageHandler(msg); + } + }; + + this.ws.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Signaling WebSocket error connecting to ${wsUrl}`)); + }; + + this.ws.onclose = () => { + clearTimeout(timeout); + this.stopHeartbeat(); + this.ws = null; + }; + }); + } +``` + +- **整体是"把 WebSocket 的事件回调包装成一个 Promise"**——和第⑥篇 client 的 `connect`、第⑧篇思想一脉相承: + - `return new Promise((resolve, reject) => {...})` —— 返回一个承诺,"连上了就 resolve、出错/超时就 reject"。 + - 10 秒超时(`setTimeout` → reject)。 + - `this.ws = new WebSocket(wsUrl);` —— 建 WebSocket,连 `信令URL/ws`。 +- 然后挂四个事件回调(WebSocket 的标准事件): + - **`onopen`(连上了)**:清超时 → `startHeartbeat()` 开始心跳(见下)→ `resolve()` 兑现承诺。 + - **`onmessage`(收到消息)**: + - `const data = typeof event.data === "string" ? event.data : event.data.toString();` —— 消息数据可能是字符串或二进制,统一转成字符串(三元 + `typeof`,第⑥篇)。 + - `JSON.parse` 解析(失败就忽略,空 catch)。 + - **自动回心跳**:`if (msg.type === "heartbeat_request")` —— 服务器要心跳,就**立刻自动回一条 heartbeat**(`this.send(...)`),然后 `return` 不再往下。这是"保活"机制的一半(另一半是下面主动定时发)。 + - **语法小课堂:`satisfies` 运算符。** `{ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage` —— `satisfies` 检查"这个对象**符合** `HeartbeatMessage` 类型吗",符合就放行、不符合编译报错。 + - **它和 `as` 的区别**:`as` 是"别管了,我说它是这类型"(断言,可能骗过 TS);`satisfies` 是"请帮我**验证**它确实是这类型,但保留它本来的精确类型"。`satisfies` 更安全——它不放松检查,只确认你写对了。这里用它确保心跳消息字段写对(`type`/`timestamp` 齐全且类型对)。 + - 否则把消息转交给登记的 `messageHandler`(第30篇 `connect` 里登记的那个 `handleSignalingMessage`)。 + - **`onerror`(出错)**:清超时 → reject。 + - **`onclose`(关闭)**:清超时 → 停心跳 → 置 null。 +- 这就是第30篇 connect 第 2 步 `await this.signaling.connectWs()` 等的东西。 + +--- + +## 第 119-179 行:消息收发 + join/answer/ice 几个发送方法 + +```typescript + onMessage(handler: SignalingMessageHandler): void { + this.messageHandler = handler; + } + + send(message: SignalingMessage): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("Signaling WebSocket is not connected"); + } + this.ws.send(JSON.stringify(message)); + } +``` + +- `onMessage` —— 存下消息处理器(第30篇调它登记 `handleSignalingMessage`)。 +- `send` —— **发送出口**:先确认 WebSocket 还开着(`this.ws.readyState !== WebSocket.OPEN` 检查连接状态),否则抛错;然后 `JSON.stringify` 后发出。所有发送都走它。 + +```typescript + joinRoom(roomId, peerId, peerType, sessionId): void { + this.send({ + type: "join_room", + room_id: roomId, + peer_id: peerId, + peer_type: peerType, + session_id: sessionId, + } satisfies JoinRoomMessage); + } +``` + +- `joinRoom` / `sendAnswer` / `sendIceCandidate` —— 三个**便捷发送方法**:各自组一条对应的信令消息(第29篇的 `JoinRoomMessage`/`AnswerMessage`/`IceCandidateMessage`),用 `satisfies` 确认格式正确,再 `send` 出去。 + - 这就是第30篇 `joinRoom(...)`、`sendAnswer(...)`、`sendIceCandidate(...)` 的实现。 + - 每个都用 `satisfies 对应类型` 把关——保证发出去的消息符合协议。**这是 `satisfies` 的典型用途:构造要发给外部的结构化消息时,让 TS 帮你核对字段。** + +--- + +## 第 167-179 行:`close` + `isConnected`(getter) + +```typescript + close(): void { + this.stopHeartbeat(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + get isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } +``` + +- `close` —— 停心跳 + 关 WebSocket + 置 null。 +- `get isConnected()` —— **语法小课堂:`get`(取值器 / getter)。** + - 前面加 `get` 的方法是个"**伪装成属性的方法**"。用的时候写 `client.isConnected`(**不加括号**,像访问属性),它背后会执行这个函数返回值。 + - 这里 `isConnected` 计算"ws 存在且处于 OPEN 状态"返回布尔。调用方写 `signaling.isConnected` 就像读一个属性,但其实是实时算的。 + - 用 getter 是为了"对外看像个只读属性、内部其实有计算逻辑"。比写成 `isConnected()` 方法更自然(语义上它是个状态值,不是动作)。 + +--- + +## 第 181-196 行:心跳的开/停 + +```typescript + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.send({ type: "heartbeat", timestamp: Date.now() } satisfies HeartbeatMessage); + } + }, 15_000); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } +``` + +- **语法小课堂:`setInterval(回调, 毫秒)` / `clearInterval(句柄)`。** + - 回忆第②篇 `setTimeout`(**只触发一次**)。`setInterval` 是它的"反复版"——**每隔指定毫秒就触发一次回调,直到 `clearInterval` 停掉**。 + - 这里每 15 秒发一条心跳(`heartbeat`),告诉服务器"我还在线"。这是**主动心跳**(和上面 `onmessage` 里"被请求才回"的被动心跳互补,双保险保活)。 +- `startHeartbeat` —— 先 `stopHeartbeat()`(防止重复开多个定时器)再开新的。每次触发前检查连接还开着才发。 +- `stopHeartbeat` —— 有定时器就 `clearInterval` 停掉并置 null。 +- **保活机制小结**:长连接容易被中间设备掐断,靠定期心跳维持。本类两路心跳——主动每 15 秒发、被收到 `heartbeat_request` 时回——确保连接不被误判为死连接。 + +--- + +## 整章回顾 + +- `SignalingClient` 封装了"和信令服务器通信"的全部细节,给 `WebRTCTransport` 当底层: + - **REST**(`fetch`):`discoverRobots`/`requestConnection`/`requestDisconnect`——连接前的 HTTP 交互。 + - **WebSocket**:`connectWs`(Promise 包装事件 + 超时)、`send`/`onMessage`、`joinRoom`/`sendAnswer`/`sendIceCandidate`(便捷发送,`satisfies` 把关)。 + - **心跳**:`setInterval` 主动发 + 收到请求被动回,双路保活。 +- 它和第⑥篇 `RosbridgeClient` 角色相似(都是"管连接、收发底层消息"的客户端),只是一个走 rosbridge WebSocket、一个走信令服务器。 + +**语法点回顾清单**(本章新增/巩固): +- `fetch(url, {method, headers, body})`:发 HTTP 请求;`res.ok`/`res.status`/`res.json()`/`res.text()` +- **`satisfies 类型`**:验证对象符合某类型但保留精确类型(比 `as` 安全——核对而非强断言) +- `get 属性()`:取值器(getter),用时不加括号、像读属性但实时计算 +- `setInterval`/`clearInterval`:反复定时触发(vs `setTimeout` 一次);心跳保活 +- `字符串.replace(/\/+$/, "")`:正则去掉结尾斜杠(URL 规整,呼应第27篇) +- Promise 包装 WebSocket 事件(`onopen`→resolve / `onerror`→reject + 超时)(巩固第⑥⑧篇) +- `ws.readyState === WebSocket.OPEN` 判断连接是否就绪 + +--- + +## 🎉🎉 传输层全部讲完!三种模式集齐! + +到这里,**第七部分(模式 A / C)讲完,整个传输层的三种实现全部读完**: + +> **模式 B rosbridge(⑤–⑩)** + **模式 A 本地 DDS(26–28)** + **模式 C WebRTC(29–31)**,外加它们共同的接口(③④)和工厂(⑪)。 + +三种实现各走不同的"管子"(WebSocket / 本地 DDS / WebRTC 数据通道),但**都实现同一个 `RosTransport` 接口**——这就是第④篇那个"接口抽象"的全部价值:上层(工具、命令、服务)完全不用管底层用哪种,换模式只换工厂里 `new` 哪个类。你现在对"面向接口编程、多种实现可替换"有了**三个活生生的例子**支撑,这是非常扎实的收获。 + +**整个 `@rosclaw/openclaw-plugin` 插件——31 个源文件——已全部逐行读完!** 只剩最后一篇:第二个扩展 `openclaw-canvas`(实时仪表盘)。 + +下一份:[`openclaw-canvas` 逐行详解 →](32-openclaw-canvas.md)(第八部分、收官篇:另一个独立扩展,实时可视化机器人状态) diff --git a/learn/code/32-openclaw-canvas.md b/learn/code/32-openclaw-canvas.md new file mode 100644 index 0000000..3f46e2c --- /dev/null +++ b/learn/code/32-openclaw-canvas.md @@ -0,0 +1,140 @@ +# 逐行详解 ㉜:`openclaw-canvas`(收官篇) + +> 对应源文件:[extensions/openclaw-canvas/index.ts](../../extensions/openclaw-canvas/index.ts)(仅 14 行)及该扩展的配置文件 +> +> 推荐阅读顺序第 32 个、**最后一个文件**。它是项目的**第二个扩展** `@rosclaw/openclaw-canvas`——一个"实时机器人仪表盘"。和前面 31 个文件不同,**它的代码现在是真正的"存根(placeholder)"**:`index.ts` 只有 14 行、什么实质功能都没做。所以本篇换个讲法:**代码部分几分钟讲完,重点带你读懂它的"设计意图"**——一个还没动工、但已经把蓝图画清楚的扩展长什么样,以及"存根/占位"在工程里的正当价值。读完它,整个 32 篇逐行系列就圆满了。 + +--- + +## 先理解它和主插件的关系 + +- 前面 31 篇讲的都是 `@rosclaw/openclaw-plugin`(主插件,负责"控制机器人")。 +- 本篇的 `@rosclaw/openclaw-canvas` 是**另一个独立扩展**(monorepo 里 `extensions/` 下并列的第二个包,回忆 CLAUDE.md 的结构),目标是"**可视化机器人状态**"——实时仪表盘:电量、摄像头画面、地图、遥控摇杆等。 +- 关键分工(README 讲得很清楚): + - **聊天界面**(WhatsApp/Telegram 等,主插件管)适合"下命令、看文字回复"。 + - **仪表盘**(本扩展管)适合"看连续更新的画面和数据、做高频遥控"——这些塞不进聊天消息。 + - 用消息 App 的普通用户看不到仪表盘;只有用 **OpenClaw 原生 App**(macOS/iOS/Android)的操作员能看到。 + +--- + +## 代码部分:14 行的存根(很快) + +### `index.ts` 全文 + +```typescript +/** + * OpenClaw Canvas extension for real-time robot dashboard. + * Phase 3 — placeholder only. + */ + +interface OpenClawPluginAPI { + log: { + info(msg: string, ...args: unknown[]): void; + }; +} + +export function register(api: OpenClawPluginAPI): void { + api.log.info("RosClaw Canvas extension loaded (Phase 3 — not yet implemented)"); +} +``` + +逐段(语法全是老朋友,快速过): + +- 注释直说:`Phase 3 — placeholder only`(第三阶段,仅占位)。**诚实标注"还没做"**。 +- `interface OpenClawPluginAPI { log: { info(...): void } }` —— **一个极简的局部接口**。 + - 注意它**只声明了用得到的那一点点**:一个 `log.info` 方法。对比主插件第①篇那个完整的 `OpenClawPluginApi`(有 logger、registerTool、registerService……几十项),这里**只要 `log.info` 就只声明 `log.info`**。 + - **语法小课堂:`...args: unknown[]`(剩余参数 rest parameters)。** 回忆第25篇解构里的 `...rest`,这是它的**函数参数版**:`info(msg: string, ...args: unknown[])` 表示"第一个参数是 `msg`,**后面任意多个参数**都收进数组 `args`"。这样 `info("x")`、`info("x", 1, 2)` 都能调。常见于日志类函数(消息 + 若干附加值)。 + - **为什么自己声明而不 import 主插件的接口?** 因为这是**独立扩展**,刻意不依赖主插件——只按自己需要描述"宿主至少得给我一个能打日志的 api"。存根阶段,够用就行。 +- `export function register(api): void { api.log.info("...not yet implemented"); }` —— **唯一的函数**:被宿主加载时,只打一行"已加载,但第三阶段尚未实现"的日志。**仅此而已。** + +### 配置文件(一眼带过) + +- `openclaw.plugin.json` —— 扩展清单:`id`、`name`、`version`、`description`、`entry`(入口指向 `./dist/index.js`)。宿主靠它认识这个扩展。 +- `package.json` —— npm 包定义:注意 `"dependencies": {}`(**空依赖**——存根什么库都不需要)、有 `build`/`typecheck` 脚本。`"type": "module"`(纯 ESM,和全项目一致,回忆 CLAUDE.md)。 + +**代码部分到此讲完**——确实就这么点。下面是本篇真正有价值的部分。 + +--- + +## 重点:读懂一份"还没写、但想清楚了"的设计 + +`index.ts` 是空的,但它的 [README](../../extensions/openclaw-canvas/README.md) 写得非常充实——**这本身就是一种值得学习的工程实践**:动手写代码前,先把"要做什么、怎么做、依赖什么"想透并写下来。我们顺着它的蓝图,认识几个新概念(你不必掌握细节,理解"是什么、为什么"即可)。 + +### 1. Canvas 是什么——原生 App 里的一块 WebView 面板 + +- "Canvas(画布)"不是网页、不是浏览器标签,而是**嵌在 OpenClaw 原生 App 里的一块 WebView 面板**(WebView = App 内嵌的网页渲染区)。 +- 三个平台各自的承载方式不同(macOS 用 WKWebView、iOS 用 SwiftUI+WebView、Android 用 WebView),但对扩展来说都是"一块能显示 UI 的面板"。 +- AI 通过一个内置的 `canvas` 工具控制它(`canvas.present` 显示、`canvas.navigate` 跳转、`canvas.a2ui.push` 推送界面……)——**回忆第14篇"工具"概念**:这又是一组工具,只不过是 OpenClaw 内置的、操作画布的。 + +### 2. A2UI 是什么——"用 JSON 描述界面"的协议 + +- 仪表盘的界面不是手写 HTML,而是用 **A2UI**(Agent-to-UI,一个开放标准)来描述。 +- **核心思想:agent 用 JSON 描述"界面长什么样、数据是什么",客户端负责把它渲染成原生组件。** 三类关键消息: + - `surfaceUpdate` —— **描述组件树**(界面由哪些卡片、文字、按钮组成)。注意它用"扁平邻接表"——每个组件一个 id,靠 id 互相引用拼成树。 + - `dataModelUpdate` —— **推送数据**(如电量 "87%")。绑定了数据的组件会**自动重新渲染**(响应式)。 + - `userAction` —— **用户操作回传**(点了急停按钮、拖了滑块)。agent 收到后处理、再推新界面。 +- **这和我们学过的东西呼应**:第15篇工具参数用 TypeBox 描述"形状"、第23篇用文本注入给 AI——都是"**用数据/声明来表达东西,而非写死的代码**"的思路。A2UI 是这个思路在"界面"上的体现:**声明式 UI**。 + +### 3. 数据怎么流——它不直接连 ROS2 + +README 的架构图点出一个重要设计:**这个扩展不自己连机器人**。它没有自己的传输层(不像主插件有第④篇那套),而是: +- **路径一(agent 中介)**:让 AI 调主插件的工具(`ros2_subscribe`、`ros2_camera_snapshot`……第15–21篇那些)拿数据,格式化成 A2UI 推给画布。简单,但每次都过一遍 AI,有延迟。 +- **路径二(网关方法)**:主插件注册一些"网关方法",画布前端直接调它们拿实时数据,**绕过 AI**(适合高频更新,如遥控)。但这要求主插件先实现网关方法(README 说"Issue #10,目前搁置")。 +- **设计上的复用意识**:仪表盘不重复造"连机器人"的轮子,而是**站在主插件已有能力之上**。这正是前面反复见到的分层/复用精神(第④篇接口、第17篇"工具薄薄一层")。 + +--- + +## 为什么留一个"存根"是合理的? + +新手可能疑惑:"没实现,写它干嘛?"——存根/占位在工程里有正当价值: + +1. **占好位置、定好边界**:`register` 的签名、扩展的 id/入口、在 monorepo 的位置都先确定了。将来填实现时,骨架是现成的,不用动结构。 +2. **写下设计、对齐共识**:那份详尽的 README 把"要做什么、怎么渲染、数据怎么流、依赖什么前置条件"都讲清了。**团队能据此讨论、评审,甚至发现依赖未就绪(如网关方法 Issue #10)——在写代码之前就暴露问题**,成本最低。 +3. **诚实标注状态**:注释、README、日志都明说"Phase 3 — not yet implemented"。**不假装、不误导**——回忆 CLAUDE.md 的规范:"未实现的代码用 `// TODO` 标记,并附正确的类型签名"。这个扩展就是这条规范的整体体现:**类型签名对(`register(api)` 形状正确)、功能诚实标注未做。** +4. **可独立演进**:作为独立扩展,它不拖累主插件——主插件 31 个文件已是完整可用的产品,仪表盘可以将来从容补上。 + +> **一句话**:好的"存根"不是偷懒,而是"**先把蓝图和骨架立好,把功能诚实地标为待办**"。它和"假装实现了、其实是空壳骗人"有本质区别——前者透明、后者欺骗。 + +--- + +## 整章回顾 + +- `@rosclaw/openclaw-canvas` 是项目第二个扩展,目标是原生 App 里的**实时机器人仪表盘**,目前是**诚实的存根**(`index.ts` 仅 14 行,只打一行日志)。 +- 代码层面只有两点新语法:**极简局部接口**(只声明用得到的 `log.info`)和**剩余参数 `...args: unknown[]`**。 +- 真正的价值在它的**设计蓝图**:Canvas(App 内 WebView 面板)+ A2UI(用 JSON 声明式描述界面)+ 不自己连 ROS2(复用主插件能力,agent 中介或网关方法两条数据路径)。 +- 它示范了"存根"的正当用途:**占位定界、写下设计、诚实标注、独立演进**——是工程实践的一部分,不是糊弄。 + +**语法点回顾清单**(本章新增/巩固): +- 剩余参数 `function f(a, ...args: unknown[])`(收集任意多个尾部参数,函数版的 `...rest`,呼应第25篇) +- 极简局部接口:只声明真正用到的成员(vs 第①篇完整 `OpenClawPluginApi`) +- 存根/占位的工程价值:正确类型签名 + 诚实标注未实现(呼应 CLAUDE.md `// TODO` 规范) + +--- + +# 🎉🎉🎉 全系列完成!32 篇逐行详解到此收官 + +你从第 01 篇 `plugin-api.ts` 一路读到这第 32 篇,**把整个 RosClaw 项目——主插件 31 个源文件 + 第二个扩展——逐行、逐符号读完了。** 回头看走过的路: + +| 部分 | 篇目 | 你掌握了 | +|---|---|---| +| 一·契约与配置 | 01–02 | 接口/类型基础、Zod 配置校验 | +| 二·传输抽象 | 03–04 | `RosTransport` 统一接口、判别联合 | +| 三·rosbridge | 05–10 | WebSocket 协议、按 id 配对、请求-响应/动作骨架、适配器 | +| 四·装配启动 | 11–13 | 工厂、服务生命周期、插件入口 | +| 五·工具层 | 14–21 | AI 的 7 个"动作按钮"、TypeBox、竞速 Promise | +| 六·钩子与命令 | 22–25 | 三种交互方式、安全校验、能力注入、`/estop`·`/transport` | +| 七·另两种传输 | 26–31 | 本地 DDS(rclnodejs/递归/缓存)、WebRTC(信令/fetch/satisfies) | +| 八·第二扩展 | 32 | 存根的设计意图、A2UI 声明式 UI | + +**更重要的是,你不再是"新手中的新手"了。** 一路上「语法小课堂」累计讲了上百个 TypeScript 语法点(都收录在 [README 的语法学习地图](README.md)里可随时反查),从 `interface`、`async/await`,到 `Promise.all`、递归、`createRequire`、`satisfies`、类型守卫……这些是读**任何** TypeScript 项目都用得上的通用功底,而不只限于这一个项目。 + +而且你练出了几样比语法更值钱的东西: +- **看懂"分层"**:接口在中间、实现可替换(三种传输模式是最好的例子)。 +- **识别"模式"**:缓存(查→建→存)、Promise 包装回调、按 id 配对、引用计数清理……同一套路在不同文件反复出现,你已能一眼认出。 +- **对照"现状 vs 文档"**:第21、30、31 篇我们都发现并诚实指出了代码与文档的不一致——这种不盲信、以代码为准的眼光,是真正读懂代码的标志。 + +接下来建议你:挑一个最感兴趣的方法,**自己改一行、跑一下**,看会发生什么——"读"过之后,"动手改"是把知识焊牢的最后一步。 + +← 返回 [逐行详解系列 · 总览](README.md) | ← 返回 [核心代码导读(鸟瞰版)](../04-核心代码导读.md) + +**恭喜你读完整个系列。** 🚀 diff --git a/learn/code/README.md b/learn/code/README.md new file mode 100644 index 0000000..19095d4 --- /dev/null +++ b/learn/code/README.md @@ -0,0 +1,270 @@ +# 逐行详解系列 · 总览 + +> 这是为**新手中的新手**准备的"逐行 + 逐语法"代码详解系列,是对 [`learn/04-核心代码导读.md`](../04-核心代码导读.md) 的深度展开。 +> +> - 顶层 `04` 章是"鸟瞰"——讲设计意图、用简化代码。 +> - 本系列 `code/` 是"显微镜"——读**真实源码**,**每一行、每一个符号**都拆开讲,第一次出现的 TypeScript 语法都用「语法小课堂」单独补课。 +> +> **从哪开始**:🧭 **强烈建议先读 [00 · 一条命令的旅程](00-一条命令的旅程.md)**——它是全系列的"龙头",跟着一条命令(「向前走 1 米」)走过 8 个文件,精确到"哪个文件、哪个函数、第几行",并链到下面每一篇。有了这条主线打底,再按下表顺序逐篇读就不会迷路。 +> +> **怎么用**:按下表顺序一篇篇读。每篇结尾有「语法点回顾清单」,可当字典反查。讲解顺序遵循**依赖关系**(先读被依赖的底层,再读依赖它的上层),而不是文件名顺序。 + +--- + +## 流程主线(先读我) + +| # | 文档 | 内容 | 状态 | +|---|---|---|---| +| 00 | [一条命令的旅程](00-一条命令的旅程.md) | 跟踪一条命令端到端穿过 8 个文件,每跳标注 `文件:函数:行号` 并链入对应逐行详解——**全系列的导航主线** | ✅ | + +--- + +## 进度一览 + +图例:✅ 已完成 · ⬜ 待完成 · "行数"=源码行数(难度参考) + +### 第一部分:契约与配置(基础,无执行逻辑) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 01 | [plugin-api.ts](01-plugin-api.ts.md) | `src/plugin-api.ts` | ✅ | 138 | +| 02 | [config.ts](02-config.ts.md) | `src/config.ts` | ✅ | 73 | + +### 第二部分:传输层抽象(接口与数据结构) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 03 | [transport-types.ts](03-transport-types.ts.md) | `src/transport/types.ts` | ✅ | 119 | +| 04 | [transport.ts](04-transport.ts.md) | `src/transport/transport.ts` | ✅ | 70 | + +### 第三部分:rosbridge 实现(模式 B,唯一完整实现) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 05 | [rosbridge-types.ts](05-rosbridge-types.ts.md) | `src/transport/rosbridge/types.ts` | ✅ | 119 | +| 06 | [rosbridge-client.ts](06-rosbridge-client.ts.md) 🔥最难 | `src/transport/rosbridge/client.ts` | ✅ | 317 | +| 07 | [rosbridge-topics.ts](07-rosbridge-topics.ts.md) | `src/transport/rosbridge/topics.ts` | ✅ | 60 | +| 08 | [rosbridge-services.ts](08-rosbridge-services.ts.md) | `src/transport/rosbridge/services.ts` | ✅ | 41 | +| 09 | [rosbridge-actions.ts](09-rosbridge-actions.ts.md) | `src/transport/rosbridge/actions.ts` | ✅ | 81 | +| 10 | [rosbridge-adapter.ts](10-rosbridge-adapter.ts.md) | `src/transport/rosbridge/adapter.ts` | ✅ | 148 | + +### 第四部分:装配与启动 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 11 | [transport-factory.ts](11-transport-factory.ts.md) | `src/transport/factory.ts` | ✅ | 42 | +| 12 | [service.ts](12-service.ts.md) | `src/service.ts` | ✅ | 114 | +| 13 | [index.ts](13-index.ts.md) | `src/index.ts` | ✅ | 40 | + +### 第五部分:工具层(注册给 AI 的 8 个工具) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 14 | [tools-index.ts(含通用结构)](14-tools-index.ts.md) | `src/tools/index.ts` | ✅ | 21 | +| 15 | [ros2-publish.ts](15-ros2-publish.ts.md) | `src/tools/ros2-publish.ts` | ✅ | 39 | +| 16 | [ros2-subscribe.ts](16-ros2-subscribe.ts.md) | `src/tools/ros2-subscribe.ts` | ✅ | 50 | +| 17 | [ros2-service.ts](17-ros2-service.ts.md) | `src/tools/ros2-service.ts` | ✅ | 43 | +| 18 | [ros2-action.ts](18-ros2-action.ts.md) | `src/tools/ros2-action.ts` | ✅ | 47 | +| 19 | [ros2-param.ts](19-ros2-param.ts.md) | `src/tools/ros2-param.ts` | ✅ | 83 | +| 20 | [ros2-introspect.ts](20-ros2-introspect.ts.md) | `src/tools/ros2-introspect.ts` | ✅ | 29 | +| 21 | [ros2-camera.ts](21-ros2-camera.ts.md) | `src/tools/ros2-camera.ts` | ✅ | 53 | + +### 第六部分:钩子与命令 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 22 | [safety-validator.ts](22-safety-validator.ts.md) | `src/safety/validator.ts` | ✅ | 43 | +| 23 | [robot-context.ts](23-robot-context.ts.md) | `src/context/robot-context.ts` | ✅ | 192 | +| 24 | [commands-estop.ts](24-commands-estop.ts.md) | `src/commands/estop.ts` | ✅ | 40 | +| 25 | [commands-transport.ts](25-commands-transport.ts.md) | `src/commands/transport.ts` | ✅ | 171 | + +### 第七部分:另外两种传输模式(模式 A / C) + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 26 | [local-transport.ts](26-local-transport.ts.md) | `src/transport/local/transport.ts` | ✅ | 375 | +| 27 | [local-conversion.ts](27-local-conversion.ts.md) | `src/transport/local/conversion.ts` | ✅ | 153 | +| 28 | [local-entities.ts](28-local-entities.ts.md) | `src/transport/local/entities.ts` | ✅ | 129 | +| 29 | [webrtc-signaling-types.ts](29-webrtc-signaling-types.ts.md) | `src/transport/webrtc/signaling-types.ts` | ✅ | 130 | +| 30 | [webrtc-transport.ts](30-webrtc-transport.ts.md) | `src/transport/webrtc/transport.ts` | ✅ | 516 | +| 31 | [webrtc-signaling-client.ts](31-webrtc-signaling-client.ts.md) | `src/transport/webrtc/signaling-client.ts` | ✅ | 196 | + +### 第八部分:第二个扩展 + +| # | 文档 | 源文件 | 状态 | 行数 | +|---|---|---|---|---| +| 32 | [openclaw-canvas](32-openclaw-canvas.md) | `extensions/openclaw-canvas/` | ✅ | 14 | + +--- + +## 当前进度 + +- **全部完成:32 / 32 篇 🎉**(整个 RosClaw 项目——主插件 31 个源文件 + 第二个扩展 canvas——已逐行读完)。 +- 收官篇 [32 · openclaw-canvas](32-openclaw-canvas.md) 末尾有全系列总回顾与下一步建议。 +- 想反查某个语法点,用下面的「语法学习地图」。 + +### 交付批次记录 + +| 批次 | 包含文档 | 状态 | +|---|---|---| +| 第一批 | 01, 02 | ✅ | +| 第二批(合并入第一批前的确认) | — | — | +| 第三批 | 03, 04 | ✅ | +| 第四批 | 05, 06 | ✅ | +| 第五批 | 07, 08, 09 | ✅ | +| 第六批 | 10, 11 | ✅ | +| 第七批 | 12, 13 | ✅ | +| 第八批 | 14, 15, 16 | ✅ | +| 第九批 | 17, 18, 19 | ✅ | +| 第十批 | 20, 21 | ✅ | +| 第十一批 | 22, 23 | ✅ | +| 第十二批 | 24, 25 | ✅ | +| 第十三批 | 26, 27, 28 | ✅ | +| 第十四批 | 29, 30, 31 | ✅ | +| 第十五批 | 32 | ✅ 收官 🎉 | + +> 批次划分是计划,可能随内容长短微调;以上表"进度一览"的 ✅/⬜ 为准。 + +--- + +## 语法学习地图(已讲过的 TypeScript 语法 → 在哪篇首次出现) + +读到后面遇到忘了的语法,回这里查它在哪篇讲过。 + +### 注释与模块 +- 三种注释 `// /* */ /** */`、JSDoc、`@see` —— 01、05 +- `import { X }`(具名)/ `import X`(默认)/ `import type` —— 01、02、06 +- `import`(值,要 new/调用)vs `import type`(仅类型,运行时抹除)的判断标准 —— 10 +- 一条 import 混用值与类型:`import { 值, type 类型 } from …` —— 30 +- 动态 `import("...")`:执行到才加载、返回 Promise、按需加载 —— 11 +- `createRequire(import.meta.url)`:在 ESM 里借 CJS 的 `require` 加载老库(rclnodejs);`import.meta.url` 当前文件地址 —— 26 +- `export default`(默认导出,一文件至多一个、导入不带花括号)vs 具名 `export` —— 13 +- `export` 导出、`./` 当前目录、`../` 上级、内部导入写 `.js` 后缀 —— 01、04 + +### 类型系统基础 +- 基础类型 `string`/`number`/`boolean`/`void`/`unknown`/`null` —— 01、06 +- `interface`(对象形状)vs `type`(更万能) —— 01 +- 可选 `?`、数组 `[]`、`Record`、`Promise` —— 01 +- 联合 `|`、字面量类型、判别联合、类型收窄 —— 01、03 +- 交叉类型 `&` —— 06 +- 函数类型 `(参数) => 返回`、回调、函数重载 —— 01、03 +- 剩余参数 `function f(a, ...args: unknown[])`(收集任意多个尾部参数) —— 32 +- 极简局部接口:只声明真正用到的成员(vs 完整 `OpenClawPluginApi`) —— 32 +- `interface extends`(继承)、子接口收紧字段 —— 05 +- `class implements 接口`:承诺实现契约、TS 逐方法强制检查 —— 10 +- `never` 类型 + 穷举检查(`const _e: never = 判别变量`,漏分支编译期报错) —— 11 +- `any`(放弃类型检查的逃生类型,catch 里常见) —— 11 +- `type X = any` 给 any 起有意义的别名(自我文档化) —— 28 +- 工具类型 `Required`、`ReturnType` —— 06 +- `as`(类型断言)、`!`(非空断言)、`typeof 值`(值→类型) —— 06、02 +- 双重断言 `as unknown as X`(源/目标差太远时的强转) —— 09 +- `satisfies 类型`:验证对象符合某类型但保留精确类型(比 `as` 安全——核对而非强断言) —— 31 +- 索引签名 `[key: string]: unknown`(除已知字段外允许任意字符串键) —— 29 +- `string | null`(值可为 null)vs `?`(键可省略)的区别 —— 29 +- 索引访问类型 `TransportConfig["mode"]`(从对象类型里取某字段的类型,跟着源头自动变) —— 12 +- `as const`(锁成只读字面量类型)+ 从数组派生类型 `(typeof 数组)[number]` —— 25 +- 类型守卫 `function f(x): x is T`(运行时检查 + 让 TS 收窄类型) —— 25 +- 真值判断 `if (someValue)`(判空简写)、可空函数字段 `(() => void) | null` —— 07 + +### 值与变量 +- `const`/`let`/`var`、对象简写 `{a,b}`、数组解构 `[x,y]` —— 02、06 +- 模块级变量(写在文件最外层、全文件共享、程序运行期长存)——单例的朴素实现 —— 12 +- 先声明后赋值 `let x: T;` 再在各分支赋值(穷尽分支时可省 `default`) —— 12 +- 参数名加 `_` 前缀(`_ctx`)表示"故意不用此参数" —— 12 +- 对象解构 `const { X } = 模块/对象`(拆出某项) —— 11 +- 模板字符串 `` `${x}` ``、数字分隔符 `10_000` —— 06 +- `??`(空值合并)、`?.`(可选链)、默认参数 —— 06 + +### 运算与控制流 +- `===`/`!==`、`!`/`&&`/`||`、三元 `?:` —— 06 +- `if`、`switch`/`case`/`break`、`for...of`、`continue`、`return` —— 02、06 +- `++x`/`x++`、`Math.min`/`Math.pow`、`字符串.startsWith` —— 06 +- `**`(乘方)、`Math.sqrt`(平方根)、`Math.abs`(绝对值)、`数字.toFixed(2)`(保留小数) —— 22 +- 数组 `.map((元素,下标)=>…)`、`.push()`;箭头函数返回对象套圆括号 `({...})` —— 10 +- 数组 `.filter(判断函数)`(筛选)、`.length`(长度) —— 23 +- 数组 `.some(判断)`(有无任一满足)、`.find(判断)`(找第一个)、链式 `.filter().map()` —— 26 +- `Object.entries`(键值对数组)/`Object.keys`(键名数组)/`Map.values`(值),配 `for...of` 遍历 —— 26 +- 数组 `.includes`/`.join`/`.slice(1)`、字符串 `.includes`/`.split`、正则 `/\s+/`(按空白分割) —— 25 +- 解构剩余元素 `const [key, ...rest] = …`(处理"值里也含分隔符") —— 25 +- `{ ...对象 }` 展开浅拷贝(改副本不污染原对象)、`键 in 对象`(判断有无该键) —— 25 +- `Number(value)` 转数字、`Number.isNaN` 校验、`value === "true"` 转布尔、按 `typeof` 分支转换 —— 25 +- 字符串 `.endsWith` / `.slice(起,止)` / 负数下标"从末尾数";`对象["键名"]` 取属性 —— 10 +- 字符串拼装:`s += x`(追加)、`\n` 换行、多行模板字符串、`\\\`` 转义反引号、`.trim()` —— 23 +- `字符串.replace(/\/+$/, "")`:正则去掉结尾斜杠(URL 规整) —— 31 + +### 类与异步 +- `class`/`new`/实例/`this`/`constructor`/`private` —— 06 +- `static` 静态成员(类共享一份)+ 静态门闩(全进程只初始化一次) —— 26 +- 递归:函数调用自己处理"同类的子结构"(嵌套消息层层穿透) —— 27 +- 构造函数参数属性简写(参数前加 `private`/`public` 自动建字段) —— 07 +- `get 属性()`:取值器(getter),用时不加括号、像读属性但实时计算 —— 31 +- `Map`/`Set` 容器及其方法 —— 06 +- `Promise` 构造 `new Promise((resolve,reject)=>…)`、`async`/`await` —— 06 +- `async` 函数直接 `return` 一个 Promise(不在体内 await)、事件→Promise 桥接 —— 08 +- `setTimeout`/`clearTimeout`、`try/catch`、`throw`/`new Error` —— 02、06 +- `setInterval`/`clearInterval`:反复定时触发(vs setTimeout 一次);心跳保活 —— 31 +- `fetch(url, {method,headers,body})` 发 HTTP 请求;`res.ok`/`res.status`/`res.json()`/`res.text()` —— 31 +- `catch {}`(不接错误对象,出错也无所谓)vs `catch (err)`(要用错误) —— 23 +- `try/finally`(无论成败必执行的善后) —— 09 +- 并发门闩:布尔标志 + `try { 上闩… } finally { 抬闩 }` 防止异步操作重入 —— 12 +- **`Promise.all([...])`:多个异步操作并行、一起等,结果按序解构 `const [a,b,c]=…`** —— 23 +- 带 TTL 的缓存:`Date.now()` 时间戳 + `现在-timestamp`、返回 `content`(文本) + `details`(对象) —— 14、15 +- `params["键"] as 类型`:从 `Record` 取参数并断言(有 schema 校验兜底) —— 15 +- 竞速 Promise:一条路 `resolve`、另一条 `setTimeout`→`reject`,谁先到谁结束 + 双向清理 —— 16 +- 一个注册函数里多次 `api.registerTool`(一文件注册多个工具) —— 19 +- "某能力 = 调某约定服务"模式:模板字符串拼服务名 + 迁就服务形状(值包成数组、嵌套结构) —— 19(呼应 10 的 `/rosapi/topics`) +- 纯转发的值不必 `as` 断言(要用具体能力才断言) —— 19 +- 底层能力(如动作进度回调)在工具层可选择不用——务实取舍 —— 18 +- `Type.Object({})`:声明"无参数"工具(仍必须写 `parameters`) —— 20 +- base64:把二进制(图像)编码成纯文本以便走 JSON 协议;`ToolResult.content` 支持 `text`/`image` 两型 —— 21 + +### 钩子(OpenClaw hooks,按时机自动触发) +- `api.on(事件名, async (event, ctx) => {...})`:登记钩子,宿主到点自动触发(vs 工具被动调用) —— 22 +- `before_tool_call`:工具调用前拦截,返回 `{block,blockReason}` 拦下 / `void` 放行 —— 22 +- `before_agent_start`:对话开始前注入,返回 `{prependContext: 文本}` 塞进系统提示 —— 23 +- 钩子用返回值影响宿主行为(拦截 / 注入),是统一的机制 —— 22、23 + +### 命令(OpenClaw commands,用户直接打 `/xxx`) +- `api.registerCommand({ name, description, handler })`:登记命令,绕过 AI;`description` 写给人 —— 24 +- 命令 `handler(ctx)` 返回 `{ text }`:作为回复直接显示给用户 —— 24 +- 三种交互对照:工具(AI 调,返 content/details) / 钩子(宿主触发,返 block/prependContext) / 命令(用户打,返 text) —— 24 +- `ctx.args` 解析参数:`?? ""` + `.trim()` + `.split(/\s+/)` + 类型守卫校验 —— 25 +- 安全操作的 try/catch:成功确认、失败响亮告警、绝不静默(`/estop`) —— 24 + +> 早先预告的大语法(`Promise.all` 并行、`createRequire`/CommonJS 互操作)均已在第 23、26 篇讲完。至此本系列承诺补的语法点已全部覆盖。 + +### 读码方法论(比语法更值钱的几样) +- 看懂"分层":接口在中间、实现可替换(三种传输模式同实现一个 `RosTransport`) —— 04 + 10/26/30 +- 识别"模式":缓存(查→建→存)、Promise 包装回调、按 id 配对、引用计数清理 —— 反复出现 +- 对照"现状 vs 文档":发现并诚实指出代码与文档/注释的不一致(以代码为准) —— 21、30、31 +- 存根的正当价值:正确类型签名 + 诚实标注未实现(vs 假装实现) —— 32 + +--- + +## 阅读建议 + +1. **严格按顺序**:后一篇默认你已掌握前面讲过的语法,讲过的不再重复。 +2. **对照真实源码**:每篇开头有源文件链接,建议左边开源码、右边开详解对照看。 +3. **卡住就回查**:忘了某语法,用上面「语法学习地图」定位到首讲那篇。 +4. **第 06 篇(client.ts)是分水岭**:它最难、语法最密。吃透它,后面都是轻松的。建议反复读 `connect` 和 `handleMessage` 两个方法。 +5. **读完动手改一行**:挑个最感兴趣的方法,自己改一行、跑一下看会怎样——"读"之后"动手"才能把知识焊牢。 + +--- + +## 🎉 全系列已完成(32 / 32) + +从 01 `plugin-api.ts` 到 32 `openclaw-canvas`,整个 RosClaw 已逐行读完。收官总回顾见 [第 32 篇结尾](32-openclaw-canvas.md)。一路累计的上百个 TypeScript 语法点都收录在上面的「语法学习地图」里,可随时反查——它们是读**任何** TS 项目的通用功底。 + +← 返回 [核心代码导读(鸟瞰版)](../04-核心代码导读.md)