@seeed-studio/meshtastic 0.1.0 → 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.
@@ -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
@@ -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,
package/src/monitor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk";
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 setup' 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 setup'.");
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. */