@seeed-studio/meshtastic 0.1.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/src/monitor.ts ADDED
@@ -0,0 +1,300 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import { resolveMeshtasticAccount } from "./accounts.js";
4
+ import { connectMeshtasticClient, type MeshtasticClient } from "./client.js";
5
+ import { handleMeshtasticInbound } from "./inbound.js";
6
+ import { connectMeshtasticMqtt, type MeshtasticMqttClient } from "./mqtt-client.js";
7
+ import { nodeNumToHex } from "./normalize.js";
8
+ import { getMeshtasticRuntime } from "./runtime.js";
9
+ import { setActiveSerialSend, setActiveMqttSend } from "./send.js";
10
+ import type { CoreConfig, MeshtasticInboundMessage } from "./types.js";
11
+
12
+ export type MeshtasticMonitorOptions = {
13
+ accountId?: string;
14
+ config?: CoreConfig;
15
+ runtime?: RuntimeEnv;
16
+ abortSignal?: AbortSignal;
17
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
18
+ };
19
+
20
+ export async function monitorMeshtasticProvider(
21
+ opts: MeshtasticMonitorOptions,
22
+ ): Promise<{ stop: () => void }> {
23
+ const core = getMeshtasticRuntime();
24
+ const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
25
+ const account = resolveMeshtasticAccount({
26
+ cfg,
27
+ accountId: opts.accountId,
28
+ });
29
+
30
+ const runtime: RuntimeEnv =
31
+ opts.runtime ??
32
+ createLoggerBackedRuntime({
33
+ logger: core.logging.getChildLogger(),
34
+ exitError: () => new Error("Runtime exit not available"),
35
+ });
36
+
37
+ if (!account.configured) {
38
+ throw new Error(
39
+ `Meshtastic is not configured for account "${account.accountId}". ` +
40
+ `Set channels.meshtastic.transport and connection details.`,
41
+ );
42
+ }
43
+
44
+ const logger = core.logging.getChildLogger({
45
+ channel: "meshtastic",
46
+ accountId: account.accountId,
47
+ });
48
+
49
+ const transport = account.transport;
50
+
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
+ if (transport === "mqtt") {
75
+ return monitorMqtt({ account, cfg: effectiveCfg, runtime, logger, opts });
76
+ }
77
+ return monitorDevice({ account, cfg: effectiveCfg, runtime, logger, opts, transport });
78
+ }
79
+
80
+ async function monitorDevice(params: {
81
+ account: ReturnType<typeof resolveMeshtasticAccount>;
82
+ cfg: CoreConfig;
83
+ runtime: RuntimeEnv;
84
+ logger: ReturnType<ReturnType<typeof getMeshtasticRuntime>["logging"]["getChildLogger"]>;
85
+ opts: MeshtasticMonitorOptions;
86
+ transport: "serial" | "http";
87
+ }): Promise<{ stop: () => void }> {
88
+ const { account, cfg, runtime, logger, opts, transport } = params;
89
+ const core = getMeshtasticRuntime();
90
+
91
+ let client: MeshtasticClient | null = null;
92
+
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
+ };
125
+
126
+ core.channel.activity.record({
127
+ channel: "meshtastic",
128
+ accountId: account.accountId,
129
+ direction: "inbound",
130
+ at: message.timestamp,
131
+ });
132
+
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
+ });
169
+
170
+ // Register active send function for `openclaw message send`.
171
+ setActiveSerialSend((text, destination, channelIndex) =>
172
+ client ? client.sendText(text, destination, true, channelIndex) : Promise.resolve(0),
173
+ );
174
+
175
+ const address =
176
+ transport === "serial"
177
+ ? account.serialPort
178
+ : `${account.httpAddress}${account.httpTls ? " (tls)" : ""}`;
179
+ logger.info(
180
+ `[${account.accountId}] connected via ${transport} (${address}), node ${nodeNumToHex(client.myNodeNum)}`,
181
+ );
182
+
183
+ // Block until the gateway aborts or the device disconnects.
184
+ // Returning from startAccount signals "channel exited" to the framework,
185
+ // which triggers auto-restart. We must stay alive so the serial port
186
+ // remains open and isn't double-locked on reconnect.
187
+ await new Promise<void>((resolve) => {
188
+ if (opts.abortSignal) {
189
+ opts.abortSignal.addEventListener("abort", () => resolve(), { once: true });
190
+ }
191
+ client!.device.events.onDeviceStatus.subscribe((status: number) => {
192
+ if (status === 2 /* DeviceDisconnected */) {
193
+ logger.info(`[${account.accountId}] device disconnected, exiting monitor`);
194
+ resolve();
195
+ }
196
+ });
197
+ });
198
+
199
+ // Cleanup: release the serial port so the next start can open it.
200
+ setActiveSerialSend(null);
201
+ client?.close();
202
+ client = null;
203
+
204
+ // Give the OS time to release the serial port lock before the framework
205
+ // restarts the channel (which would immediately try to reopen it).
206
+ await new Promise<void>((r) => setTimeout(r, 3_000));
207
+
208
+ return { stop: () => {} };
209
+ }
210
+
211
+ async function monitorMqtt(params: {
212
+ account: ReturnType<typeof resolveMeshtasticAccount>;
213
+ cfg: CoreConfig;
214
+ runtime: RuntimeEnv;
215
+ logger: ReturnType<ReturnType<typeof getMeshtasticRuntime>["logging"]["getChildLogger"]>;
216
+ opts: MeshtasticMonitorOptions;
217
+ }): Promise<{ stop: () => void }> {
218
+ const { account, cfg, runtime, logger, opts } = params;
219
+ const core = getMeshtasticRuntime();
220
+ const mqttConfig = account.config.mqtt;
221
+
222
+ if (!mqttConfig?.broker) {
223
+ throw new Error("MQTT broker not configured");
224
+ }
225
+
226
+ let mqttClient: MeshtasticMqttClient | null = null;
227
+
228
+ mqttClient = await connectMeshtasticMqtt({
229
+ mqtt: mqttConfig,
230
+ abortSignal: opts.abortSignal,
231
+ onStatus: (status) => {
232
+ logger.info(`[${account.accountId}] mqtt: ${status}`);
233
+ },
234
+ onError: (error) => {
235
+ logger.error(`[${account.accountId}] mqtt error: ${error.message}`);
236
+ },
237
+ onText: async (event) => {
238
+ const message: MeshtasticInboundMessage = {
239
+ messageId: randomUUID(),
240
+ senderNodeId: event.senderNodeId,
241
+ senderName: event.senderName,
242
+ channelIndex: event.channelIndex,
243
+ channelName: event.channelName ?? `channel-${event.channelIndex}`,
244
+ text: event.text,
245
+ timestamp: event.rxTime,
246
+ isGroup: !event.isDirect,
247
+ };
248
+
249
+ core.channel.activity.record({
250
+ channel: "meshtastic",
251
+ accountId: account.accountId,
252
+ direction: "inbound",
253
+ at: message.timestamp,
254
+ });
255
+
256
+ await handleMeshtasticInbound({
257
+ message,
258
+ account,
259
+ config: cfg,
260
+ runtime,
261
+ sendReply: async (target, text) => {
262
+ if (!mqttClient) {
263
+ return;
264
+ }
265
+ const channelName = message.isGroup ? message.channelName : undefined;
266
+ await mqttClient.sendText(text, message.isGroup ? undefined : target, channelName);
267
+ opts.statusSink?.({ lastOutboundAt: Date.now() });
268
+ core.channel.activity.record({
269
+ channel: "meshtastic",
270
+ accountId: account.accountId,
271
+ direction: "outbound",
272
+ });
273
+ },
274
+ statusSink: opts.statusSink,
275
+ });
276
+ },
277
+ });
278
+
279
+ // Register active send function for `openclaw message send`.
280
+ setActiveMqttSend((text, destination, channelName) =>
281
+ mqttClient ? mqttClient.sendText(text, destination, channelName) : Promise.resolve(),
282
+ );
283
+
284
+ logger.info(
285
+ `[${account.accountId}] connected via mqtt (${mqttConfig.broker}:${mqttConfig.port ?? 1883})`,
286
+ );
287
+
288
+ // Block until the gateway aborts. Same pattern as monitorDevice.
289
+ await new Promise<void>((resolve) => {
290
+ if (opts.abortSignal) {
291
+ opts.abortSignal.addEventListener("abort", () => resolve(), { once: true });
292
+ }
293
+ });
294
+
295
+ setActiveMqttSend(null);
296
+ mqttClient?.close();
297
+ mqttClient = null;
298
+
299
+ return { stop: () => {} };
300
+ }
@@ -0,0 +1,162 @@
1
+ import mqtt from "mqtt";
2
+ import { nodeNumToHex } from "./normalize.js";
3
+ import type { MeshtasticMqttConfig } from "./types.js";
4
+
5
+ export type MeshtasticMqttTextEvent = {
6
+ senderNodeId: string;
7
+ senderName?: string;
8
+ text: string;
9
+ channelIndex: number;
10
+ channelName?: string;
11
+ isDirect: boolean;
12
+ rxTime: number;
13
+ };
14
+
15
+ export type MeshtasticMqttClientOptions = {
16
+ mqtt: MeshtasticMqttConfig;
17
+ myNodeId?: string;
18
+ abortSignal?: AbortSignal;
19
+ onText?: (event: MeshtasticMqttTextEvent) => void | Promise<void>;
20
+ onStatus?: (status: string) => void;
21
+ onError?: (error: Error) => void;
22
+ };
23
+
24
+ export type MeshtasticMqttClient = {
25
+ sendText: (text: string, destination?: string, channelName?: string) => Promise<void>;
26
+ close: () => void;
27
+ };
28
+
29
+ /**
30
+ * Meshtastic MQTT JSON message format.
31
+ * Messages on the JSON topic contain: sender, from, type, payload, channel.
32
+ */
33
+ type MqttJsonMessage = {
34
+ sender?: string;
35
+ from?: number;
36
+ to?: number;
37
+ type?: string;
38
+ payload?: { text?: string };
39
+ channel?: number;
40
+ channel_name?: string;
41
+ };
42
+
43
+ /** Connect to a Meshtastic mesh via MQTT broker. */
44
+ export async function connectMeshtasticMqtt(
45
+ options: MeshtasticMqttClientOptions,
46
+ ): Promise<MeshtasticMqttClient> {
47
+ const mqttConfig = options.mqtt;
48
+ const broker = mqttConfig.broker ?? "mqtt.meshtastic.org";
49
+ const port = mqttConfig.port ?? 1883;
50
+ const username = mqttConfig.username ?? "meshdev";
51
+ const password = mqttConfig.password ?? "large4cats";
52
+ const topic = mqttConfig.topic ?? "msh/US/2/json/#";
53
+ const publishTopic = mqttConfig.publishTopic ?? topic.replace("/#", "/mqtt");
54
+ const protocol = mqttConfig.tls ? "mqtts" : "mqtt";
55
+ const myNodeId = options.myNodeId?.toLowerCase();
56
+
57
+ const client = mqtt.connect(`${protocol}://${broker}:${port}`, {
58
+ username,
59
+ password,
60
+ clean: true,
61
+ reconnectPeriod: 5000,
62
+ });
63
+
64
+ client.on("connect", () => {
65
+ options.onStatus?.("connected");
66
+ client.subscribe(topic, (err) => {
67
+ if (err) {
68
+ options.onError?.(new Error(`MQTT subscribe failed: ${err.message}`));
69
+ } else {
70
+ options.onStatus?.(`subscribed to ${topic}`);
71
+ }
72
+ });
73
+ });
74
+
75
+ client.on("error", (err) => {
76
+ options.onError?.(err);
77
+ });
78
+
79
+ client.on("reconnect", () => {
80
+ options.onStatus?.("reconnecting");
81
+ });
82
+
83
+ client.on("message", async (_topic, payload) => {
84
+ if (!options.onText) {
85
+ return;
86
+ }
87
+
88
+ let msg: MqttJsonMessage;
89
+ try {
90
+ msg = JSON.parse(payload.toString()) as MqttJsonMessage;
91
+ } catch {
92
+ return;
93
+ }
94
+
95
+ // Only handle text messages.
96
+ if (msg.type !== "sendtext" || !msg.payload?.text) {
97
+ return;
98
+ }
99
+
100
+ // Skip own messages.
101
+ const senderNodeId = msg.sender
102
+ ? msg.sender.toLowerCase()
103
+ : msg.from
104
+ ? nodeNumToHex(msg.from)
105
+ : undefined;
106
+ if (!senderNodeId) {
107
+ return;
108
+ }
109
+ if (myNodeId && senderNodeId === myNodeId) {
110
+ return;
111
+ }
112
+
113
+ // 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;
118
+
119
+ const event: MeshtasticMqttTextEvent = {
120
+ senderNodeId: senderNodeId.startsWith("!") ? senderNodeId : `!${senderNodeId}`,
121
+ text: msg.payload.text,
122
+ channelIndex: msg.channel ?? 0,
123
+ channelName: msg.channel_name,
124
+ isDirect,
125
+ rxTime: Date.now(),
126
+ };
127
+
128
+ try {
129
+ await options.onText(event);
130
+ } catch (err) {
131
+ options.onError?.(err instanceof Error ? err : new Error(String(err)));
132
+ }
133
+ });
134
+
135
+ if (options.abortSignal) {
136
+ options.abortSignal.addEventListener(
137
+ "abort",
138
+ () => {
139
+ client.end(true);
140
+ },
141
+ { once: true },
142
+ );
143
+ }
144
+
145
+ return {
146
+ sendText: async (text, destination, channelName) => {
147
+ const outboundTopic = channelName
148
+ ? publishTopic.replace(/\/[^/]*$/, `/${channelName}`)
149
+ : publishTopic;
150
+ const message: MqttJsonMessage = {
151
+ sender: myNodeId ?? options.myNodeId,
152
+ type: "sendtext",
153
+ payload: { text },
154
+ ...(destination ? { to: Number.parseInt(destination.replace("!", ""), 16) } : {}),
155
+ };
156
+ client.publish(outboundTopic, JSON.stringify(message));
157
+ },
158
+ close: () => {
159
+ client.end(true);
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,112 @@
1
+ import type { MeshtasticInboundMessage } from "./types.js";
2
+
3
+ /** Convert numeric node ID to !hex format (e.g. 2882400001 -> "!abcd0001"). */
4
+ export function nodeNumToHex(nodeNum: number): string {
5
+ return `!${nodeNum.toString(16).padStart(8, "0")}`;
6
+ }
7
+
8
+ /** Convert !hex node ID to numeric (e.g. "!abcd0001" -> 2882400001). */
9
+ export function hexToNodeNum(hex: string): number {
10
+ const cleaned = hex.startsWith("!") ? hex.slice(1) : hex;
11
+ const parsed = Number.parseInt(cleaned, 16);
12
+ if (!Number.isFinite(parsed) || parsed < 0) {
13
+ throw new Error(`Invalid Meshtastic node ID: ${hex}`);
14
+ }
15
+ return parsed;
16
+ }
17
+
18
+ /** Normalize a node ID to !hex format. Accepts !hex or numeric string. */
19
+ export function normalizeMeshtasticNodeId(raw: string): string {
20
+ const trimmed = raw.trim().toLowerCase();
21
+ if (!trimmed) {
22
+ return "";
23
+ }
24
+ if (trimmed.startsWith("!")) {
25
+ const hex = trimmed.slice(1);
26
+ if (/^[0-9a-f]{1,8}$/i.test(hex)) {
27
+ return `!${hex.padStart(8, "0")}`;
28
+ }
29
+ return trimmed;
30
+ }
31
+ const num = Number.parseInt(trimmed, 10);
32
+ if (Number.isFinite(num) && num >= 0) {
33
+ return nodeNumToHex(num);
34
+ }
35
+ return trimmed;
36
+ }
37
+
38
+ /** Check if a string looks like a Meshtastic node ID (!hex or numeric). */
39
+ export function looksLikeMeshtasticNodeId(raw: string): boolean {
40
+ const trimmed = raw.trim();
41
+ if (!trimmed) {
42
+ return false;
43
+ }
44
+ if (trimmed.startsWith("!") && /^![0-9a-f]{1,8}$/i.test(trimmed)) {
45
+ return true;
46
+ }
47
+ const num = Number.parseInt(trimmed, 10);
48
+ return Number.isFinite(num) && num >= 0 && String(num) === trimmed;
49
+ }
50
+
51
+ /** Normalize a messaging target. Strips "meshtastic:" prefix, resolves channel: prefix. */
52
+ export function normalizeMeshtasticMessagingTarget(raw: string): string | undefined {
53
+ const trimmed = raw.trim();
54
+ if (!trimmed) {
55
+ return undefined;
56
+ }
57
+ let target = trimmed;
58
+ if (target.toLowerCase().startsWith("meshtastic:")) {
59
+ target = target.slice("meshtastic:".length).trim();
60
+ }
61
+ if (target.toLowerCase().startsWith("channel:")) {
62
+ return target.slice("channel:".length).trim() || undefined;
63
+ }
64
+ if (target.toLowerCase().startsWith("user:")) {
65
+ target = target.slice("user:".length).trim();
66
+ }
67
+ if (!target) {
68
+ return undefined;
69
+ }
70
+ if (looksLikeMeshtasticNodeId(target)) {
71
+ return normalizeMeshtasticNodeId(target);
72
+ }
73
+ return target;
74
+ }
75
+
76
+ /** Normalize an allowlist entry (lowercase, strip meshtastic: prefix). */
77
+ export function normalizeMeshtasticAllowEntry(raw: string): string {
78
+ let value = raw.trim().toLowerCase();
79
+ if (!value) {
80
+ return "";
81
+ }
82
+ if (value.startsWith("meshtastic:")) {
83
+ value = value.slice("meshtastic:".length);
84
+ }
85
+ if (value.startsWith("user:")) {
86
+ value = value.slice("user:".length);
87
+ }
88
+ return normalizeMeshtasticNodeId(value.trim());
89
+ }
90
+
91
+ /** Normalize a list of allowlist entries. */
92
+ export function normalizeMeshtasticAllowlist(entries?: string[]): string[] {
93
+ return (entries ?? []).map((entry) => normalizeMeshtasticAllowEntry(entry)).filter(Boolean);
94
+ }
95
+
96
+ /** Check if sender matches an allowlist. */
97
+ export function resolveMeshtasticAllowlistMatch(params: {
98
+ allowFrom: string[];
99
+ message: MeshtasticInboundMessage;
100
+ }): { allowed: boolean; source?: string } {
101
+ const allowFrom = new Set(
102
+ params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
103
+ );
104
+ if (allowFrom.has("*")) {
105
+ return { allowed: true, source: "wildcard" };
106
+ }
107
+ const nodeId = normalizeMeshtasticNodeId(params.message.senderNodeId).toLowerCase();
108
+ if (nodeId && allowFrom.has(nodeId)) {
109
+ return { allowed: true, source: nodeId };
110
+ }
111
+ return { allowed: false };
112
+ }