@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/.github/scripts/translate_readme.py +632 -0
- package/.github/translate/do-not-translate.md +6 -0
- package/.github/translate/glossary/es.md +23 -0
- package/.github/translate/glossary/fr.md +23 -0
- package/.github/translate/glossary/ja.md +23 -0
- package/.github/translate/glossary/pt.md +23 -0
- package/.github/translate/glossary/zh-CN.md +23 -0
- package/.github/translate/languages.json +37 -0
- package/.github/translate/prompts/es.md +16 -0
- package/.github/translate/prompts/fr.md +16 -0
- package/.github/translate/prompts/ja.md +17 -0
- package/.github/translate/prompts/pt.md +16 -0
- package/.github/translate/prompts/zh-CN.md +15 -0
- package/.github/workflows/publish.yml +25 -0
- package/.github/workflows/readme-translate.yml +166 -0
- package/AGENTS.md +172 -0
- package/LICENSE +21 -0
- package/README.es.md +337 -0
- package/README.fr.md +350 -0
- package/README.ja.md +344 -0
- package/README.md +262 -88
- package/README.pt.md +337 -0
- package/README.zh-CN.md +337 -0
- package/package.json +4 -3
- package/src/channel.ts +70 -17
- package/src/client.ts +108 -17
- package/src/config-schema.ts +37 -7
- package/src/inbound.ts +19 -4
- package/src/monitor.ts +131 -104
- package/src/mqtt-client.ts +30 -6
- package/src/normalize.ts +12 -4
- package/src/onboarding.ts +116 -28
- package/src/policy.ts +6 -2
- package/src/send.ts +13 -7
- package/src/types.ts +4 -2
package/src/monitor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/irc";
|
|
3
3
|
import { resolveMeshtasticAccount } from "./accounts.js";
|
|
4
|
-
import { connectMeshtasticClient, type MeshtasticClient } from "./client.js";
|
|
4
|
+
import { connectMeshtasticClient, DeviceStatus, SetOwnerRebootError, type MeshtasticClient } from "./client.js";
|
|
5
5
|
import { handleMeshtasticInbound } from "./inbound.js";
|
|
6
6
|
import { connectMeshtasticMqtt, type MeshtasticMqttClient } from "./mqtt-client.js";
|
|
7
7
|
import { nodeNumToHex } from "./normalize.js";
|
|
@@ -9,6 +9,28 @@ import { getMeshtasticRuntime } from "./runtime.js";
|
|
|
9
9
|
import { setActiveSerialSend, setActiveMqttSend } from "./send.js";
|
|
10
10
|
import type { CoreConfig, MeshtasticInboundMessage } from "./types.js";
|
|
11
11
|
|
|
12
|
+
/** Inject a mention pattern (e.g. "@bard2") into the config so group messages
|
|
13
|
+
* containing the pattern are recognized as mentions. */
|
|
14
|
+
function injectMentionPattern(cfg: CoreConfig, name: string | undefined): CoreConfig {
|
|
15
|
+
if (!name) return cfg;
|
|
16
|
+
const mentionPattern = `@${name}`;
|
|
17
|
+
const existingPatterns =
|
|
18
|
+
(cfg as Record<string, unknown> & { messages?: { groupChat?: { mentionPatterns?: string[] } } })
|
|
19
|
+
.messages?.groupChat?.mentionPatterns ?? [];
|
|
20
|
+
if (existingPatterns.includes(mentionPattern)) return cfg;
|
|
21
|
+
return {
|
|
22
|
+
...cfg,
|
|
23
|
+
messages: {
|
|
24
|
+
...(cfg as Record<string, unknown>).messages as Record<string, unknown> | undefined,
|
|
25
|
+
groupChat: {
|
|
26
|
+
...((cfg as Record<string, unknown>).messages as Record<string, unknown> | undefined)
|
|
27
|
+
?.groupChat as Record<string, unknown> | undefined,
|
|
28
|
+
mentionPatterns: [...existingPatterns, mentionPattern],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
export type MeshtasticMonitorOptions = {
|
|
13
35
|
accountId?: string;
|
|
14
36
|
config?: CoreConfig;
|
|
@@ -37,7 +59,7 @@ export async function monitorMeshtasticProvider(
|
|
|
37
59
|
if (!account.configured) {
|
|
38
60
|
throw new Error(
|
|
39
61
|
`Meshtastic is not configured for account "${account.accountId}". ` +
|
|
40
|
-
`
|
|
62
|
+
`Run 'openclaw onboard' or set channels.meshtastic.transport and connection details in config.`,
|
|
41
63
|
);
|
|
42
64
|
}
|
|
43
65
|
|
|
@@ -48,33 +70,14 @@ export async function monitorMeshtasticProvider(
|
|
|
48
70
|
|
|
49
71
|
const transport = account.transport;
|
|
50
72
|
|
|
51
|
-
// Auto-inject nodeName into mentionPatterns so "@NodeName" triggers replies.
|
|
52
|
-
// buildMentionRegexes reads from cfg.messages.groupChat.mentionPatterns, so
|
|
53
|
-
// we merge nodeName there (not in channel-specific config).
|
|
54
|
-
const nodeName = account.config.nodeName?.trim();
|
|
55
|
-
const mentionPattern = nodeName ? `@${nodeName}` : undefined;
|
|
56
|
-
const existingPatterns =
|
|
57
|
-
(cfg as Record<string, unknown> & { messages?: { groupChat?: { mentionPatterns?: string[] } } })
|
|
58
|
-
.messages?.groupChat?.mentionPatterns ?? [];
|
|
59
|
-
const effectiveCfg =
|
|
60
|
-
mentionPattern && !existingPatterns.includes(mentionPattern)
|
|
61
|
-
? {
|
|
62
|
-
...cfg,
|
|
63
|
-
messages: {
|
|
64
|
-
...(cfg as Record<string, unknown>).messages as Record<string, unknown> | undefined,
|
|
65
|
-
groupChat: {
|
|
66
|
-
...((cfg as Record<string, unknown>).messages as Record<string, unknown> | undefined)
|
|
67
|
-
?.groupChat as Record<string, unknown> | undefined,
|
|
68
|
-
mentionPatterns: [...existingPatterns, mentionPattern],
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
: cfg;
|
|
73
|
-
|
|
74
73
|
if (transport === "mqtt") {
|
|
74
|
+
// MQTT: use config nodeName for mention pattern (no device to read from).
|
|
75
|
+
const effectiveCfg = injectMentionPattern(cfg, account.config.nodeName?.trim());
|
|
75
76
|
return monitorMqtt({ account, cfg: effectiveCfg, runtime, logger, opts });
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
// Serial/HTTP: mention pattern is injected after connection so the device's
|
|
79
|
+
// actual name can be used as fallback when nodeName is not configured.
|
|
80
|
+
return monitorDevice({ account, cfg, runtime, logger, opts, transport });
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
async function monitorDevice(params: {
|
|
@@ -85,87 +88,110 @@ async function monitorDevice(params: {
|
|
|
85
88
|
opts: MeshtasticMonitorOptions;
|
|
86
89
|
transport: "serial" | "http";
|
|
87
90
|
}): Promise<{ stop: () => void }> {
|
|
88
|
-
const { account,
|
|
91
|
+
const { account, runtime, logger, opts, transport } = params;
|
|
92
|
+
let cfg = params.cfg;
|
|
89
93
|
const core = getMeshtasticRuntime();
|
|
90
94
|
|
|
91
95
|
let client: MeshtasticClient | null = null;
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const channelName =
|
|
113
|
-
client.getChannelName(event.channelIndex) ?? `channel-${event.channelIndex}`;
|
|
114
|
-
|
|
115
|
-
const message: MeshtasticInboundMessage = {
|
|
116
|
-
messageId: randomUUID(),
|
|
117
|
-
senderNodeId: event.senderNodeId,
|
|
118
|
-
senderName: event.senderName ?? client.getNodeName(event.senderNodeNum),
|
|
119
|
-
channelIndex: event.channelIndex,
|
|
120
|
-
channelName,
|
|
121
|
-
text: event.text,
|
|
122
|
-
timestamp: event.rxTime,
|
|
123
|
-
isGroup: !event.isDirect,
|
|
124
|
-
};
|
|
97
|
+
try {
|
|
98
|
+
client = await connectMeshtasticClient({
|
|
99
|
+
transport,
|
|
100
|
+
serialPort: account.serialPort,
|
|
101
|
+
httpAddress: account.httpAddress,
|
|
102
|
+
httpTls: account.httpTls,
|
|
103
|
+
region: account.config.region,
|
|
104
|
+
nodeName: account.config.nodeName,
|
|
105
|
+
abortSignal: opts.abortSignal,
|
|
106
|
+
onStatus: (status) => {
|
|
107
|
+
logger.info(`[${account.accountId}] device ${status}`);
|
|
108
|
+
},
|
|
109
|
+
onError: (error) => {
|
|
110
|
+
logger.error(`[${account.accountId}] error: ${error.message}`);
|
|
111
|
+
},
|
|
112
|
+
onText: async (event) => {
|
|
113
|
+
if (!client) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
125
116
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
const channelName =
|
|
118
|
+
client.getChannelName(event.channelIndex) ?? `channel-${event.channelIndex}`;
|
|
119
|
+
|
|
120
|
+
const message: MeshtasticInboundMessage = {
|
|
121
|
+
messageId: randomUUID(),
|
|
122
|
+
senderNodeId: event.senderNodeId,
|
|
123
|
+
senderName: event.senderName ?? client.getNodeName(event.senderNodeNum),
|
|
124
|
+
channelIndex: event.channelIndex,
|
|
125
|
+
channelName,
|
|
126
|
+
text: event.text,
|
|
127
|
+
timestamp: event.rxTime,
|
|
128
|
+
isGroup: !event.isDirect,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
core.channel.activity.record({
|
|
132
|
+
channel: "meshtastic",
|
|
133
|
+
accountId: account.accountId,
|
|
134
|
+
direction: "inbound",
|
|
135
|
+
at: message.timestamp,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await handleMeshtasticInbound({
|
|
139
|
+
message,
|
|
140
|
+
account,
|
|
141
|
+
config: cfg,
|
|
142
|
+
runtime,
|
|
143
|
+
sendReply: async (target, text) => {
|
|
144
|
+
if (!client) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// For DM replies, resolve node number from hex ID.
|
|
148
|
+
// For group replies, broadcast to the same channel.
|
|
149
|
+
if (message.isGroup) {
|
|
150
|
+
// Broadcast: fire-and-forget. The SDK's sendText promise waits
|
|
151
|
+
// for internal queue confirmation which may time out for broadcasts.
|
|
152
|
+
// The radio sends the packet regardless, so we don't await.
|
|
153
|
+
client.sendText(text, undefined, false, message.channelIndex).catch((err) => {
|
|
154
|
+
logger.warn(`[${account.accountId}] broadcast send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
// DM: fire-and-forget. The SDK's sendText awaits ACK from the
|
|
158
|
+
// target node; if ACK times out the promise rejects, but the radio
|
|
159
|
+
// has already transmitted the packet. Awaiting would block
|
|
160
|
+
// subsequent reply chunks.
|
|
161
|
+
const { hexToNodeNum } = await import("./normalize.js");
|
|
162
|
+
const destNum = hexToNodeNum(target);
|
|
163
|
+
client.sendText(text, destNum, true).catch((err) => {
|
|
164
|
+
logger.warn(`[${account.accountId}] DM send failed to ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
|
168
|
+
core.channel.activity.record({
|
|
169
|
+
channel: "meshtastic",
|
|
170
|
+
accountId: account.accountId,
|
|
171
|
+
direction: "outbound",
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
statusSink: opts.statusSink,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof SetOwnerRebootError) {
|
|
180
|
+
logger.info(`[${account.accountId}] ${err.message}`);
|
|
181
|
+
// Wait for the device to finish rebooting before the framework retries.
|
|
182
|
+
logger.info(`[${account.accountId}] waiting 30s for device reboot...`);
|
|
183
|
+
await new Promise((r) => setTimeout(r, 30_000));
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
132
187
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
// For DM replies, resolve node number from hex ID.
|
|
143
|
-
// For group replies, broadcast to the same channel.
|
|
144
|
-
if (message.isGroup) {
|
|
145
|
-
// Broadcast: fire-and-forget. The SDK's sendText promise waits
|
|
146
|
-
// for internal queue confirmation which may time out for broadcasts.
|
|
147
|
-
// The radio sends the packet regardless, so we don't await.
|
|
148
|
-
client.sendText(text, undefined, false, message.channelIndex).catch(() => {});
|
|
149
|
-
} else {
|
|
150
|
-
// DM: fire-and-forget. The SDK's sendText awaits ACK from the
|
|
151
|
-
// target node; if ACK times out the promise rejects, but the radio
|
|
152
|
-
// has already transmitted the packet. Awaiting would block
|
|
153
|
-
// subsequent reply chunks.
|
|
154
|
-
const { hexToNodeNum } = await import("./normalize.js");
|
|
155
|
-
const destNum = hexToNodeNum(target);
|
|
156
|
-
client.sendText(text, destNum, true).catch(() => {});
|
|
157
|
-
}
|
|
158
|
-
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
|
159
|
-
core.channel.activity.record({
|
|
160
|
-
channel: "meshtastic",
|
|
161
|
-
accountId: account.accountId,
|
|
162
|
-
direction: "outbound",
|
|
163
|
-
});
|
|
164
|
-
},
|
|
165
|
-
statusSink: opts.statusSink,
|
|
166
|
-
});
|
|
167
|
-
},
|
|
168
|
-
});
|
|
188
|
+
// Determine the effective device name for @mention matching.
|
|
189
|
+
// If nodeName is configured, use it. Otherwise read the device's actual name.
|
|
190
|
+
const effectiveName = account.config.nodeName?.trim() || client.getMyNodeName();
|
|
191
|
+
cfg = injectMentionPattern(cfg, effectiveName);
|
|
192
|
+
if (effectiveName) {
|
|
193
|
+
logger.info(`[${account.accountId}] mention trigger: @${effectiveName}`);
|
|
194
|
+
}
|
|
169
195
|
|
|
170
196
|
// Register active send function for `openclaw message send`.
|
|
171
197
|
setActiveSerialSend((text, destination, channelIndex) =>
|
|
@@ -189,7 +215,7 @@ async function monitorDevice(params: {
|
|
|
189
215
|
opts.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
190
216
|
}
|
|
191
217
|
client!.device.events.onDeviceStatus.subscribe((status: number) => {
|
|
192
|
-
if (status ===
|
|
218
|
+
if (status === DeviceStatus.Disconnected) {
|
|
193
219
|
logger.info(`[${account.accountId}] device disconnected, exiting monitor`);
|
|
194
220
|
resolve();
|
|
195
221
|
}
|
|
@@ -203,6 +229,7 @@ async function monitorDevice(params: {
|
|
|
203
229
|
|
|
204
230
|
// Give the OS time to release the serial port lock before the framework
|
|
205
231
|
// restarts the channel (which would immediately try to reopen it).
|
|
232
|
+
logger.info(`[${account.accountId}] releasing serial port (3s delay)...`);
|
|
206
233
|
await new Promise<void>((r) => setTimeout(r, 3_000));
|
|
207
234
|
|
|
208
235
|
return { stop: () => {} };
|
|
@@ -220,7 +247,7 @@ async function monitorMqtt(params: {
|
|
|
220
247
|
const mqttConfig = account.config.mqtt;
|
|
221
248
|
|
|
222
249
|
if (!mqttConfig?.broker) {
|
|
223
|
-
throw new Error("MQTT broker not configured");
|
|
250
|
+
throw new Error("MQTT broker not configured. Set channels.meshtastic.mqtt.broker or run 'openclaw onboard'.");
|
|
224
251
|
}
|
|
225
252
|
|
|
226
253
|
let mqttClient: MeshtasticMqttClient | null = null;
|
package/src/mqtt-client.ts
CHANGED
|
@@ -2,6 +2,16 @@ import mqtt from "mqtt";
|
|
|
2
2
|
import { nodeNumToHex } from "./normalize.js";
|
|
3
3
|
import type { MeshtasticMqttConfig } from "./types.js";
|
|
4
4
|
|
|
5
|
+
/** Derive a publish topic from a subscribe topic.
|
|
6
|
+
* Standard pattern: "msh/REGION/NUM/json/#" → "msh/REGION/NUM/json/mqtt".
|
|
7
|
+
* If the topic has no wildcard suffix, appends "/mqtt" as the publish leaf. */
|
|
8
|
+
function derivePublishTopic(subscribeTopic: string): string {
|
|
9
|
+
if (subscribeTopic.endsWith("/#")) {
|
|
10
|
+
return subscribeTopic.slice(0, -2) + "/mqtt";
|
|
11
|
+
}
|
|
12
|
+
return subscribeTopic + "/mqtt";
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export type MeshtasticMqttTextEvent = {
|
|
6
16
|
senderNodeId: string;
|
|
7
17
|
senderName?: string;
|
|
@@ -50,9 +60,16 @@ export async function connectMeshtasticMqtt(
|
|
|
50
60
|
const username = mqttConfig.username ?? "meshdev";
|
|
51
61
|
const password = mqttConfig.password ?? "large4cats";
|
|
52
62
|
const topic = mqttConfig.topic ?? "msh/US/2/json/#";
|
|
53
|
-
const publishTopic = mqttConfig.publishTopic ?? topic
|
|
63
|
+
const publishTopic = mqttConfig.publishTopic ?? derivePublishTopic(topic);
|
|
54
64
|
const protocol = mqttConfig.tls ? "mqtts" : "mqtt";
|
|
55
|
-
const myNodeId = options.myNodeId?.toLowerCase();
|
|
65
|
+
const myNodeId = (mqttConfig.myNodeId ?? options.myNodeId)?.toLowerCase();
|
|
66
|
+
|
|
67
|
+
if (!myNodeId) {
|
|
68
|
+
options.onStatus?.(
|
|
69
|
+
"warning: myNodeId not set — all messages will be treated as group. " +
|
|
70
|
+
"Set channels.meshtastic.mqtt.myNodeId for DM support.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
56
73
|
|
|
57
74
|
const client = mqtt.connect(`${protocol}://${broker}:${port}`, {
|
|
58
75
|
username,
|
|
@@ -111,13 +128,19 @@ export async function connectMeshtasticMqtt(
|
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
// Determine DM vs broadcast.
|
|
114
|
-
// MQTT JSON
|
|
115
|
-
const isDirect = myNodeId
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
// MQTT JSON: if `to` matches our node ID, it's a direct message.
|
|
132
|
+
const isDirect = myNodeId !== undefined
|
|
133
|
+
&& msg.to !== undefined
|
|
134
|
+
&& msg.to !== 0xffffffff
|
|
135
|
+
&& nodeNumToHex(msg.to).toLowerCase() === myNodeId;
|
|
136
|
+
|
|
137
|
+
const senderName = msg.sender && msg.sender !== senderNodeId
|
|
138
|
+
? msg.sender
|
|
139
|
+
: undefined;
|
|
118
140
|
|
|
119
141
|
const event: MeshtasticMqttTextEvent = {
|
|
120
142
|
senderNodeId: senderNodeId.startsWith("!") ? senderNodeId : `!${senderNodeId}`,
|
|
143
|
+
senderName,
|
|
121
144
|
text: msg.payload.text,
|
|
122
145
|
channelIndex: msg.channel ?? 0,
|
|
123
146
|
channelName: msg.channel_name,
|
|
@@ -152,6 +175,7 @@ export async function connectMeshtasticMqtt(
|
|
|
152
175
|
type: "sendtext",
|
|
153
176
|
payload: { text },
|
|
154
177
|
...(destination ? { to: Number.parseInt(destination.replace("!", ""), 16) } : {}),
|
|
178
|
+
...(channelName ? { channel_name: channelName } : {}),
|
|
155
179
|
};
|
|
156
180
|
client.publish(outboundTopic, JSON.stringify(message));
|
|
157
181
|
},
|
package/src/normalize.ts
CHANGED
|
@@ -28,13 +28,16 @@ export function normalizeMeshtasticNodeId(raw: string): string {
|
|
|
28
28
|
}
|
|
29
29
|
return trimmed;
|
|
30
30
|
}
|
|
31
|
+
// Bare hex string (contains at least one a-f character to disambiguate from decimal).
|
|
32
|
+
if (/^[0-9a-f]{1,8}$/i.test(trimmed) && !/^\d+$/.test(trimmed)) {
|
|
33
|
+
return `!${trimmed.padStart(8, "0")}`;
|
|
34
|
+
}
|
|
31
35
|
const num = Number.parseInt(trimmed, 10);
|
|
32
36
|
if (Number.isFinite(num) && num >= 0) {
|
|
33
37
|
return nodeNumToHex(num);
|
|
34
38
|
}
|
|
35
39
|
return trimmed;
|
|
36
40
|
}
|
|
37
|
-
|
|
38
41
|
/** Check if a string looks like a Meshtastic node ID (!hex or numeric). */
|
|
39
42
|
export function looksLikeMeshtasticNodeId(raw: string): boolean {
|
|
40
43
|
const trimmed = raw.trim();
|
|
@@ -44,10 +47,13 @@ export function looksLikeMeshtasticNodeId(raw: string): boolean {
|
|
|
44
47
|
if (trimmed.startsWith("!") && /^![0-9a-f]{1,8}$/i.test(trimmed)) {
|
|
45
48
|
return true;
|
|
46
49
|
}
|
|
50
|
+
// Bare hex string (contains at least one a-f to disambiguate from decimal).
|
|
51
|
+
if (/^[0-9a-f]{1,8}$/i.test(trimmed) && !/^\d+$/.test(trimmed)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
47
54
|
const num = Number.parseInt(trimmed, 10);
|
|
48
55
|
return Number.isFinite(num) && num >= 0 && String(num) === trimmed;
|
|
49
56
|
}
|
|
50
|
-
|
|
51
57
|
/** Normalize a messaging target. Strips "meshtastic:" prefix, resolves channel: prefix. */
|
|
52
58
|
export function normalizeMeshtasticMessagingTarget(raw: string): string | undefined {
|
|
53
59
|
const trimmed = raw.trim();
|
|
@@ -89,8 +95,10 @@ export function normalizeMeshtasticAllowEntry(raw: string): string {
|
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/** Normalize a list of allowlist entries. */
|
|
92
|
-
export function normalizeMeshtasticAllowlist(entries?:
|
|
93
|
-
return (entries ?? [])
|
|
98
|
+
export function normalizeMeshtasticAllowlist(entries?: unknown[]): string[] {
|
|
99
|
+
return (entries ?? [])
|
|
100
|
+
.map((entry) => (typeof entry === "string" ? normalizeMeshtasticAllowEntry(entry) : ""))
|
|
101
|
+
.filter(Boolean);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/** Check if sender matches an allowlist. */
|
package/src/onboarding.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import {
|
|
2
3
|
addWildcardAllowFrom,
|
|
3
4
|
DEFAULT_ACCOUNT_ID,
|
|
4
5
|
formatDocsLink,
|
|
5
|
-
promptAccountId,
|
|
6
|
-
promptChannelAccessConfig,
|
|
7
|
-
type ChannelOnboardingAdapter,
|
|
8
|
-
type ChannelOnboardingDmPolicy,
|
|
9
6
|
type DmPolicy,
|
|
10
7
|
type WizardPrompter,
|
|
11
|
-
} from "openclaw/plugin-sdk";
|
|
8
|
+
} from "openclaw/plugin-sdk/irc";
|
|
9
|
+
import {
|
|
10
|
+
promptAccountId,
|
|
11
|
+
promptChannelAccessConfig,
|
|
12
|
+
} from "openclaw/plugin-sdk/matrix";
|
|
13
|
+
import type {
|
|
14
|
+
ChannelSetupAdapter as ChannelOnboardingAdapter,
|
|
15
|
+
ChannelSetupDmPolicy as ChannelOnboardingDmPolicy,
|
|
16
|
+
} from "openclaw/plugin-sdk/setup";
|
|
12
17
|
import {
|
|
13
18
|
listMeshtasticAccountIds,
|
|
14
19
|
resolveDefaultMeshtasticAccountId,
|
|
@@ -24,6 +29,57 @@ import type {
|
|
|
24
29
|
|
|
25
30
|
const channel = "meshtastic" as const;
|
|
26
31
|
|
|
32
|
+
async function detectMeshtasticSerialPortCandidates(): Promise<string[]> {
|
|
33
|
+
const candidates = new Set<string>();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const devEntries = await readdir("/dev");
|
|
37
|
+
for (const entry of devEntries) {
|
|
38
|
+
if (
|
|
39
|
+
entry.startsWith("cu.usb")
|
|
40
|
+
|| entry.startsWith("tty.usb")
|
|
41
|
+
|| entry.startsWith("ttyUSB")
|
|
42
|
+
|| entry.startsWith("ttyACM")
|
|
43
|
+
) {
|
|
44
|
+
candidates.add(`/dev/${entry}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const byIdEntries = await readdir("/dev/serial/by-id");
|
|
51
|
+
for (const entry of byIdEntries) {
|
|
52
|
+
candidates.add(`/dev/serial/by-id/${entry}`);
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
|
|
56
|
+
return [...candidates].sort((a, b) => a.localeCompare(b));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REGION_OPTIONS: { value: string; label: string }[] = [
|
|
60
|
+
{ value: "US", label: "US (902-928 MHz)" },
|
|
61
|
+
{ value: "EU_433", label: "EU_433 (433 MHz)" },
|
|
62
|
+
{ value: "EU_868", label: "EU_868 (869 MHz)" },
|
|
63
|
+
{ value: "CN", label: "CN (470-510 MHz)" },
|
|
64
|
+
{ value: "JP", label: "JP (920 MHz)" },
|
|
65
|
+
{ value: "ANZ", label: "ANZ (915-928 MHz)" },
|
|
66
|
+
{ value: "KR", label: "KR (920-923 MHz)" },
|
|
67
|
+
{ value: "TW", label: "TW (920-925 MHz)" },
|
|
68
|
+
{ value: "RU", label: "RU (868 MHz)" },
|
|
69
|
+
{ value: "IN", label: "IN (865-867 MHz)" },
|
|
70
|
+
{ value: "TH", label: "TH (920-925 MHz)" },
|
|
71
|
+
{ value: "LORA_24", label: "LORA_24 (2.4 GHz)" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function regionToMqttTopic(region: string): string {
|
|
75
|
+
return `msh/${region}/2/json/#`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseRegionFromTopic(topic: string): string | undefined {
|
|
79
|
+
const match = topic.match(/^msh\/([^/]+)\//);
|
|
80
|
+
return match?.[1];
|
|
81
|
+
}
|
|
82
|
+
|
|
27
83
|
function parseListInput(raw: string): string[] {
|
|
28
84
|
return raw
|
|
29
85
|
.split(/[\n,;]+/g)
|
|
@@ -227,14 +283,39 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
227
283
|
const transport = String(transportChoice) as MeshtasticTransport;
|
|
228
284
|
|
|
229
285
|
if (transport === "serial") {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
const serialCandidates = await detectMeshtasticSerialPortCandidates();
|
|
287
|
+
let serialPort = resolved.serialPort || "";
|
|
288
|
+
let needsManualSerialInput = true;
|
|
289
|
+
|
|
290
|
+
if (serialCandidates.length > 0) {
|
|
291
|
+
const initialDetected = serialCandidates.includes(serialPort)
|
|
292
|
+
? serialPort
|
|
293
|
+
: serialCandidates[0];
|
|
294
|
+
const detectedChoice = await prompter.select({
|
|
295
|
+
message: "Detected serial port",
|
|
296
|
+
options: [
|
|
297
|
+
...serialCandidates.map((value) => ({ value, label: value })),
|
|
298
|
+
{ value: "__manual__", label: "Manual input" },
|
|
299
|
+
],
|
|
300
|
+
initialValue: initialDetected,
|
|
301
|
+
});
|
|
302
|
+
const selected = String(detectedChoice);
|
|
303
|
+
if (selected !== "__manual__") {
|
|
304
|
+
serialPort = selected;
|
|
305
|
+
needsManualSerialInput = false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (needsManualSerialInput) {
|
|
310
|
+
serialPort = String(
|
|
311
|
+
await prompter.text({
|
|
312
|
+
message: "Serial port path",
|
|
313
|
+
placeholder: "/dev/ttyUSB0 or /dev/tty.usbmodem*",
|
|
314
|
+
initialValue: serialPort || undefined,
|
|
315
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
316
|
+
}),
|
|
317
|
+
).trim();
|
|
318
|
+
}
|
|
238
319
|
|
|
239
320
|
next = updateMeshtasticAccountConfig(next, accountId, {
|
|
240
321
|
enabled: true,
|
|
@@ -295,10 +376,20 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
295
376
|
}),
|
|
296
377
|
).trim();
|
|
297
378
|
|
|
379
|
+
// Region selection — generates the default MQTT topic.
|
|
380
|
+
const existingTopic = resolved.config.mqtt?.topic || "msh/US/2/json/#";
|
|
381
|
+
const currentMqttRegion = parseRegionFromTopic(existingTopic) ?? "US";
|
|
382
|
+
const mqttRegionChoice = await prompter.select({
|
|
383
|
+
message: "LoRa region (determines which mesh traffic to receive)",
|
|
384
|
+
options: REGION_OPTIONS,
|
|
385
|
+
initialValue: currentMqttRegion,
|
|
386
|
+
});
|
|
387
|
+
const mqttRegion = String(mqttRegionChoice);
|
|
388
|
+
|
|
298
389
|
const topic = String(
|
|
299
390
|
await prompter.text({
|
|
300
391
|
message: "MQTT subscribe topic",
|
|
301
|
-
initialValue:
|
|
392
|
+
initialValue: regionToMqttTopic(mqttRegion),
|
|
302
393
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
303
394
|
}),
|
|
304
395
|
).trim();
|
|
@@ -328,18 +419,7 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
328
419
|
message: "LoRa region",
|
|
329
420
|
options: [
|
|
330
421
|
{ 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)" },
|
|
422
|
+
...REGION_OPTIONS,
|
|
343
423
|
],
|
|
344
424
|
initialValue: resolved.config.region ?? "UNSET",
|
|
345
425
|
});
|
|
@@ -352,11 +432,19 @@ export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
352
432
|
// Device display name — also used as a mention pattern so users can
|
|
353
433
|
// @NodeName the bot in group channels.
|
|
354
434
|
const currentNodeName = resolveMeshtasticAccount({ cfg: next, accountId }).config.nodeName;
|
|
435
|
+
const isMqtt = transport === "mqtt";
|
|
355
436
|
const nodeNameInput = String(
|
|
356
437
|
await prompter.text({
|
|
357
|
-
message:
|
|
358
|
-
|
|
438
|
+
message: isMqtt
|
|
439
|
+
? "Device display name (required for MQTT — used as @mention trigger)"
|
|
440
|
+
: "Device display name (leave empty to auto-detect from device)",
|
|
441
|
+
placeholder: isMqtt
|
|
442
|
+
? "e.g. OpenClaw"
|
|
443
|
+
: "e.g. OpenClaw (empty = use device's current name)",
|
|
359
444
|
initialValue: currentNodeName || undefined,
|
|
445
|
+
validate: isMqtt
|
|
446
|
+
? (value) => (String(value ?? "").trim() ? undefined : "Required for MQTT (no device to auto-detect from)")
|
|
447
|
+
: undefined,
|
|
360
448
|
}),
|
|
361
449
|
).trim();
|
|
362
450
|
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({
|