@seeed-studio/meshtastic 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +25 -0
- package/LICENSE +21 -0
- package/README.md +228 -92
- package/README.zh-CN.md +306 -0
- package/package.json +1 -1
- package/src/channel.ts +69 -16
- package/src/client.ts +108 -17
- package/src/config-schema.ts +36 -6
- package/src/inbound.ts +17 -2
- package/src/monitor.ts +130 -103
- package/src/mqtt-client.ts +30 -6
- package/src/normalize.ts +12 -4
- package/src/onboarding.ts +107 -23
- package/src/policy.ts +6 -2
- package/src/send.ts +13 -7
- package/src/types.ts +2 -0
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# OpenClaw Meshtastic 插件
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@seeed-studio/meshtastic)
|
|
4
|
+
[](https://www.npmjs.com/package/@seeed-studio/meshtastic)
|
|
5
|
+
|
|
6
|
+
[English](README.md) | **[中文](README.zh-CN.md)**
|
|
7
|
+
|
|
8
|
+
[OpenClaw](https://github.com/openclaw/openclaw) 的 [Meshtastic](https://meshtastic.org/) LoRa 网状网络频道插件。通过 USB 串口、HTTP 或 MQTT 将 AI 网关连接到 mesh 网络,无需云服务。
|
|
9
|
+
|
|
10
|
+
> [!IMPORTANT]
|
|
11
|
+
> 这是 [OpenClaw](https://github.com/openclaw/openclaw) AI 网关的**频道插件**,不是独立应用程序。你需要一个运行中的 OpenClaw 实例(Node.js 22+)才能使用。
|
|
12
|
+
|
|
13
|
+
[文档][docs] · [硬件指南](#推荐硬件) · [报告问题][issues] · [功能请求][issues]
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="media/hardware.jpg" width="420" alt="Meshtastic LoRa 硬件" />
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
## 目录
|
|
20
|
+
|
|
21
|
+
- [快速开始](#快速开始)
|
|
22
|
+
- [工作原理](#工作原理)
|
|
23
|
+
- [推荐硬件](#推荐硬件)
|
|
24
|
+
- [演示](#演示)
|
|
25
|
+
- [功能特性](#功能特性)
|
|
26
|
+
- [设置向导](#设置向导)
|
|
27
|
+
- [配置](#配置)
|
|
28
|
+
- [故障排查](#故障排查)
|
|
29
|
+
- [开发](#开发)
|
|
30
|
+
- [贡献](#贡献)
|
|
31
|
+
|
|
32
|
+
## 快速开始
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 1. 安装插件
|
|
36
|
+
openclaw plugins install @seeed-studio/meshtastic
|
|
37
|
+
|
|
38
|
+
# 2. 交互式设置 — 引导完成传输方式、频率区域、访问策略等配置
|
|
39
|
+
openclaw setup
|
|
40
|
+
|
|
41
|
+
# 3. 验证
|
|
42
|
+
openclaw channels status --probe
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<img src="media/setup-screenshot.png" width="700" alt="OpenClaw 设置向导" />
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
## 工作原理
|
|
50
|
+
|
|
51
|
+
```mermaid
|
|
52
|
+
flowchart LR
|
|
53
|
+
subgraph mesh ["📻 LoRa Mesh 网络"]
|
|
54
|
+
N["Meshtastic 节点"]
|
|
55
|
+
end
|
|
56
|
+
subgraph gw ["⚙️ OpenClaw 网关"]
|
|
57
|
+
P["Meshtastic 插件"]
|
|
58
|
+
AI["AI Agent"]
|
|
59
|
+
end
|
|
60
|
+
N -- "Serial (USB)" --> P
|
|
61
|
+
N -- "HTTP (WiFi)" --> P
|
|
62
|
+
N -. "MQTT (Broker)" .-> P
|
|
63
|
+
P <--> AI
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
本插件在 Meshtastic LoRa 设备和 OpenClaw AI Agent 之间架起桥梁,支持三种传输模式:
|
|
67
|
+
|
|
68
|
+
- **Serial** — 通过 USB 直连本地 Meshtastic 设备
|
|
69
|
+
- **HTTP** — 通过 WiFi / 局域网连接设备
|
|
70
|
+
- **MQTT** — 订阅 Meshtastic MQTT broker,无需本地硬件
|
|
71
|
+
|
|
72
|
+
入站消息经过访问控制(私信策略、群组策略、@mention 门控)后到达 AI。出站回复会自动去除 markdown 格式(LoRa 设备无法渲染),并按无线电包大小限制进行分片。
|
|
73
|
+
|
|
74
|
+
## 推荐硬件
|
|
75
|
+
|
|
76
|
+
<p align="center">
|
|
77
|
+
<img src="media/XIAOclaw.png" width="760" alt="搭载 Seeed XIAO 模组的 Meshtastic 设备" />
|
|
78
|
+
</p>
|
|
79
|
+
|
|
80
|
+
| 设备 | 适用场景 | 链接 |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| XIAO ESP32S3 + Wio-SX1262 套件 | 低成本离网节点 | [购买][hw-xiao] |
|
|
83
|
+
| Wio Tracker L1 Pro | 即插即用网关 | [购买][hw-wio] |
|
|
84
|
+
| SenseCAP Card Tracker T1000-E | 便携追踪器 | [购买][hw-sensecap] |
|
|
85
|
+
|
|
86
|
+
任何 Meshtastic 兼容设备均可使用。Serial 和 HTTP 直连设备;MQTT 完全不需要本地硬件。
|
|
87
|
+
|
|
88
|
+
## 演示
|
|
89
|
+
|
|
90
|
+
https://github.com/user-attachments/assets/a3e46e9d-cf5a-4743-9830-f671a1998ca0
|
|
91
|
+
|
|
92
|
+
备用链接:[media/demo.mp4](media/demo.mp4)
|
|
93
|
+
|
|
94
|
+
## 功能特性
|
|
95
|
+
|
|
96
|
+
- **私信和 mesh 频道** — 支持按频道设置独立规则
|
|
97
|
+
- **访问控制** — 私信策略(`open` / `pairing` / `allowlist`)、群组策略(`open` / `allowlist` / `disabled`)、@mention 门控、按频道白名单
|
|
98
|
+
- **多账户** — 同时运行独立的 serial、HTTP、MQTT 连接
|
|
99
|
+
- **区域感知** — 连接时自动设置设备区域,自动推导 MQTT topic 默认值
|
|
100
|
+
- **自动重连** — 弹性重试处理
|
|
101
|
+
|
|
102
|
+
## 设置向导
|
|
103
|
+
|
|
104
|
+
运行 `openclaw setup` 会启动一个交互式向导,逐步引导你完成配置。以下是每一步的含义和选择建议。
|
|
105
|
+
|
|
106
|
+
### 1. 传输方式(Transport)
|
|
107
|
+
|
|
108
|
+
网关如何连接到 Meshtastic mesh 网络:
|
|
109
|
+
|
|
110
|
+
| 选项 | 说明 | 要求 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| **Serial**(USB 串口) | 通过 USB 直连本地设备,自动检测可用端口。 | Meshtastic 设备已通过 USB 连接 |
|
|
113
|
+
| **HTTP**(WiFi) | 通过局域网连接设备。 | 设备 IP 或主机名(如 `meshtastic.local`) |
|
|
114
|
+
| **MQTT**(broker) | 通过 MQTT broker 连接 mesh 网络,无需本地硬件。 | broker 地址、凭据和订阅 topic |
|
|
115
|
+
|
|
116
|
+
### 2. LoRa 频率区域(Region)
|
|
117
|
+
|
|
118
|
+
> 仅 Serial 和 HTTP 模式需要。MQTT 模式从订阅 topic 中自动推导区域。
|
|
119
|
+
|
|
120
|
+
设置设备的无线电频率区域,必须与当地法规和 mesh 网络中其他节点一致。常用选项:
|
|
121
|
+
|
|
122
|
+
| 区域 | 频率 |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `US` | 902–928 MHz |
|
|
125
|
+
| `EU_868` | 869 MHz |
|
|
126
|
+
| `CN` | 470–510 MHz |
|
|
127
|
+
| `JP` | 920 MHz |
|
|
128
|
+
| `UNSET` | 保持设备默认值 |
|
|
129
|
+
|
|
130
|
+
完整列表参见 [Meshtastic 区域文档](https://meshtastic.org/docs/getting-started/initial-config/#lora)。
|
|
131
|
+
|
|
132
|
+
### 3. 节点名称(Node Name)
|
|
133
|
+
|
|
134
|
+
设备在 mesh 网络中的显示名称,同时作为群组频道中的 **@mention 触发词** — 其他用户发送 `@OpenClaw` 即可与你的 bot 对话。
|
|
135
|
+
|
|
136
|
+
- **Serial / HTTP**:可选 — 留空会自动从连接的设备读取名称。
|
|
137
|
+
- **MQTT**:必填 — 没有物理设备可供读取名称。
|
|
138
|
+
|
|
139
|
+
### 4. 频道访问控制(Channel Access / `groupPolicy`)
|
|
140
|
+
|
|
141
|
+
控制 bot 是否以及如何响应 **mesh 群组频道**(如 LongFast、Emergency)中的消息:
|
|
142
|
+
|
|
143
|
+
| 策略 | 行为 |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `disabled`(默认) | 忽略所有群组频道消息。仅处理私信。 |
|
|
146
|
+
| `open` | 在 mesh 上的**所有**频道中响应消息。 |
|
|
147
|
+
| `allowlist` | 仅在**指定频道**中响应。设置时会提示输入频道名称(逗号分隔,如 `LongFast, Emergency`)。使用 `*` 通配符匹配所有频道。 |
|
|
148
|
+
|
|
149
|
+
### 5. 需要 @mention 才回复(Require Mention)
|
|
150
|
+
|
|
151
|
+
> 仅在频道访问控制不为 `disabled` 时出现。
|
|
152
|
+
|
|
153
|
+
启用时(默认:**是**),bot 在群组频道中只有被 @mention 时才会回复(如 `@OpenClaw 天气怎么样?`),防止 bot 对频道中的每条消息都回复。
|
|
154
|
+
|
|
155
|
+
禁用时,bot 会回复允许频道中的**所有**消息。
|
|
156
|
+
|
|
157
|
+
### 6. 私信访问策略(DM Access Policy / `dmPolicy`)
|
|
158
|
+
|
|
159
|
+
控制谁可以给 bot 发送**私信(Direct Message)**:
|
|
160
|
+
|
|
161
|
+
| 策略 | 行为 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `pairing`(默认) | 新发送者会触发配对请求,审批通过后才能对话。 |
|
|
164
|
+
| `open` | mesh 上的任何人都可以自由私信 bot。 |
|
|
165
|
+
| `allowlist` | 仅 `allowFrom` 列表中的节点可以私信,其他人被忽略。 |
|
|
166
|
+
|
|
167
|
+
### 7. 私信白名单(DM Allowlist / `allowFrom`)
|
|
168
|
+
|
|
169
|
+
> 仅在 `dmPolicy` 为 `allowlist` 或向导判断需要时出现。
|
|
170
|
+
|
|
171
|
+
允许发送私信的 Meshtastic 节点 ID 列表。格式为 `!aabbccdd`(十六进制节点 ID),多个条目用逗号分隔。
|
|
172
|
+
|
|
173
|
+
### 8. 账户显示名称(Account Display Names)
|
|
174
|
+
|
|
175
|
+
> 仅在多账户配置时出现。可选。
|
|
176
|
+
|
|
177
|
+
为你的账户设置人类可读的显示名称。例如,ID 为 `home` 的账户可以显示为「家里基站」。如果跳过,系统直接使用原始 account ID。这是纯展示性的设置,不影响任何功能。
|
|
178
|
+
|
|
179
|
+
## 配置
|
|
180
|
+
|
|
181
|
+
交互式设置(`openclaw setup`)涵盖以下所有内容。详细步骤说明参见[设置向导](#设置向导)。如需手动编辑,使用 `openclaw config edit`。
|
|
182
|
+
|
|
183
|
+
### Serial(USB 串口)
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
channels:
|
|
187
|
+
meshtastic:
|
|
188
|
+
transport: serial
|
|
189
|
+
serialPort: /dev/ttyUSB0
|
|
190
|
+
nodeName: OpenClaw
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### HTTP(WiFi)
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
channels:
|
|
197
|
+
meshtastic:
|
|
198
|
+
transport: http
|
|
199
|
+
httpAddress: meshtastic.local
|
|
200
|
+
nodeName: OpenClaw
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### MQTT(broker)
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
channels:
|
|
207
|
+
meshtastic:
|
|
208
|
+
transport: mqtt
|
|
209
|
+
nodeName: OpenClaw
|
|
210
|
+
mqtt:
|
|
211
|
+
broker: mqtt.meshtastic.org
|
|
212
|
+
username: meshdev
|
|
213
|
+
password: large4cats
|
|
214
|
+
topic: "msh/US/2/json/#"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 多账户
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
channels:
|
|
221
|
+
meshtastic:
|
|
222
|
+
accounts:
|
|
223
|
+
home:
|
|
224
|
+
transport: serial
|
|
225
|
+
serialPort: /dev/ttyUSB0
|
|
226
|
+
remote:
|
|
227
|
+
transport: mqtt
|
|
228
|
+
mqtt:
|
|
229
|
+
broker: mqtt.meshtastic.org
|
|
230
|
+
topic: "msh/US/2/json/#"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
<details>
|
|
234
|
+
<summary><b>全部选项参考</b></summary>
|
|
235
|
+
|
|
236
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
237
|
+
|---|---|---|---|
|
|
238
|
+
| `transport` | `serial \| http \| mqtt` | `serial` | |
|
|
239
|
+
| `serialPort` | `string` | — | Serial 模式必填 |
|
|
240
|
+
| `httpAddress` | `string` | `meshtastic.local` | HTTP 模式必填 |
|
|
241
|
+
| `httpTls` | `boolean` | `false` | |
|
|
242
|
+
| `mqtt.broker` | `string` | `mqtt.meshtastic.org` | |
|
|
243
|
+
| `mqtt.port` | `number` | `1883` | |
|
|
244
|
+
| `mqtt.username` | `string` | `meshdev` | |
|
|
245
|
+
| `mqtt.password` | `string` | `large4cats` | |
|
|
246
|
+
| `mqtt.topic` | `string` | `msh/US/2/json/#` | 订阅 topic |
|
|
247
|
+
| `mqtt.publishTopic` | `string` | 自动推导 | |
|
|
248
|
+
| `mqtt.tls` | `boolean` | `false` | |
|
|
249
|
+
| `region` | 枚举 | `UNSET` | `US`、`EU_868`、`CN`、`JP`、`ANZ`、`KR`、`TW`、`RU`、`IN`、`NZ_865`、`TH`、`EU_433`、`UA_433`、`UA_868`、`MY_433`、`MY_919`、`SG_923`、`LORA_24`。仅 Serial/HTTP 模式。 |
|
|
250
|
+
| `nodeName` | `string` | 自动检测 | 显示名称及 @mention 触发词。MQTT 模式必填。 |
|
|
251
|
+
| `dmPolicy` | `open \| pairing \| allowlist` | `pairing` | 私信访问策略。详见[私信访问策略](#6-私信访问策略dm-access-policy--dmpolicy)。 |
|
|
252
|
+
| `allowFrom` | `string[]` | — | 私信白名单节点 ID,如 `["!aabbccdd"]` |
|
|
253
|
+
| `groupPolicy` | `open \| allowlist \| disabled` | `disabled` | 群组频道响应策略。详见[频道访问控制](#4-频道访问控制channel-access--grouppolicy)。 |
|
|
254
|
+
| `channels` | `Record<string, object>` | — | 按频道覆盖:`requireMention`、`allowFrom`、`tools` |
|
|
255
|
+
|
|
256
|
+
</details>
|
|
257
|
+
|
|
258
|
+
<details>
|
|
259
|
+
<summary><b>环境变量覆盖</b></summary>
|
|
260
|
+
|
|
261
|
+
以下环境变量覆盖默认账户的配置(YAML 中的命名账户配置优先):
|
|
262
|
+
|
|
263
|
+
| 变量 | 等效配置项 |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `MESHTASTIC_TRANSPORT` | `transport` |
|
|
266
|
+
| `MESHTASTIC_SERIAL_PORT` | `serialPort` |
|
|
267
|
+
| `MESHTASTIC_HTTP_ADDRESS` | `httpAddress` |
|
|
268
|
+
| `MESHTASTIC_MQTT_BROKER` | `mqtt.broker` |
|
|
269
|
+
| `MESHTASTIC_MQTT_TOPIC` | `mqtt.topic` |
|
|
270
|
+
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
## 故障排查
|
|
274
|
+
|
|
275
|
+
| 症状 | 检查项 |
|
|
276
|
+
|---|---|
|
|
277
|
+
| Serial 无法连接 | 设备路径是否正确?主机是否有权限? |
|
|
278
|
+
| HTTP 无法连接 | `httpAddress` 是否可达?`httpTls` 是否与设备设置匹配? |
|
|
279
|
+
| MQTT 收不到消息 | `mqtt.topic` 中的区域是否正确?broker 凭据是否有效? |
|
|
280
|
+
| 私信无回复 | `dmPolicy` 和 `allowFrom` 是否已配置?详见[私信访问策略](#6-私信访问策略dm-access-policy--dmpolicy)。 |
|
|
281
|
+
| 群组频道无回复 | `groupPolicy` 是否已启用?频道是否在白名单中?是否需要 @mention?详见[频道访问控制](#4-频道访问控制channel-access--grouppolicy)。 |
|
|
282
|
+
|
|
283
|
+
发现 bug?请[提交 issue][issues],附上传输方式、配置(隐去密钥)以及 `openclaw channels status --probe` 的输出。
|
|
284
|
+
|
|
285
|
+
## 开发
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
git clone https://github.com/Seeed-Solution/openclaw-meshtastic.git
|
|
289
|
+
cd openclaw-meshtastic
|
|
290
|
+
npm install
|
|
291
|
+
openclaw plugins install -l ./openclaw-meshtastic
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
无构建步骤 — OpenClaw 直接加载 TypeScript 源码。使用 `openclaw channels status --probe` 验证。
|
|
295
|
+
|
|
296
|
+
## 贡献
|
|
297
|
+
|
|
298
|
+
- [提交 issue][issues] 报告 bug 或提出功能请求
|
|
299
|
+
- 欢迎 Pull Request — 请保持与现有 TypeScript 代码风格一致
|
|
300
|
+
|
|
301
|
+
<!-- Reference-style links -->
|
|
302
|
+
[docs]: https://meshtastic.org/docs/
|
|
303
|
+
[issues]: https://github.com/Seeed-Solution/openclaw-meshtastic/issues
|
|
304
|
+
[hw-xiao]: https://www.seeedstudio.com/Wio-SX1262-with-XIAO-ESP32S3-p-5982.html
|
|
305
|
+
[hw-wio]: https://www.seeedstudio.com/Wio-Tracker-L1-Pro-p-6454.html
|
|
306
|
+
[hw-sensecap]: https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -264,8 +264,8 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
264
264
|
},
|
|
265
265
|
outbound: {
|
|
266
266
|
deliveryMode: "direct",
|
|
267
|
-
chunker: (text, limit) => getMeshtasticRuntime().channel.text.
|
|
268
|
-
chunkerMode: "
|
|
267
|
+
chunker: (text, limit) => getMeshtasticRuntime().channel.text.chunkText(text, limit),
|
|
268
|
+
chunkerMode: "text",
|
|
269
269
|
textChunkLimit: 200,
|
|
270
270
|
sendText: async ({ to, text, accountId }) => {
|
|
271
271
|
const result = await sendMessageMeshtastic(to, text, {
|
|
@@ -291,25 +291,78 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
291
291
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
292
292
|
}),
|
|
293
293
|
probeAccount: async ({ account }) => {
|
|
294
|
-
// Meshtastic probing is transport-dependent and may require
|
|
295
|
-
// active device connection. Return a basic status.
|
|
296
294
|
if (!account.configured) {
|
|
297
295
|
return {
|
|
298
296
|
ok: false,
|
|
299
|
-
error: "
|
|
297
|
+
error: "Not configured. Run 'openclaw setup' to configure.",
|
|
300
298
|
transport: account.transport,
|
|
301
299
|
} as MeshtasticProbe;
|
|
302
300
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
account.transport === "
|
|
308
|
-
? account.
|
|
309
|
-
: account.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
301
|
+
|
|
302
|
+
const address =
|
|
303
|
+
account.transport === "serial"
|
|
304
|
+
? account.serialPort
|
|
305
|
+
: account.transport === "http"
|
|
306
|
+
? account.httpAddress
|
|
307
|
+
: account.config.mqtt?.broker;
|
|
308
|
+
|
|
309
|
+
// Lightweight transport-specific reachability check.
|
|
310
|
+
try {
|
|
311
|
+
if (account.transport === "serial") {
|
|
312
|
+
const { access } = await import("node:fs/promises");
|
|
313
|
+
await access(account.serialPort);
|
|
314
|
+
} else if (account.transport === "http") {
|
|
315
|
+
const prefix = account.httpTls ? "https" : "http";
|
|
316
|
+
const url = `${prefix}://${account.httpAddress}/api/v1/fromradio`;
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
319
|
+
try {
|
|
320
|
+
await fetch(url, { signal: controller.signal });
|
|
321
|
+
} finally {
|
|
322
|
+
clearTimeout(timeout);
|
|
323
|
+
}
|
|
324
|
+
} else if (account.transport === "mqtt") {
|
|
325
|
+
const mqtt = await import("mqtt");
|
|
326
|
+
const mqttConfig = account.config.mqtt;
|
|
327
|
+
const protocol = mqttConfig?.tls ? "mqtts" : "mqtt";
|
|
328
|
+
const broker = mqttConfig?.broker ?? "mqtt.meshtastic.org";
|
|
329
|
+
const port = mqttConfig?.port ?? 1883;
|
|
330
|
+
await new Promise<void>((resolve, reject) => {
|
|
331
|
+
const timeout = setTimeout(() => {
|
|
332
|
+
client.end(true);
|
|
333
|
+
reject(new Error("MQTT connection timed out (5s)"));
|
|
334
|
+
}, 5_000);
|
|
335
|
+
const client = mqtt.default.connect(`${protocol}://${broker}:${port}`, {
|
|
336
|
+
username: mqttConfig?.username ?? "meshdev",
|
|
337
|
+
password: mqttConfig?.password ?? "large4cats",
|
|
338
|
+
clean: true,
|
|
339
|
+
connectTimeout: 5_000,
|
|
340
|
+
});
|
|
341
|
+
client.on("connect", () => {
|
|
342
|
+
clearTimeout(timeout);
|
|
343
|
+
client.end(true);
|
|
344
|
+
resolve();
|
|
345
|
+
});
|
|
346
|
+
client.on("error", (err) => {
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
client.end(true);
|
|
349
|
+
reject(err);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
transport: account.transport,
|
|
356
|
+
address,
|
|
357
|
+
} as MeshtasticProbe;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
error: err instanceof Error ? err.message : String(err),
|
|
362
|
+
transport: account.transport,
|
|
363
|
+
address,
|
|
364
|
+
} as MeshtasticProbe;
|
|
365
|
+
}
|
|
313
366
|
},
|
|
314
367
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
315
368
|
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
@@ -324,7 +377,7 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
324
377
|
if (!account.configured) {
|
|
325
378
|
throw new Error(
|
|
326
379
|
`Meshtastic is not configured for account "${account.accountId}". ` +
|
|
327
|
-
`
|
|
380
|
+
`Run 'openclaw setup' or set channels.meshtastic.transport and connection details in config.`,
|
|
328
381
|
);
|
|
329
382
|
}
|
|
330
383
|
const transportDesc =
|
package/src/client.ts
CHANGED
|
@@ -2,6 +2,39 @@ import { MeshDevice } from "@meshtastic/core";
|
|
|
2
2
|
import { nodeNumToHex } from "./normalize.js";
|
|
3
3
|
import type { MeshtasticRegion } from "./types.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Device status codes from @meshtastic/core DeviceStatusEnum.
|
|
7
|
+
* The SDK doesn't export these as named constants, so we define them here.
|
|
8
|
+
*/
|
|
9
|
+
export const DeviceStatus = {
|
|
10
|
+
Restarting: 1,
|
|
11
|
+
Disconnected: 2,
|
|
12
|
+
Connecting: 3,
|
|
13
|
+
Reconnecting: 4,
|
|
14
|
+
Connected: 5,
|
|
15
|
+
Configuring: 6,
|
|
16
|
+
Configured: 7,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when setOwner() was called to update the device name.
|
|
21
|
+
* The device will reboot to persist the change; the caller should
|
|
22
|
+
* reconnect after a delay (~30 s).
|
|
23
|
+
*/
|
|
24
|
+
export class SetOwnerRebootError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly newName: string,
|
|
27
|
+
public readonly previousName: string | undefined,
|
|
28
|
+
) {
|
|
29
|
+
super(
|
|
30
|
+
`Device rebooting to apply name "${newName}"` +
|
|
31
|
+
(previousName ? ` (was "${previousName}")` : "") +
|
|
32
|
+
`. Auto-reconnect expected.`,
|
|
33
|
+
);
|
|
34
|
+
this.name = "SetOwnerRebootError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
5
38
|
export type MeshtasticTextEvent = {
|
|
6
39
|
senderNodeNum: number;
|
|
7
40
|
senderNodeId: string;
|
|
@@ -37,6 +70,7 @@ export type MeshtasticClient = {
|
|
|
37
70
|
channelIndex?: number,
|
|
38
71
|
) => Promise<number>;
|
|
39
72
|
getNodeName: (nodeNum: number) => string | undefined;
|
|
73
|
+
getMyNodeName: () => string | undefined;
|
|
40
74
|
getChannelName: (index: number) => string | undefined;
|
|
41
75
|
close: () => void;
|
|
42
76
|
};
|
|
@@ -93,6 +127,22 @@ export async function connectMeshtasticClient(
|
|
|
93
127
|
|
|
94
128
|
const device = new MeshDevice(transport);
|
|
95
129
|
|
|
130
|
+
// Expose serial port events for diagnostics — helps pinpoint why USB
|
|
131
|
+
// connections drop (power management, cable, firmware, etc.).
|
|
132
|
+
if (options.transport === "serial") {
|
|
133
|
+
const port = (transport as unknown as { port?: { on?: (...a: unknown[]) => void } }).port;
|
|
134
|
+
if (port?.on) {
|
|
135
|
+
port.on("error", (err: unknown) => {
|
|
136
|
+
options.onError?.(
|
|
137
|
+
err instanceof Error ? err : new Error(`serial port error: ${err}`),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
port.on("close", () => {
|
|
141
|
+
options.onStatus?.("serial port closed by OS / device");
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
// Node info cache for name resolution.
|
|
97
147
|
const nodeNames = new Map<number, string>();
|
|
98
148
|
const channelNames = new Map<number, string>();
|
|
@@ -106,10 +156,11 @@ export async function connectMeshtasticClient(
|
|
|
106
156
|
}
|
|
107
157
|
});
|
|
108
158
|
|
|
159
|
+
// NodeInfo is dispatched directly (not wrapped in { data: … }) during config.
|
|
109
160
|
device.events.onNodeInfoPacket.subscribe(
|
|
110
|
-
(packet: {
|
|
111
|
-
if (packet.
|
|
112
|
-
nodeNames.set(packet.
|
|
161
|
+
(packet: { num?: number; user?: { longName?: string } }) => {
|
|
162
|
+
if (packet.user?.longName && packet.num) {
|
|
163
|
+
nodeNames.set(packet.num, packet.user.longName);
|
|
113
164
|
}
|
|
114
165
|
},
|
|
115
166
|
);
|
|
@@ -132,7 +183,16 @@ export async function connectMeshtasticClient(
|
|
|
132
183
|
);
|
|
133
184
|
|
|
134
185
|
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
135
|
-
|
|
186
|
+
const label: Record<number, string> = {
|
|
187
|
+
[DeviceStatus.Restarting]: "Restarting",
|
|
188
|
+
[DeviceStatus.Disconnected]: "Disconnected",
|
|
189
|
+
[DeviceStatus.Connecting]: "Connecting",
|
|
190
|
+
[DeviceStatus.Reconnecting]: "Reconnecting",
|
|
191
|
+
[DeviceStatus.Connected]: "Connected",
|
|
192
|
+
[DeviceStatus.Configuring]: "Configuring",
|
|
193
|
+
[DeviceStatus.Configured]: "Configured",
|
|
194
|
+
};
|
|
195
|
+
options.onStatus?.(`status=${status} (${label[status] ?? "unknown"})`);
|
|
136
196
|
});
|
|
137
197
|
|
|
138
198
|
device.events.onMessagePacket.subscribe(
|
|
@@ -203,27 +263,31 @@ export async function connectMeshtasticClient(
|
|
|
203
263
|
};
|
|
204
264
|
const timeout = setTimeout(() => {
|
|
205
265
|
cleanup();
|
|
206
|
-
reject(new Error(
|
|
266
|
+
reject(new Error(
|
|
267
|
+
"Device configure timed out (45s). Check USB connection, try a different port, or power-cycle the device.",
|
|
268
|
+
));
|
|
207
269
|
}, 45_000);
|
|
208
270
|
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
209
|
-
if (status ===
|
|
271
|
+
if (status === DeviceStatus.Configured) {
|
|
210
272
|
cleanup();
|
|
211
273
|
resolve();
|
|
212
|
-
} else if (status ===
|
|
274
|
+
} else if (status === DeviceStatus.Connected && !configureRetried) {
|
|
213
275
|
// Transport is now connected — re-send the config request after a
|
|
214
276
|
// short delay so the serial pipe is fully established.
|
|
215
277
|
configureRetried = true;
|
|
216
278
|
setTimeout(() => device.configure().catch(() => {}), 500);
|
|
217
|
-
} else if (status ===
|
|
279
|
+
} else if (status === DeviceStatus.Disconnected) {
|
|
218
280
|
cleanup();
|
|
219
|
-
reject(new Error(
|
|
281
|
+
reject(new Error(
|
|
282
|
+
"Device disconnected during configure. Check cable connection and ensure no other program is using the serial port.",
|
|
283
|
+
));
|
|
220
284
|
}
|
|
221
285
|
});
|
|
222
286
|
// Poll as fallback — ste-core dispatch can miss late subscribers.
|
|
223
287
|
poll = setInterval(() => {
|
|
224
288
|
if (
|
|
225
289
|
(device as unknown as { isConfigured: boolean }).isConfigured ||
|
|
226
|
-
(device as unknown as { deviceStatus: number }).deviceStatus ===
|
|
290
|
+
(device as unknown as { deviceStatus: number }).deviceStatus === DeviceStatus.Configured
|
|
227
291
|
) {
|
|
228
292
|
cleanup();
|
|
229
293
|
resolve();
|
|
@@ -241,17 +305,43 @@ export async function connectMeshtasticClient(
|
|
|
241
305
|
throw err;
|
|
242
306
|
}
|
|
243
307
|
|
|
308
|
+
// Serial connections require periodic heartbeat pings to stay alive.
|
|
309
|
+
// Without this the device (or macOS USB stack) drops the connection after
|
|
310
|
+
// ~30 s of inactivity once configuration is complete.
|
|
311
|
+
if (options.transport === "serial") {
|
|
312
|
+
device.setHeartbeatInterval(15_000);
|
|
313
|
+
}
|
|
314
|
+
|
|
244
315
|
// LoRa region: rely on NVS-persisted config set via `meshtastic --set lora.region`.
|
|
245
316
|
// Sending a partial setConfig (region-only) zeroes out tx_enabled, tx_power, etc.
|
|
246
317
|
// in the protobuf message, effectively disabling TX. So we skip setConfig here.
|
|
247
318
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
319
|
+
// Device display name — only call setOwner() when the configured name
|
|
320
|
+
// differs from what the device already reports. setOwner() sends an admin
|
|
321
|
+
// packet that writes to NVS flash and reboots the device (~20-30 s later).
|
|
322
|
+
// By gating on a name mismatch we ensure the call happens at most once;
|
|
323
|
+
// after reboot the name matches and the guard passes, preventing an
|
|
324
|
+
// infinite reboot loop.
|
|
325
|
+
if (options.nodeName) {
|
|
326
|
+
const desiredName = options.nodeName.trim();
|
|
327
|
+
const currentName = nodeNames.get(myNodeNum);
|
|
328
|
+
|
|
329
|
+
if (desiredName && currentName !== desiredName) {
|
|
330
|
+
const shortName = desiredName.slice(0, 4);
|
|
331
|
+
try {
|
|
332
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
333
|
+
await device.setOwner({ longName: desiredName, shortName } as any);
|
|
334
|
+
} catch {
|
|
335
|
+
// Admin packet may fail on a flaky serial link; fall through and
|
|
336
|
+
// let the device keep its current name rather than crashing.
|
|
337
|
+
}
|
|
338
|
+
// Allow the firmware time to receive and process the admin packet
|
|
339
|
+
// before we tear down the serial connection.
|
|
340
|
+
options.onStatus?.("waiting 2s for firmware to process name change...");
|
|
341
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
342
|
+
safeDisconnect();
|
|
343
|
+
throw new SetOwnerRebootError(desiredName, currentName);
|
|
344
|
+
}
|
|
255
345
|
}
|
|
256
346
|
|
|
257
347
|
// Catch unhandled promise rejections originating from @meshtastic/core's
|
|
@@ -283,6 +373,7 @@ export async function connectMeshtasticClient(
|
|
|
283
373
|
sendText: (text, destination, wantAck = true, channelIndex) =>
|
|
284
374
|
device.sendText(text, destination, wantAck, channelIndex),
|
|
285
375
|
getNodeName: (nodeNum) => nodeNames.get(nodeNum),
|
|
376
|
+
getMyNodeName: () => nodeNames.get(myNodeNum),
|
|
286
377
|
getChannelName: (index) => channelNames.get(index) || (index === 0 ? "LongFast" : undefined),
|
|
287
378
|
close: () => {
|
|
288
379
|
safeDisconnect();
|