@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/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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
358
|
-
|
|
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
|
-
|
|
144
|
+
const innerResult = resolveMeshtasticAllowlistMatch({
|
|
142
145
|
allowFrom: inner,
|
|
143
146
|
message: params.message,
|
|
144
|
-
})
|
|
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
|
-
`
|
|
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(
|
|
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
|
-
|
|
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(
|
|
82
|
+
await activeMqttSend(stripped, target, opts.channelName);
|
|
77
83
|
} else {
|
|
78
|
-
throw new Error("No active MQTT connection.
|
|
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(
|
|
90
|
+
await activeSerialSend(stripped, destination, opts.channelIndex);
|
|
85
91
|
} else {
|
|
86
|
-
throw new Error(`No active ${transport} connection.
|
|
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";
|