@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/src/onboarding.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import {
2
3
  addWildcardAllowFrom,
3
4
  DEFAULT_ACCOUNT_ID,
@@ -24,6 +25,57 @@ import type {
24
25
 
25
26
  const channel = "meshtastic" as const;
26
27
 
28
+ async function detectMeshtasticSerialPortCandidates(): Promise<string[]> {
29
+ const candidates = new Set<string>();
30
+
31
+ try {
32
+ const devEntries = await readdir("/dev");
33
+ for (const entry of devEntries) {
34
+ if (
35
+ entry.startsWith("cu.usb")
36
+ || entry.startsWith("tty.usb")
37
+ || entry.startsWith("ttyUSB")
38
+ || entry.startsWith("ttyACM")
39
+ ) {
40
+ candidates.add(`/dev/${entry}`);
41
+ }
42
+ }
43
+ } catch {}
44
+
45
+ try {
46
+ const byIdEntries = await readdir("/dev/serial/by-id");
47
+ for (const entry of byIdEntries) {
48
+ candidates.add(`/dev/serial/by-id/${entry}`);
49
+ }
50
+ } catch {}
51
+
52
+ return [...candidates].sort((a, b) => a.localeCompare(b));
53
+ }
54
+
55
+ const REGION_OPTIONS: { value: string; label: string }[] = [
56
+ { value: "US", label: "US (902-928 MHz)" },
57
+ { value: "EU_433", label: "EU_433 (433 MHz)" },
58
+ { value: "EU_868", label: "EU_868 (869 MHz)" },
59
+ { value: "CN", label: "CN (470-510 MHz)" },
60
+ { value: "JP", label: "JP (920 MHz)" },
61
+ { value: "ANZ", label: "ANZ (915-928 MHz)" },
62
+ { value: "KR", label: "KR (920-923 MHz)" },
63
+ { value: "TW", label: "TW (920-925 MHz)" },
64
+ { value: "RU", label: "RU (868 MHz)" },
65
+ { value: "IN", label: "IN (865-867 MHz)" },
66
+ { value: "TH", label: "TH (920-925 MHz)" },
67
+ { value: "LORA_24", label: "LORA_24 (2.4 GHz)" },
68
+ ];
69
+
70
+ function regionToMqttTopic(region: string): string {
71
+ return `msh/${region}/2/json/#`;
72
+ }
73
+
74
+ function parseRegionFromTopic(topic: string): string | undefined {
75
+ const match = topic.match(/^msh\/([^/]+)\//);
76
+ return match?.[1];
77
+ }
78
+
27
79
  function parseListInput(raw: string): string[] {
28
80
  return raw
29
81
  .split(/[\n,;]+/g)
@@ -227,14 +279,39 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
227
279
  const transport = String(transportChoice) as MeshtasticTransport;
228
280
 
229
281
  if (transport === "serial") {
230
- const serialPort = String(
231
- await prompter.text({
232
- message: "Serial port path",
233
- placeholder: "/dev/ttyUSB0 or /dev/tty.usbmodem*",
234
- initialValue: resolved.serialPort || undefined,
235
- validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
236
- }),
237
- ).trim();
282
+ const serialCandidates = await detectMeshtasticSerialPortCandidates();
283
+ let serialPort = resolved.serialPort || "";
284
+ let needsManualSerialInput = true;
285
+
286
+ if (serialCandidates.length > 0) {
287
+ const initialDetected = serialCandidates.includes(serialPort)
288
+ ? serialPort
289
+ : serialCandidates[0];
290
+ const detectedChoice = await prompter.select({
291
+ message: "Detected serial port",
292
+ options: [
293
+ ...serialCandidates.map((value) => ({ value, label: value })),
294
+ { value: "__manual__", label: "Manual input" },
295
+ ],
296
+ initialValue: initialDetected,
297
+ });
298
+ const selected = String(detectedChoice);
299
+ if (selected !== "__manual__") {
300
+ serialPort = selected;
301
+ needsManualSerialInput = false;
302
+ }
303
+ }
304
+
305
+ if (needsManualSerialInput) {
306
+ serialPort = String(
307
+ await prompter.text({
308
+ message: "Serial port path",
309
+ placeholder: "/dev/ttyUSB0 or /dev/tty.usbmodem*",
310
+ initialValue: serialPort || undefined,
311
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
312
+ }),
313
+ ).trim();
314
+ }
238
315
 
239
316
  next = updateMeshtasticAccountConfig(next, accountId, {
240
317
  enabled: true,
@@ -295,10 +372,20 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
295
372
  }),
296
373
  ).trim();
297
374
 
375
+ // Region selection — generates the default MQTT topic.
376
+ const existingTopic = resolved.config.mqtt?.topic || "msh/US/2/json/#";
377
+ const currentMqttRegion = parseRegionFromTopic(existingTopic) ?? "US";
378
+ const mqttRegionChoice = await prompter.select({
379
+ message: "LoRa region (determines which mesh traffic to receive)",
380
+ options: REGION_OPTIONS,
381
+ initialValue: currentMqttRegion,
382
+ });
383
+ const mqttRegion = String(mqttRegionChoice);
384
+
298
385
  const topic = String(
299
386
  await prompter.text({
300
387
  message: "MQTT subscribe topic",
301
- initialValue: resolved.config.mqtt?.topic || "msh/US/2/json/#",
388
+ initialValue: regionToMqttTopic(mqttRegion),
302
389
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
303
390
  }),
304
391
  ).trim();
@@ -328,18 +415,7 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
328
415
  message: "LoRa region",
329
416
  options: [
330
417
  { value: "UNSET", label: "UNSET (keep device default)" },
331
- { value: "US", label: "US (902-928 MHz)" },
332
- { value: "EU_433", label: "EU_433 (433 MHz)" },
333
- { value: "EU_868", label: "EU_868 (869 MHz)" },
334
- { value: "CN", label: "CN (470-510 MHz)" },
335
- { value: "JP", label: "JP (920 MHz)" },
336
- { value: "ANZ", label: "ANZ (915-928 MHz)" },
337
- { value: "KR", label: "KR (920-923 MHz)" },
338
- { value: "TW", label: "TW (920-925 MHz)" },
339
- { value: "RU", label: "RU (868 MHz)" },
340
- { value: "IN", label: "IN (865-867 MHz)" },
341
- { value: "TH", label: "TH (920-925 MHz)" },
342
- { value: "LORA_24", label: "LORA_24 (2.4 GHz)" },
418
+ ...REGION_OPTIONS,
343
419
  ],
344
420
  initialValue: resolved.config.region ?? "UNSET",
345
421
  });
@@ -352,11 +428,19 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
352
428
  // Device display name — also used as a mention pattern so users can
353
429
  // @NodeName the bot in group channels.
354
430
  const currentNodeName = resolveMeshtasticAccount({ cfg: next, accountId }).config.nodeName;
431
+ const isMqtt = transport === "mqtt";
355
432
  const nodeNameInput = String(
356
433
  await prompter.text({
357
- message: "Device display name (also used as @mention trigger)",
358
- placeholder: "e.g. OpenClaw",
434
+ message: isMqtt
435
+ ? "Device display name (required for MQTT — used as @mention trigger)"
436
+ : "Device display name (leave empty to auto-detect from device)",
437
+ placeholder: isMqtt
438
+ ? "e.g. OpenClaw"
439
+ : "e.g. OpenClaw (empty = use device's current name)",
359
440
  initialValue: currentNodeName || undefined,
441
+ validate: isMqtt
442
+ ? (value) => (String(value ?? "").trim() ? undefined : "Required for MQTT (no device to auto-detect from)")
443
+ : undefined,
360
444
  }),
361
445
  ).trim();
362
446
  if (nodeNameInput) {
package/src/policy.ts CHANGED
@@ -137,11 +137,15 @@ export function resolveMeshtasticGroupSenderAllowed(params: {
137
137
  const inner = normalizeMeshtasticAllowlist(params.innerAllowFrom);
138
138
  const outer = normalizeMeshtasticAllowlist(params.outerAllowFrom);
139
139
 
140
+ // Fallback strategy: check channel-specific allowlist first, then fall
141
+ // back to the global allowlist. This ensures global admin nodes are
142
+ // never locked out of channels that define their own lists.
140
143
  if (inner.length > 0) {
141
- return resolveMeshtasticAllowlistMatch({
144
+ const innerResult = resolveMeshtasticAllowlistMatch({
142
145
  allowFrom: inner,
143
146
  message: params.message,
144
- }).allowed;
147
+ });
148
+ if (innerResult.allowed) return true;
145
149
  }
146
150
  if (outer.length > 0) {
147
151
  return resolveMeshtasticAllowlistMatch({
package/src/send.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
3
+ import { stripMarkdown } from "openclaw/plugin-sdk";
4
+
2
5
  import { resolveMeshtasticAccount } from "./accounts.js";
3
6
  import { hexToNodeNum, normalizeMeshtasticMessagingTarget } from "./normalize.js";
4
7
  import { getMeshtasticRuntime } from "./runtime.js";
@@ -50,13 +53,15 @@ export async function sendMessageMeshtastic(
50
53
  if (!account.configured) {
51
54
  throw new Error(
52
55
  `Meshtastic is not configured for account "${account.accountId}". ` +
53
- `Set channels.meshtastic.transport and connection details.`,
56
+ `Run 'openclaw setup' or set channels.meshtastic.transport and connection details in config.`,
54
57
  );
55
58
  }
56
59
 
57
60
  const target = normalizeMeshtasticMessagingTarget(to);
58
61
  if (!target) {
59
- throw new Error(`Invalid Meshtastic target: ${to}`);
62
+ throw new Error(
63
+ `Invalid Meshtastic target: "${to}". Use a node ID like "!aabbccdd" or a channel name like "channel:LongFast".`,
64
+ );
60
65
  }
61
66
 
62
67
  const tableMode = runtime.channel.text.resolveMarkdownTableMode({
@@ -65,7 +70,8 @@ export async function sendMessageMeshtastic(
65
70
  accountId: account.accountId,
66
71
  });
67
72
  const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode);
68
- if (!prepared.trim()) {
73
+ const stripped = stripMarkdown(prepared);
74
+ if (!stripped.trim()) {
69
75
  throw new Error("Message must be non-empty for Meshtastic sends");
70
76
  }
71
77
 
@@ -73,17 +79,17 @@ export async function sendMessageMeshtastic(
73
79
 
74
80
  if (transport === "mqtt") {
75
81
  if (activeMqttSend) {
76
- await activeMqttSend(prepared, target, opts.channelName);
82
+ await activeMqttSend(stripped, target, opts.channelName);
77
83
  } else {
78
- throw new Error("No active MQTT connection. Start the gateway first.");
84
+ throw new Error("No active MQTT connection. Run 'openclaw gateway start' to connect.");
79
85
  }
80
86
  } else {
81
87
  // Serial or HTTP: use active transport if available.
82
88
  if (activeSerialSend) {
83
89
  const destination = target.startsWith("!") ? hexToNodeNum(target) : undefined;
84
- await activeSerialSend(prepared, destination, opts.channelIndex);
90
+ await activeSerialSend(stripped, destination, opts.channelIndex);
85
91
  } else {
86
- throw new Error(`No active ${transport} connection. Start the gateway first.`);
92
+ throw new Error(`No active ${transport} connection. Run 'openclaw gateway start' to connect.`);
87
93
  }
88
94
  }
89
95
 
package/src/types.ts CHANGED
@@ -28,6 +28,8 @@ export type MeshtasticMqttConfig = {
28
28
  topic?: string;
29
29
  publishTopic?: string;
30
30
  tls?: boolean;
31
+ /** Own node ID in !hex format — required for DM detection over MQTT. */
32
+ myNodeId?: string;
31
33
  };
32
34
 
33
35
  export type MeshtasticTransport = "serial" | "http" | "mqtt";