@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.
@@ -0,0 +1,306 @@
1
+ # OpenClaw Meshtastic 插件
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@seeed-studio/meshtastic.svg)](https://www.npmjs.com/package/@seeed-studio/meshtastic)
4
+ [![license](https://img.shields.io/npm/l/@seeed-studio/meshtastic.svg)](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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seeed-studio/meshtastic",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw Meshtastic LoRa mesh channel plugin",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.chunkMarkdownText(text, limit),
268
- chunkerMode: "markdown",
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: "not configured",
297
+ error: "Not configured. Run 'openclaw setup' to configure.",
300
298
  transport: account.transport,
301
299
  } as MeshtasticProbe;
302
300
  }
303
- return {
304
- ok: true,
305
- transport: account.transport,
306
- address:
307
- account.transport === "serial"
308
- ? account.serialPort
309
- : account.transport === "http"
310
- ? account.httpAddress
311
- : account.config.mqtt?.broker,
312
- } as MeshtasticProbe;
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
- `Set channels.meshtastic.transport and connection details.`,
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: { data?: { num?: number; user?: { longName?: string } } }) => {
111
- if (packet.data?.user?.longName && packet.data.num) {
112
- nodeNames.set(packet.data.num, packet.data.user.longName);
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
- options.onStatus?.(`status=${status}`);
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("device configure timed out (45 s)"));
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 === 7 /* DeviceConfigured */) {
271
+ if (status === DeviceStatus.Configured) {
210
272
  cleanup();
211
273
  resolve();
212
- } else if (status === 5 /* DeviceConnected */ && !configureRetried) {
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 === 2 /* DeviceDisconnected */) {
279
+ } else if (status === DeviceStatus.Disconnected) {
218
280
  cleanup();
219
- reject(new Error("device disconnected during configure"));
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 === 7
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
- // Set device display name if configured. Fire-and-forget for the same reason.
249
- if (options.nodeName?.trim()) {
250
- const longName = options.nodeName.trim();
251
- const shortName = longName.slice(0, 4);
252
- device
253
- .setOwner({ longName, shortName } as Parameters<typeof device.setOwner>[0])
254
- .catch(() => {});
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();