@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/README.md +170 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +30 -0
- package/src/accounts.ts +196 -0
- package/src/channel.ts +347 -0
- package/src/client.ts +296 -0
- package/src/config-schema.ts +106 -0
- package/src/inbound.ts +397 -0
- package/src/monitor.ts +300 -0
- package/src/mqtt-client.ts +162 -0
- package/src/normalize.ts +112 -0
- package/src/onboarding.ts +421 -0
- package/src/policy.ts +153 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +100 -0
- package/src/types.ts +108 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildBaseAccountStatusSnapshot,
|
|
3
|
+
buildBaseChannelStatusSummary,
|
|
4
|
+
buildChannelConfigSchema,
|
|
5
|
+
DEFAULT_ACCOUNT_ID,
|
|
6
|
+
deleteAccountFromConfigSection,
|
|
7
|
+
formatPairingApproveHint,
|
|
8
|
+
PAIRING_APPROVED_MESSAGE,
|
|
9
|
+
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
10
|
+
resolveDefaultGroupPolicy,
|
|
11
|
+
setAccountEnabledInConfigSection,
|
|
12
|
+
type ChannelPlugin,
|
|
13
|
+
} from "openclaw/plugin-sdk";
|
|
14
|
+
import {
|
|
15
|
+
listMeshtasticAccountIds,
|
|
16
|
+
resolveDefaultMeshtasticAccountId,
|
|
17
|
+
resolveMeshtasticAccount,
|
|
18
|
+
type ResolvedMeshtasticAccount,
|
|
19
|
+
} from "./accounts.js";
|
|
20
|
+
import { MeshtasticConfigSchema } from "./config-schema.js";
|
|
21
|
+
import { monitorMeshtasticProvider } from "./monitor.js";
|
|
22
|
+
import {
|
|
23
|
+
normalizeMeshtasticMessagingTarget,
|
|
24
|
+
looksLikeMeshtasticNodeId,
|
|
25
|
+
normalizeMeshtasticAllowEntry,
|
|
26
|
+
normalizeMeshtasticNodeId,
|
|
27
|
+
} from "./normalize.js";
|
|
28
|
+
import { meshtasticOnboardingAdapter } from "./onboarding.js";
|
|
29
|
+
import { resolveMeshtasticGroupMatch, resolveMeshtasticRequireMention } from "./policy.js";
|
|
30
|
+
import { getMeshtasticRuntime } from "./runtime.js";
|
|
31
|
+
import { sendMessageMeshtastic } from "./send.js";
|
|
32
|
+
import type { CoreConfig, MeshtasticProbe } from "./types.js";
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
id: "meshtastic",
|
|
36
|
+
label: "Meshtastic",
|
|
37
|
+
selectionLabel: "Meshtastic (plugin)",
|
|
38
|
+
docsPath: "/channels/meshtastic",
|
|
39
|
+
docsLabel: "meshtastic",
|
|
40
|
+
blurb: "LoRa mesh network; configure serial, HTTP, or MQTT transport.",
|
|
41
|
+
order: 80,
|
|
42
|
+
quickstartAllowFrom: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, MeshtasticProbe> = {
|
|
46
|
+
id: "meshtastic",
|
|
47
|
+
meta: {
|
|
48
|
+
...meta,
|
|
49
|
+
quickstartAllowFrom: true,
|
|
50
|
+
},
|
|
51
|
+
onboarding: meshtasticOnboardingAdapter,
|
|
52
|
+
pairing: {
|
|
53
|
+
idLabel: "meshtasticNode",
|
|
54
|
+
normalizeAllowEntry: (entry) => normalizeMeshtasticAllowEntry(entry),
|
|
55
|
+
notifyApproval: async ({ id }) => {
|
|
56
|
+
const normalized = normalizeMeshtasticNodeId(id);
|
|
57
|
+
if (!normalized) {
|
|
58
|
+
throw new Error(`invalid Meshtastic pairing id: ${id}`);
|
|
59
|
+
}
|
|
60
|
+
await sendMessageMeshtastic(normalized, PAIRING_APPROVED_MESSAGE);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
capabilities: {
|
|
64
|
+
chatTypes: ["direct", "group"],
|
|
65
|
+
media: false,
|
|
66
|
+
blockStreaming: true,
|
|
67
|
+
},
|
|
68
|
+
reload: { configPrefixes: ["channels.meshtastic"] },
|
|
69
|
+
configSchema: buildChannelConfigSchema(MeshtasticConfigSchema),
|
|
70
|
+
config: {
|
|
71
|
+
listAccountIds: (cfg) => listMeshtasticAccountIds(cfg as CoreConfig),
|
|
72
|
+
resolveAccount: (cfg, accountId) =>
|
|
73
|
+
resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
74
|
+
defaultAccountId: (cfg) => resolveDefaultMeshtasticAccountId(cfg as CoreConfig),
|
|
75
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
76
|
+
setAccountEnabledInConfigSection({
|
|
77
|
+
cfg: cfg as CoreConfig,
|
|
78
|
+
sectionKey: "meshtastic",
|
|
79
|
+
accountId,
|
|
80
|
+
enabled,
|
|
81
|
+
allowTopLevel: true,
|
|
82
|
+
}),
|
|
83
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
84
|
+
deleteAccountFromConfigSection({
|
|
85
|
+
cfg: cfg as CoreConfig,
|
|
86
|
+
sectionKey: "meshtastic",
|
|
87
|
+
accountId,
|
|
88
|
+
clearBaseFields: ["name", "transport", "serialPort", "httpAddress", "httpTls", "mqtt"],
|
|
89
|
+
}),
|
|
90
|
+
isConfigured: (account) => account.configured,
|
|
91
|
+
describeAccount: (account) => ({
|
|
92
|
+
accountId: account.accountId,
|
|
93
|
+
name: account.name,
|
|
94
|
+
enabled: account.enabled,
|
|
95
|
+
configured: account.configured,
|
|
96
|
+
transport: account.transport,
|
|
97
|
+
serialPort: account.serialPort || undefined,
|
|
98
|
+
httpAddress: account.httpAddress || undefined,
|
|
99
|
+
}),
|
|
100
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
101
|
+
(resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
102
|
+
(entry) => String(entry),
|
|
103
|
+
),
|
|
104
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
105
|
+
allowFrom.map((entry) => normalizeMeshtasticAllowEntry(String(entry))).filter(Boolean),
|
|
106
|
+
resolveDefaultTo: ({ cfg, accountId }) =>
|
|
107
|
+
resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo?.trim() ||
|
|
108
|
+
undefined,
|
|
109
|
+
},
|
|
110
|
+
security: {
|
|
111
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
112
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
113
|
+
const useAccountPath = Boolean(cfg.channels?.meshtastic?.accounts?.[resolvedAccountId]);
|
|
114
|
+
const basePath = useAccountPath
|
|
115
|
+
? `channels.meshtastic.accounts.${resolvedAccountId}.`
|
|
116
|
+
: "channels.meshtastic.";
|
|
117
|
+
return {
|
|
118
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
119
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
120
|
+
policyPath: `${basePath}dmPolicy`,
|
|
121
|
+
allowFromPath: `${basePath}allowFrom`,
|
|
122
|
+
approveHint: formatPairingApproveHint("meshtastic"),
|
|
123
|
+
normalizeEntry: (raw) => normalizeMeshtasticAllowEntry(raw),
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
collectWarnings: ({ account, cfg }) => {
|
|
127
|
+
const warnings: string[] = [];
|
|
128
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
129
|
+
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
|
130
|
+
providerConfigPresent: cfg.channels?.meshtastic !== undefined,
|
|
131
|
+
groupPolicy: account.config.groupPolicy,
|
|
132
|
+
defaultGroupPolicy,
|
|
133
|
+
});
|
|
134
|
+
if (groupPolicy === "open") {
|
|
135
|
+
warnings.push(
|
|
136
|
+
'- Meshtastic channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.meshtastic.groupPolicy="allowlist" with channels.meshtastic.channels.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (account.transport === "mqtt" && !account.config.mqtt?.tls) {
|
|
140
|
+
warnings.push("- Meshtastic MQTT TLS is disabled; credentials are sent in plaintext.");
|
|
141
|
+
}
|
|
142
|
+
return warnings;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
groups: {
|
|
146
|
+
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
|
147
|
+
const account = resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId });
|
|
148
|
+
if (!groupId) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const match = resolveMeshtasticGroupMatch({
|
|
152
|
+
groups: account.config.channels,
|
|
153
|
+
target: groupId,
|
|
154
|
+
});
|
|
155
|
+
return resolveMeshtasticRequireMention({
|
|
156
|
+
groupConfig: match.groupConfig,
|
|
157
|
+
wildcardConfig: match.wildcardConfig,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
|
|
161
|
+
const account = resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId });
|
|
162
|
+
if (!groupId) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const match = resolveMeshtasticGroupMatch({
|
|
166
|
+
groups: account.config.channels,
|
|
167
|
+
target: groupId,
|
|
168
|
+
});
|
|
169
|
+
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
messaging: {
|
|
173
|
+
normalizeTarget: normalizeMeshtasticMessagingTarget,
|
|
174
|
+
targetResolver: {
|
|
175
|
+
looksLikeId: looksLikeMeshtasticNodeId,
|
|
176
|
+
hint: "<!nodeId>",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
resolver: {
|
|
180
|
+
resolveTargets: async ({ inputs, kind }) => {
|
|
181
|
+
return inputs.map((input) => {
|
|
182
|
+
const normalized = normalizeMeshtasticMessagingTarget(input);
|
|
183
|
+
if (!normalized) {
|
|
184
|
+
return {
|
|
185
|
+
input,
|
|
186
|
+
resolved: false,
|
|
187
|
+
note: "invalid Meshtastic target",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (kind === "group") {
|
|
191
|
+
return {
|
|
192
|
+
input,
|
|
193
|
+
resolved: true,
|
|
194
|
+
id: normalized,
|
|
195
|
+
name: normalized,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (!looksLikeMeshtasticNodeId(normalized)) {
|
|
199
|
+
return {
|
|
200
|
+
input,
|
|
201
|
+
resolved: false,
|
|
202
|
+
note: "expected node ID target",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
input,
|
|
207
|
+
resolved: true,
|
|
208
|
+
id: normalized,
|
|
209
|
+
name: normalized,
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
directory: {
|
|
215
|
+
self: async () => null,
|
|
216
|
+
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
217
|
+
const account = resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId });
|
|
218
|
+
const q = query?.trim().toLowerCase() ?? "";
|
|
219
|
+
const ids = new Set<string>();
|
|
220
|
+
|
|
221
|
+
for (const entry of account.config.allowFrom ?? []) {
|
|
222
|
+
const normalized = normalizeMeshtasticAllowEntry(entry);
|
|
223
|
+
if (normalized && normalized !== "*") {
|
|
224
|
+
ids.add(normalized);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const entry of account.config.groupAllowFrom ?? []) {
|
|
228
|
+
const normalized = normalizeMeshtasticAllowEntry(entry);
|
|
229
|
+
if (normalized && normalized !== "*") {
|
|
230
|
+
ids.add(normalized);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const ch of Object.values(account.config.channels ?? {})) {
|
|
234
|
+
for (const entry of ch.allowFrom ?? []) {
|
|
235
|
+
const normalized = normalizeMeshtasticAllowEntry(entry);
|
|
236
|
+
if (normalized && normalized !== "*") {
|
|
237
|
+
ids.add(normalized);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return Array.from(ids)
|
|
243
|
+
.filter((id) => (q ? id.includes(q) : true))
|
|
244
|
+
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
245
|
+
.map((id) => ({ kind: "user", id }));
|
|
246
|
+
},
|
|
247
|
+
listGroups: async ({ cfg, accountId, query, limit }) => {
|
|
248
|
+
const account = resolveMeshtasticAccount({ cfg: cfg as CoreConfig, accountId });
|
|
249
|
+
const q = query?.trim().toLowerCase() ?? "";
|
|
250
|
+
const groupIds = new Set<string>();
|
|
251
|
+
|
|
252
|
+
for (const group of Object.keys(account.config.channels ?? {})) {
|
|
253
|
+
if (group === "*") {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
groupIds.add(group);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return Array.from(groupIds)
|
|
260
|
+
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
261
|
+
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
262
|
+
.map((id) => ({ kind: "group", id, name: id }));
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
outbound: {
|
|
266
|
+
deliveryMode: "direct",
|
|
267
|
+
chunker: (text, limit) => getMeshtasticRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
268
|
+
chunkerMode: "markdown",
|
|
269
|
+
textChunkLimit: 200,
|
|
270
|
+
sendText: async ({ to, text, accountId }) => {
|
|
271
|
+
const result = await sendMessageMeshtastic(to, text, {
|
|
272
|
+
accountId: accountId ?? undefined,
|
|
273
|
+
});
|
|
274
|
+
return { channel: "meshtastic", ...result };
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
status: {
|
|
278
|
+
defaultRuntime: {
|
|
279
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
280
|
+
running: false,
|
|
281
|
+
lastStartAt: null,
|
|
282
|
+
lastStopAt: null,
|
|
283
|
+
lastError: null,
|
|
284
|
+
},
|
|
285
|
+
buildChannelSummary: ({ account, snapshot }) => ({
|
|
286
|
+
...buildBaseChannelStatusSummary(snapshot),
|
|
287
|
+
transport: account.transport,
|
|
288
|
+
serialPort: account.serialPort || undefined,
|
|
289
|
+
httpAddress: account.httpAddress || undefined,
|
|
290
|
+
probe: snapshot.probe,
|
|
291
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
292
|
+
}),
|
|
293
|
+
probeAccount: async ({ account }) => {
|
|
294
|
+
// Meshtastic probing is transport-dependent and may require
|
|
295
|
+
// active device connection. Return a basic status.
|
|
296
|
+
if (!account.configured) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
error: "not configured",
|
|
300
|
+
transport: account.transport,
|
|
301
|
+
} as MeshtasticProbe;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
transport: account.transport,
|
|
306
|
+
address:
|
|
307
|
+
account.transport === "serial"
|
|
308
|
+
? account.serialPort
|
|
309
|
+
: account.transport === "http"
|
|
310
|
+
? account.httpAddress
|
|
311
|
+
: account.config.mqtt?.broker,
|
|
312
|
+
} as MeshtasticProbe;
|
|
313
|
+
},
|
|
314
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
315
|
+
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
316
|
+
transport: account.transport,
|
|
317
|
+
serialPort: account.serialPort || undefined,
|
|
318
|
+
httpAddress: account.httpAddress || undefined,
|
|
319
|
+
}),
|
|
320
|
+
},
|
|
321
|
+
gateway: {
|
|
322
|
+
startAccount: async (ctx) => {
|
|
323
|
+
const account = ctx.account;
|
|
324
|
+
if (!account.configured) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Meshtastic is not configured for account "${account.accountId}". ` +
|
|
327
|
+
`Set channels.meshtastic.transport and connection details.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const transportDesc =
|
|
331
|
+
account.transport === "serial"
|
|
332
|
+
? `serial (${account.serialPort})`
|
|
333
|
+
: account.transport === "http"
|
|
334
|
+
? `http (${account.httpAddress}${account.httpTls ? " tls" : ""})`
|
|
335
|
+
: `mqtt (${account.config.mqtt?.broker ?? "?"})`;
|
|
336
|
+
ctx.log?.info(`[${account.accountId}] starting Meshtastic provider (${transportDesc})`);
|
|
337
|
+
const { stop } = await monitorMeshtasticProvider({
|
|
338
|
+
accountId: account.accountId,
|
|
339
|
+
config: ctx.cfg as CoreConfig,
|
|
340
|
+
runtime: ctx.runtime,
|
|
341
|
+
abortSignal: ctx.abortSignal,
|
|
342
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
343
|
+
});
|
|
344
|
+
return { stop };
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { MeshDevice } from "@meshtastic/core";
|
|
2
|
+
import { nodeNumToHex } from "./normalize.js";
|
|
3
|
+
import type { MeshtasticRegion } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type MeshtasticTextEvent = {
|
|
6
|
+
senderNodeNum: number;
|
|
7
|
+
senderNodeId: string;
|
|
8
|
+
senderName?: string;
|
|
9
|
+
text: string;
|
|
10
|
+
channelIndex: number;
|
|
11
|
+
isDirect: boolean;
|
|
12
|
+
rxTime: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MeshtasticClientOptions = {
|
|
16
|
+
transport: "serial" | "http";
|
|
17
|
+
serialPort?: string;
|
|
18
|
+
httpAddress?: string;
|
|
19
|
+
httpTls?: boolean;
|
|
20
|
+
/** LoRa region to set on the device after connection. */
|
|
21
|
+
region?: MeshtasticRegion;
|
|
22
|
+
/** Device display name — sets the node's longName on connect. */
|
|
23
|
+
nodeName?: string;
|
|
24
|
+
abortSignal?: AbortSignal;
|
|
25
|
+
onText?: (event: MeshtasticTextEvent) => void | Promise<void>;
|
|
26
|
+
onStatus?: (status: string) => void;
|
|
27
|
+
onError?: (error: Error) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MeshtasticClient = {
|
|
31
|
+
device: MeshDevice;
|
|
32
|
+
myNodeNum: number;
|
|
33
|
+
sendText: (
|
|
34
|
+
text: string,
|
|
35
|
+
destination?: number,
|
|
36
|
+
wantAck?: boolean,
|
|
37
|
+
channelIndex?: number,
|
|
38
|
+
) => Promise<number>;
|
|
39
|
+
getNodeName: (nodeNum: number) => string | undefined;
|
|
40
|
+
getChannelName: (index: number) => string | undefined;
|
|
41
|
+
close: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Patch SerialPort.prototype.close to not throw when the port is already
|
|
46
|
+
* closed. The @meshtastic/transport-node-serial `create()` factory has an
|
|
47
|
+
* `onError` handler that calls `port.close()` on a port that may never have
|
|
48
|
+
* opened (e.g. "Resource temporarily unavailable / Cannot lock port"). The
|
|
49
|
+
* synchronous throw from the native close() becomes an uncaught exception.
|
|
50
|
+
*/
|
|
51
|
+
async function patchSerialPortClose(): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const { SerialPort } = (await import("serialport")) as {
|
|
54
|
+
SerialPort: { prototype: { close: ((...a: unknown[]) => unknown) & { __patched?: boolean } } };
|
|
55
|
+
};
|
|
56
|
+
const proto = SerialPort.prototype;
|
|
57
|
+
if (proto.close && !proto.close.__patched) {
|
|
58
|
+
const origClose = proto.close;
|
|
59
|
+
const patched = function patchedClose(this: { isOpen?: boolean }, ...args: unknown[]) {
|
|
60
|
+
if (!this.isOpen) {
|
|
61
|
+
// Port already closed — invoke callback (if any) with the error
|
|
62
|
+
// instead of throwing synchronously.
|
|
63
|
+
const cb = typeof args[args.length - 1] === "function" ? args[args.length - 1] : undefined;
|
|
64
|
+
if (cb) (cb as (e: Error) => void)(new Error("Port is not open"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
return origClose.apply(this, args);
|
|
68
|
+
};
|
|
69
|
+
patched.__patched = true;
|
|
70
|
+
proto.close = patched as typeof proto.close;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort; serialport may not be installed (HTTP transport).
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Connect to a Meshtastic device via serial or HTTP transport. */
|
|
78
|
+
export async function connectMeshtasticClient(
|
|
79
|
+
options: MeshtasticClientOptions,
|
|
80
|
+
): Promise<MeshtasticClient> {
|
|
81
|
+
let transport;
|
|
82
|
+
if (options.transport === "serial") {
|
|
83
|
+
// Patch before create() so the factory's onError handler won't throw.
|
|
84
|
+
await patchSerialPortClose();
|
|
85
|
+
const { TransportNodeSerial } = await import("@meshtastic/transport-node-serial");
|
|
86
|
+
transport = await TransportNodeSerial.create(options.serialPort ?? "");
|
|
87
|
+
} else {
|
|
88
|
+
const { TransportHTTP } = await import("@meshtastic/transport-http");
|
|
89
|
+
const address = options.httpAddress ?? "meshtastic.local";
|
|
90
|
+
const prefix = options.httpTls ? "https" : "http";
|
|
91
|
+
transport = await TransportHTTP.create(`${prefix}://${address}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const device = new MeshDevice(transport);
|
|
95
|
+
|
|
96
|
+
// Node info cache for name resolution.
|
|
97
|
+
const nodeNames = new Map<number, string>();
|
|
98
|
+
const channelNames = new Map<number, string>();
|
|
99
|
+
let myNodeNum = 0;
|
|
100
|
+
|
|
101
|
+
// Subscribe to device events before configuring.
|
|
102
|
+
// Event types from @meshtastic/core are complex generics; use explicit shapes.
|
|
103
|
+
device.events.onMyNodeInfo.subscribe((info: { myNodeNum?: number }) => {
|
|
104
|
+
if (info.myNodeNum) {
|
|
105
|
+
myNodeNum = info.myNodeNum;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
device.events.onNodeInfoPacket.subscribe(
|
|
110
|
+
(packet: { data?: { num?: number; user?: { longName?: string } } }) => {
|
|
111
|
+
if (packet.data?.user?.longName && packet.data.num) {
|
|
112
|
+
nodeNames.set(packet.data.num, packet.data.user.longName);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
device.events.onConfigPacket.subscribe(
|
|
118
|
+
(packet: {
|
|
119
|
+
data?: {
|
|
120
|
+
payloadVariantCase?: string;
|
|
121
|
+
payloadVariant?: { value?: { index?: number; settings?: { name?: string } } };
|
|
122
|
+
};
|
|
123
|
+
}) => {
|
|
124
|
+
// Capture channel config for name resolution.
|
|
125
|
+
if (packet.data?.payloadVariantCase === "channels") {
|
|
126
|
+
const ch = packet.data.payloadVariant?.value;
|
|
127
|
+
if (ch && typeof ch.index === "number" && ch.settings?.name) {
|
|
128
|
+
channelNames.set(ch.index, ch.settings.name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
135
|
+
options.onStatus?.(`status=${status}`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
device.events.onMessagePacket.subscribe(
|
|
139
|
+
async (packet: { from?: number; to?: number; channel?: number; data?: unknown }) => {
|
|
140
|
+
if (!options.onText) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const from = packet.from;
|
|
144
|
+
if (!from || from === myNodeNum) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const text =
|
|
148
|
+
typeof packet.data === "string" ? packet.data : (packet.data as { text?: string })?.text;
|
|
149
|
+
if (!text) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const senderNodeId = nodeNumToHex(from);
|
|
154
|
+
const channelIndex = packet.channel ?? 0;
|
|
155
|
+
// Direct message: packet.to equals our node number.
|
|
156
|
+
const isDirect = packet.to === myNodeNum;
|
|
157
|
+
|
|
158
|
+
const event: MeshtasticTextEvent = {
|
|
159
|
+
senderNodeNum: from,
|
|
160
|
+
senderNodeId,
|
|
161
|
+
senderName: nodeNames.get(from),
|
|
162
|
+
text: typeof text === "string" ? text : String(text),
|
|
163
|
+
channelIndex,
|
|
164
|
+
isDirect,
|
|
165
|
+
rxTime: Date.now(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await options.onText(event);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// device.disconnect() may reject asynchronously (WritableStream already
|
|
177
|
+
// closed by the transport layer after a USB disconnect). A plain try/catch
|
|
178
|
+
// only catches synchronous throws, so we must also swallow the returned
|
|
179
|
+
// promise's rejection.
|
|
180
|
+
const safeDisconnect = () => {
|
|
181
|
+
try {
|
|
182
|
+
const result = device.disconnect();
|
|
183
|
+
if (result && typeof (result as Promise<unknown>).catch === "function") {
|
|
184
|
+
(result as Promise<unknown>).catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Best-effort cleanup.
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Configure the device (loads channels, nodes, config).
|
|
192
|
+
// The SDK's configure() sends `wantConfigId` via sendRaw(), which also
|
|
193
|
+
// triggers the serial transport to start connecting. Because the first
|
|
194
|
+
// sendRaw call happens before the transport is connected, the `wantConfigId`
|
|
195
|
+
// packet is often lost. We re-call configure() once DeviceConnected is
|
|
196
|
+
// reached so the device actually receives the config request.
|
|
197
|
+
let configureRetried = false;
|
|
198
|
+
const configured = new Promise<void>((resolve, reject) => {
|
|
199
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
200
|
+
const cleanup = () => {
|
|
201
|
+
clearInterval(poll);
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
};
|
|
204
|
+
const timeout = setTimeout(() => {
|
|
205
|
+
cleanup();
|
|
206
|
+
reject(new Error("device configure timed out (45 s)"));
|
|
207
|
+
}, 45_000);
|
|
208
|
+
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
209
|
+
if (status === 7 /* DeviceConfigured */) {
|
|
210
|
+
cleanup();
|
|
211
|
+
resolve();
|
|
212
|
+
} else if (status === 5 /* DeviceConnected */ && !configureRetried) {
|
|
213
|
+
// Transport is now connected — re-send the config request after a
|
|
214
|
+
// short delay so the serial pipe is fully established.
|
|
215
|
+
configureRetried = true;
|
|
216
|
+
setTimeout(() => device.configure().catch(() => {}), 500);
|
|
217
|
+
} else if (status === 2 /* DeviceDisconnected */) {
|
|
218
|
+
cleanup();
|
|
219
|
+
reject(new Error("device disconnected during configure"));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// Poll as fallback — ste-core dispatch can miss late subscribers.
|
|
223
|
+
poll = setInterval(() => {
|
|
224
|
+
if (
|
|
225
|
+
(device as unknown as { isConfigured: boolean }).isConfigured ||
|
|
226
|
+
(device as unknown as { deviceStatus: number }).deviceStatus === 7
|
|
227
|
+
) {
|
|
228
|
+
cleanup();
|
|
229
|
+
resolve();
|
|
230
|
+
}
|
|
231
|
+
}, 2_000);
|
|
232
|
+
});
|
|
233
|
+
// First configure() call kicks off the transport connection.
|
|
234
|
+
device.configure().catch(() => {});
|
|
235
|
+
try {
|
|
236
|
+
await configured;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Configuration failed — disconnect the device to release the serial port
|
|
239
|
+
// so retries can reopen it.
|
|
240
|
+
safeDisconnect();
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// LoRa region: rely on NVS-persisted config set via `meshtastic --set lora.region`.
|
|
245
|
+
// Sending a partial setConfig (region-only) zeroes out tx_enabled, tx_power, etc.
|
|
246
|
+
// in the protobuf message, effectively disabling TX. So we skip setConfig here.
|
|
247
|
+
|
|
248
|
+
// Set device display name if configured. Fire-and-forget for the same reason.
|
|
249
|
+
if (options.nodeName?.trim()) {
|
|
250
|
+
const longName = options.nodeName.trim();
|
|
251
|
+
const shortName = longName.slice(0, 4);
|
|
252
|
+
device
|
|
253
|
+
.setOwner({ longName, shortName } as Parameters<typeof device.setOwner>[0])
|
|
254
|
+
.catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Catch unhandled promise rejections originating from @meshtastic/core's
|
|
258
|
+
// internal transport (e.g. WritableStream.close() on a broken serial port).
|
|
259
|
+
// Without this the process crashes with exit code 1.
|
|
260
|
+
let disposed = false;
|
|
261
|
+
const rejectionGuard = (reason: unknown) => {
|
|
262
|
+
const msg = reason instanceof Error ? reason.message : String(reason ?? "");
|
|
263
|
+
if (/WritableStream|Invalid state/i.test(msg)) {
|
|
264
|
+
// Suppress — handled. Return without rethrowing so the process
|
|
265
|
+
// doesn't crash.
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Non-transport rejections: let the default handler deal with them.
|
|
269
|
+
// We can't rethrow here (would crash), so just log.
|
|
270
|
+
console.error("[meshtastic] unhandled rejection:", reason);
|
|
271
|
+
};
|
|
272
|
+
process.on("unhandledRejection", rejectionGuard);
|
|
273
|
+
|
|
274
|
+
if (options.abortSignal) {
|
|
275
|
+
options.abortSignal.addEventListener("abort", () => safeDisconnect(), { once: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
device,
|
|
280
|
+
get myNodeNum() {
|
|
281
|
+
return myNodeNum;
|
|
282
|
+
},
|
|
283
|
+
sendText: (text, destination, wantAck = true, channelIndex) =>
|
|
284
|
+
device.sendText(text, destination, wantAck, channelIndex),
|
|
285
|
+
getNodeName: (nodeNum) => nodeNames.get(nodeNum),
|
|
286
|
+
getChannelName: (index) => channelNames.get(index) || (index === 0 ? "LongFast" : undefined),
|
|
287
|
+
close: () => {
|
|
288
|
+
safeDisconnect();
|
|
289
|
+
// Remove rejection guard — no longer needed after disconnect.
|
|
290
|
+
if (!disposed) {
|
|
291
|
+
disposed = true;
|
|
292
|
+
process.off("unhandledRejection", rejectionGuard);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|