@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.
- 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/config-schema.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
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
|
-
`
|
|
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
|
-
|
|
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 setup'.");
|
|
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. */
|