@seeed-studio/meshtastic 0.1.1 → 0.2.1

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/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@seeed-studio/meshtastic",
3
- "version": "0.1.1",
4
- "description": "OpenClaw Meshtastic LoRa mesh channel plugin",
3
+ "version": "0.2.1",
4
+ "description": "MeshClaw — OpenClaw channel plugin for Meshtastic LoRa mesh networks",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/Seeed-Solution/openclaw-meshtastic"
9
+ "url": "https://github.com/Seeed-Solution/MeshClaw"
10
10
  },
11
11
  "keywords": [
12
+ "meshclaw",
12
13
  "openclaw",
13
14
  "meshtastic",
14
15
  "lora",
package/src/channel.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  resolveDefaultGroupPolicy,
11
11
  setAccountEnabledInConfigSection,
12
12
  type ChannelPlugin,
13
- } from "openclaw/plugin-sdk";
13
+ } from "openclaw/plugin-sdk/irc";
14
14
  import {
15
15
  listMeshtasticAccountIds,
16
16
  resolveDefaultMeshtasticAccountId,
@@ -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 onboard' 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 onboard' 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();
@@ -5,7 +5,7 @@ import {
5
5
  ReplyRuntimeConfigSchemaShape,
6
6
  ToolPolicySchema,
7
7
  requireOpenAllowFrom,
8
- } from "openclaw/plugin-sdk";
8
+ } from "openclaw/plugin-sdk/irc";
9
9
  import { z } from "zod";
10
10
 
11
11
  const MeshtasticGroupSchema = z
@@ -22,13 +22,14 @@ const MeshtasticGroupSchema = z
22
22
 
23
23
  const MeshtasticMqttSchema = z
24
24
  .object({
25
- broker: z.string().optional(),
26
- port: z.number().int().min(1).max(65535).optional(),
27
- username: z.string().optional(),
28
- password: z.string().optional(),
29
- topic: z.string().optional(),
25
+ broker: z.string().optional().default("mqtt.meshtastic.org"),
26
+ port: z.number().int().min(1).max(65535).optional().default(1883),
27
+ username: z.string().optional().default("meshdev"),
28
+ password: z.string().optional().default("large4cats"),
29
+ topic: z.string().optional().default("msh/US/2/json/#"),
30
30
  publishTopic: z.string().optional(),
31
- tls: z.boolean().optional(),
31
+ tls: z.boolean().optional().default(false),
32
+ myNodeId: z.string().optional(),
32
33
  })
33
34
  .strict();
34
35
 
@@ -77,11 +78,39 @@ export const MeshtasticAccountSchemaBase = z
77
78
  channels: z.record(z.string(), MeshtasticGroupSchema.optional()).optional(),
78
79
  mentionPatterns: z.array(z.string()).optional(),
79
80
  markdown: MarkdownConfigSchema,
81
+ textChunkLimit: z.number().int().min(50).max(500).optional(),
80
82
  ...ReplyRuntimeConfigSchemaShape,
81
83
  })
82
84
  .strict();
83
85
 
86
+ /** Validates transport+connection coherence and open policy allowFrom. */
87
+ function validateTransportConnection(value: z.output<typeof MeshtasticAccountSchemaBase>, ctx: z.RefinementCtx) {
88
+ const transport = value.transport ?? "serial";
89
+ if (transport === "serial" && !value.serialPort) {
90
+ ctx.addIssue({
91
+ code: z.ZodIssueCode.custom,
92
+ path: ["serialPort"],
93
+ message: 'transport="serial" requires serialPort (e.g. "/dev/ttyUSB0" or "/dev/tty.usbmodem*")',
94
+ });
95
+ }
96
+ if (transport === "http" && !value.httpAddress) {
97
+ ctx.addIssue({
98
+ code: z.ZodIssueCode.custom,
99
+ path: ["httpAddress"],
100
+ message: 'transport="http" requires httpAddress (e.g. "meshtastic.local" or "192.168.1.100")',
101
+ });
102
+ }
103
+ if (transport === "mqtt" && !value.mqtt?.broker) {
104
+ ctx.addIssue({
105
+ code: z.ZodIssueCode.custom,
106
+ path: ["mqtt", "broker"],
107
+ message: 'transport="mqtt" requires mqtt.broker (e.g. "mqtt.meshtastic.org")',
108
+ });
109
+ }
110
+ }
111
+
84
112
  export const MeshtasticAccountSchema = MeshtasticAccountSchemaBase.superRefine((value, ctx) => {
113
+ validateTransportConnection(value, ctx);
85
114
  requireOpenAllowFrom({
86
115
  policy: value.dmPolicy,
87
116
  allowFrom: value.allowFrom,
@@ -95,6 +124,7 @@ export const MeshtasticAccountSchema = MeshtasticAccountSchemaBase.superRefine((
95
124
  export const MeshtasticConfigSchema = MeshtasticAccountSchemaBase.extend({
96
125
  accounts: z.record(z.string(), MeshtasticAccountSchema.optional()).optional(),
97
126
  }).superRefine((value, ctx) => {
127
+ validateTransportConnection(value, ctx);
98
128
  requireOpenAllowFrom({
99
129
  policy: value.dmPolicy,
100
130
  allowFrom: value.allowFrom,
package/src/inbound.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  GROUP_POLICY_BLOCKED_LABEL,
3
3
  createNormalizedOutboundDeliverer,
4
- createReplyPrefixOptions,
5
4
  formatTextWithAttachmentLinks,
6
5
  logInboundDrop,
7
6
  resolveControlCommandGate,
@@ -12,7 +11,8 @@ import {
12
11
  type OutboundReplyPayload,
13
12
  type OpenClawConfig,
14
13
  type RuntimeEnv,
15
- } from "openclaw/plugin-sdk";
14
+ } from "openclaw/plugin-sdk/irc";
15
+ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/matrix";
16
16
  import type { ResolvedMeshtasticAccount } from "./accounts.js";
17
17
  import {
18
18
  normalizeMeshtasticAllowlist,
@@ -51,6 +51,14 @@ function resolveMeshtasticEffectiveAllowlists(params: {
51
51
  // so the firmware doesn't silently truncate them.
52
52
  const MESHTASTIC_CHUNK_LIMIT = 200;
53
53
 
54
+ /** Channel-level system prompt hint for LoRa-constrained responses. */
55
+ const LORA_SYSTEM_HINT =
56
+ "You are responding over a LoRa mesh radio (Meshtastic). " +
57
+ "Each message is limited to ~200 bytes. " +
58
+ "Keep responses extremely concise — plain text only, no markdown, no emoji, no bullet lists. " +
59
+ "Use short sentences. Omit filler words. Prioritize the most important information first.";
60
+
61
+
54
62
  function chunkText(text: string, limit: number): string[] {
55
63
  if (text.length <= limit) return [text];
56
64
  const chunks: string[] = [];
@@ -150,7 +158,13 @@ export async function handleMeshtasticInbound(params: {
150
158
  const storeAllowFrom =
151
159
  dmPolicy === "allowlist"
152
160
  ? []
153
- : await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
161
+ : await core.channel.pairing.readAllowFromStore({
162
+ channel: CHANNEL_ID,
163
+ accountId: account.accountId,
164
+ }).catch((err: unknown) => {
165
+ runtime.log?.(`meshtastic: pairing allowFrom read failed: ${String(err)}`);
166
+ return [] as string[];
167
+ });
154
168
  const storeAllowList = normalizeMeshtasticAllowlist(storeAllowFrom);
155
169
 
156
170
  const channelLabel = message.channelName ?? `channel-${message.channelIndex}`;
@@ -227,6 +241,7 @@ export async function handleMeshtasticInbound(params: {
227
241
  const normalizedId = normalizeMeshtasticNodeId(message.senderNodeId);
228
242
  const { code, created } = await core.channel.pairing.upsertPairingRequest({
229
243
  channel: CHANNEL_ID,
244
+ accountId: account.accountId,
230
245
  id: normalizedId,
231
246
  meta: { name: message.senderName || undefined },
232
247
  });
@@ -336,7 +351,7 @@ export async function handleMeshtasticInbound(params: {
336
351
  SenderName: message.senderName || undefined,
337
352
  SenderId: message.senderNodeId,
338
353
  GroupSubject: message.isGroup ? channelLabel : undefined,
339
- GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
354
+ GroupSystemPrompt: [LORA_SYSTEM_HINT, groupSystemPrompt].filter(Boolean).join("\n\n"),
340
355
  Provider: CHANNEL_ID,
341
356
  Surface: CHANNEL_ID,
342
357
  WasMentioned: message.isGroup ? wasMentioned : undefined,