@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/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
- `Set channels.meshtastic.transport and connection details.`,
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
- return monitorDevice({ account, cfg: effectiveCfg, runtime, logger, opts, transport });
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, cfg, runtime, logger, opts, transport } = params;
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
- client = await connectMeshtasticClient({
94
- transport,
95
- serialPort: account.serialPort,
96
- httpAddress: account.httpAddress,
97
- httpTls: account.httpTls,
98
- region: account.config.region,
99
- nodeName: account.config.nodeName,
100
- abortSignal: opts.abortSignal,
101
- onStatus: (status) => {
102
- logger.info(`[${account.accountId}] device ${status}`);
103
- },
104
- onError: (error) => {
105
- logger.error(`[${account.accountId}] error: ${error.message}`);
106
- },
107
- onText: async (event) => {
108
- if (!client) {
109
- return;
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
- core.channel.activity.record({
127
- channel: "meshtastic",
128
- accountId: account.accountId,
129
- direction: "inbound",
130
- at: message.timestamp,
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
- await handleMeshtasticInbound({
134
- message,
135
- account,
136
- config: cfg,
137
- runtime,
138
- sendReply: async (target, text) => {
139
- if (!client) {
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 === 2 /* DeviceDisconnected */) {
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;
@@ -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.replace("/#", "/mqtt");
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 doesn't clearly distinguish DM; if `to` is a specific node and matches our ID, it's direct.
115
- const isDirect = myNodeId
116
- ? msg.to !== undefined && nodeNumToHex(msg.to).toLowerCase() === myNodeId
117
- : false;
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?: string[]): string[] {
93
- return (entries ?? []).map((entry) => normalizeMeshtasticAllowEntry(entry)).filter(Boolean);
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 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();
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: resolved.config.mqtt?.topic || "msh/US/2/json/#",
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
- { 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)" },
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: "Device display name (also used as @mention trigger)",
358
- placeholder: "e.g. OpenClaw",
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
- 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({