neoagent 2.3.1-beta.32 → 2.3.1-beta.34
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/package.json +1 -2
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/services/ai/tools.js +6 -1
- package/server/services/mcp/client.js +120 -23
- package/server/services/messaging/meshtastic.js +44 -137
- package/server/services/messaging/meshtastic_protocol.js +473 -0
- package/server/services/messaging/meshtastic_tcp_transport.js +9 -131
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neoagent",
|
|
3
|
-
"version": "2.3.1-beta.
|
|
3
|
+
"version": "2.3.1-beta.34",
|
|
4
4
|
"description": "Proactive personal AI agent with no limits",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
55
55
|
"@google/generative-ai": "^0.24.0",
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
57
|
-
"@meshtastic/core": "^2.6.7",
|
|
58
57
|
"baileys": "^6.7.21",
|
|
59
58
|
"bcrypt": "^6.0.0",
|
|
60
59
|
"better-sqlite3": "^11.8.1",
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"42d3d75a56efe1a2e9902f52dc8006099c45d9
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "4059918015" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -2744,7 +2744,12 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
2744
2744
|
const { detectPromptInjection } = require('../../utils/security');
|
|
2745
2745
|
const mcpManager = mcp();
|
|
2746
2746
|
if (mcpManager) {
|
|
2747
|
-
|
|
2747
|
+
let mcpResult = null;
|
|
2748
|
+
try {
|
|
2749
|
+
mcpResult = await mcpManager.callToolByName(toolName, args, userId, { agentId });
|
|
2750
|
+
} catch (mcpErr) {
|
|
2751
|
+
return { error: mcpErr.message, tool: toolName, source: 'mcp' };
|
|
2752
|
+
}
|
|
2748
2753
|
if (mcpResult !== null) {
|
|
2749
2754
|
const resultText = typeof mcpResult === 'string' ? mcpResult : JSON.stringify(mcpResult);
|
|
2750
2755
|
if (detectPromptInjection(resultText)) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
const EventEmitter = require('events');
|
|
2
4
|
const crypto = require('crypto');
|
|
3
5
|
const db = require('../../db/database');
|
|
@@ -5,6 +7,9 @@ const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
|
5
7
|
const { SSEClientTransport } = require('@modelcontextprotocol/sdk/client/sse.js');
|
|
6
8
|
const { validateRemoteMcpEndpoint } = require('../runtime/mcp');
|
|
7
9
|
|
|
10
|
+
const CONSECUTIVE_FAIL_LIMIT = 3;
|
|
11
|
+
const RECONNECT_DELAY_MS = 60_000;
|
|
12
|
+
|
|
8
13
|
class DBAuthProvider {
|
|
9
14
|
constructor(serverId, clientId, authServerUrl) {
|
|
10
15
|
this.serverId = serverId;
|
|
@@ -50,7 +55,6 @@ class DBAuthProvider {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
redirectToAuthorization(authorizationUrl) {
|
|
53
|
-
// Throw error so the API route catches it and returns the URL to the frontend
|
|
54
58
|
throw new Error(`OAUTH_REDIRECT:${authorizationUrl.toString()}`);
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -66,10 +70,30 @@ class DBAuthProvider {
|
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
function extractErrorMessage(err) {
|
|
74
|
+
const raw = err?.message || String(err || 'Unknown error');
|
|
75
|
+
// Strip HTML bodies (e.g. Cloudflare 530 error pages) — keep only the first line
|
|
76
|
+
if (raw.includes('<!doctype') || raw.includes('<html') || raw.includes('<!DOCTYPE')) {
|
|
77
|
+
const httpMatch = raw.match(/HTTP (\d+)/i);
|
|
78
|
+
return httpMatch
|
|
79
|
+
? `Server returned HTTP ${httpMatch[1]} — the MCP endpoint may be down or misconfigured`
|
|
80
|
+
: 'Server returned an HTML error page — the MCP endpoint may be down or misconfigured';
|
|
81
|
+
}
|
|
82
|
+
// ECONNREFUSED: pull out just the host/port
|
|
83
|
+
if (err?.code === 'ECONNREFUSED' || raw.includes('ECONNREFUSED')) {
|
|
84
|
+
const addrMatch = raw.match(/connect ECONNREFUSED ([^\s,]+)/);
|
|
85
|
+
return addrMatch
|
|
86
|
+
? `Connection refused at ${addrMatch[1]} — is the MCP server running?`
|
|
87
|
+
: 'Connection refused — the MCP server is not reachable';
|
|
88
|
+
}
|
|
89
|
+
return raw.split('\n')[0].trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
69
92
|
class MCPClient extends EventEmitter {
|
|
70
93
|
constructor() {
|
|
71
94
|
super();
|
|
72
95
|
this.servers = new Map();
|
|
96
|
+
this._reconnectTimers = new Map();
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
async startServer(serverId, url, name = '', userId = null, options = {}) {
|
|
@@ -90,13 +114,12 @@ class MCPClient extends EventEmitter {
|
|
|
90
114
|
|
|
91
115
|
const transportOpts = {
|
|
92
116
|
requestInit: { headers: {} },
|
|
93
|
-
eventSourceInit: { headers: {} }
|
|
117
|
+
eventSourceInit: { headers: {} },
|
|
94
118
|
};
|
|
95
119
|
|
|
96
120
|
if (authObj.type === 'bearer' && authObj.token) {
|
|
97
121
|
const h = `Bearer ${authObj.token}`;
|
|
98
122
|
transportOpts.requestInit.headers['Authorization'] = h;
|
|
99
|
-
// Native EventSource doesn't support headers well in browsers, but Node.js EventSource / sse.js might
|
|
100
123
|
transportOpts.eventSourceInit.headers['Authorization'] = h;
|
|
101
124
|
} else if (authObj.type === 'oauth') {
|
|
102
125
|
transportOpts.authProvider = new DBAuthProvider(serverId, authObj.clientId, authObj.authServerUrl);
|
|
@@ -105,7 +128,7 @@ class MCPClient extends EventEmitter {
|
|
|
105
128
|
const transport = new SSEClientTransport(new URL(endpoint), transportOpts);
|
|
106
129
|
const client = new Client(
|
|
107
130
|
{ name: 'NeoAgent', version: '1.0.0' },
|
|
108
|
-
{ capabilities: { tools: {} } }
|
|
131
|
+
{ capabilities: { tools: {} } },
|
|
109
132
|
);
|
|
110
133
|
|
|
111
134
|
const serverObj = {
|
|
@@ -119,7 +142,9 @@ class MCPClient extends EventEmitter {
|
|
|
119
142
|
client,
|
|
120
143
|
transport,
|
|
121
144
|
tools: [],
|
|
122
|
-
status: 'starting'
|
|
145
|
+
status: 'starting',
|
|
146
|
+
consecutiveFails: 0,
|
|
147
|
+
lastError: null,
|
|
123
148
|
};
|
|
124
149
|
|
|
125
150
|
this.servers.set(serverId, serverObj);
|
|
@@ -129,17 +154,24 @@ class MCPClient extends EventEmitter {
|
|
|
129
154
|
const server = this.servers.get(serverId);
|
|
130
155
|
if (server) {
|
|
131
156
|
server.status = 'running';
|
|
157
|
+
server.consecutiveFails = 0;
|
|
158
|
+
server.lastError = null;
|
|
132
159
|
this.emit('server_status', { serverId, status: 'running' });
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
return { status: 'running' };
|
|
136
163
|
} catch (err) {
|
|
164
|
+
const message = extractErrorMessage(err);
|
|
137
165
|
const server = this.servers.get(serverId);
|
|
138
166
|
if (server) {
|
|
167
|
+
server.consecutiveFails = (server.consecutiveFails || 0) + 1;
|
|
168
|
+
server.lastError = message;
|
|
139
169
|
server.status = 'error';
|
|
140
|
-
this.emit('server_status', { serverId, status: 'error', error:
|
|
170
|
+
this.emit('server_status', { serverId, status: 'error', error: message });
|
|
141
171
|
}
|
|
142
|
-
|
|
172
|
+
const friendlyErr = new Error(message);
|
|
173
|
+
friendlyErr.originalError = err;
|
|
174
|
+
throw friendlyErr;
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
|
|
@@ -149,26 +181,62 @@ class MCPClient extends EventEmitter {
|
|
|
149
181
|
throw new Error(`Server ${serverId} transport not initialized`);
|
|
150
182
|
}
|
|
151
183
|
await server.transport.finishAuth(code);
|
|
152
|
-
await server.client.connect(server.transport).catch(() => {
|
|
184
|
+
await server.client.connect(server.transport).catch(() => {});
|
|
153
185
|
|
|
154
186
|
server.status = 'running';
|
|
187
|
+
server.consecutiveFails = 0;
|
|
188
|
+
server.lastError = null;
|
|
155
189
|
this.emit('server_status', { serverId, status: 'running' });
|
|
156
190
|
}
|
|
157
191
|
|
|
158
192
|
async stopServer(serverId) {
|
|
193
|
+
const timer = this._reconnectTimers.get(serverId);
|
|
194
|
+
if (timer) {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
this._reconnectTimers.delete(serverId);
|
|
197
|
+
}
|
|
198
|
+
|
|
159
199
|
const server = this.servers.get(serverId);
|
|
160
200
|
if (!server) return;
|
|
161
201
|
|
|
162
202
|
try {
|
|
163
203
|
if (server.client) await server.client.close();
|
|
164
204
|
} catch (err) {
|
|
165
|
-
console.error(`Error closing
|
|
205
|
+
console.error(`[MCP] Error closing client ${serverId}:`, err.message);
|
|
166
206
|
}
|
|
167
207
|
|
|
168
208
|
this.servers.delete(serverId);
|
|
169
209
|
this.emit('server_status', { serverId, status: 'stopped' });
|
|
170
210
|
}
|
|
171
211
|
|
|
212
|
+
_scheduleReconnect(serverId, userId, options) {
|
|
213
|
+
if (this._reconnectTimers.has(serverId)) return;
|
|
214
|
+
|
|
215
|
+
const timer = setTimeout(async () => {
|
|
216
|
+
this._reconnectTimers.delete(serverId);
|
|
217
|
+
const server = this.servers.get(serverId);
|
|
218
|
+
if (!server || server.status === 'running') return;
|
|
219
|
+
|
|
220
|
+
const row = db.prepare('SELECT * FROM mcp_servers WHERE id = ? AND enabled = 1').get(serverId);
|
|
221
|
+
if (!row) return;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await this.startServer(serverId, row.command, row.name, userId, options);
|
|
225
|
+
await this.listTools(serverId, userId);
|
|
226
|
+
console.log(`[MCP] Reconnected to ${row.name}`);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const server = this.servers.get(serverId);
|
|
229
|
+
if (server && server.consecutiveFails < CONSECUTIVE_FAIL_LIMIT) {
|
|
230
|
+
this._scheduleReconnect(serverId, userId, options);
|
|
231
|
+
} else {
|
|
232
|
+
console.warn(`[MCP] ${row.name} disabled after ${CONSECUTIVE_FAIL_LIMIT} consecutive failures: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}, RECONNECT_DELAY_MS);
|
|
236
|
+
|
|
237
|
+
this._reconnectTimers.set(serverId, timer);
|
|
238
|
+
}
|
|
239
|
+
|
|
172
240
|
_getOwnedServer(serverId, userId = null) {
|
|
173
241
|
const server = this.servers.get(serverId);
|
|
174
242
|
if (!server) return null;
|
|
@@ -189,14 +257,30 @@ class MCPClient extends EventEmitter {
|
|
|
189
257
|
|
|
190
258
|
async callTool(serverId, toolName, args = {}, userId = null) {
|
|
191
259
|
const server = this._getOwnedServer(serverId, userId);
|
|
192
|
-
if (!server
|
|
193
|
-
|
|
260
|
+
if (!server) throw new Error(`Server ${serverId} not found`);
|
|
261
|
+
|
|
262
|
+
if (server.status !== 'running') {
|
|
263
|
+
const hint = server.lastError ? ` (${server.lastError})` : '';
|
|
264
|
+
throw new Error(`MCP server "${server.name}" is not available${hint}`);
|
|
194
265
|
}
|
|
195
266
|
|
|
196
|
-
|
|
197
|
-
name: toolName,
|
|
198
|
-
|
|
199
|
-
|
|
267
|
+
try {
|
|
268
|
+
const result = await server.client.callTool({ name: toolName, arguments: args });
|
|
269
|
+
server.consecutiveFails = 0;
|
|
270
|
+
return result;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const message = extractErrorMessage(err);
|
|
273
|
+
server.consecutiveFails = (server.consecutiveFails || 0) + 1;
|
|
274
|
+
server.lastError = message;
|
|
275
|
+
|
|
276
|
+
if (server.consecutiveFails >= CONSECUTIVE_FAIL_LIMIT) {
|
|
277
|
+
server.status = 'error';
|
|
278
|
+
this.emit('server_status', { serverId, status: 'error', error: message });
|
|
279
|
+
this._scheduleReconnect(serverId, server.userId, { agentId: server.agentId });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error(`MCP tool "${toolName}" failed: ${message}`);
|
|
283
|
+
}
|
|
200
284
|
}
|
|
201
285
|
|
|
202
286
|
async callToolByName(fullName, args = {}, userId = null, options = {}) {
|
|
@@ -204,10 +288,10 @@ class MCPClient extends EventEmitter {
|
|
|
204
288
|
if (userId != null && server.userId !== userId) continue;
|
|
205
289
|
if (options.agentId && server.agentId && server.agentId !== options.agentId) continue;
|
|
206
290
|
const prefix = `mcp_${server.slug}_`;
|
|
207
|
-
if (fullName.startsWith(prefix))
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
291
|
+
if (!fullName.startsWith(prefix)) continue;
|
|
292
|
+
|
|
293
|
+
const originalName = fullName.substring(prefix.length);
|
|
294
|
+
return await this.callTool(serverId, originalName, args, userId);
|
|
211
295
|
}
|
|
212
296
|
return null;
|
|
213
297
|
}
|
|
@@ -224,7 +308,7 @@ class MCPClient extends EventEmitter {
|
|
|
224
308
|
name: `mcp_${server.slug}_${tool.name}`,
|
|
225
309
|
originalName: tool.name,
|
|
226
310
|
parameters: tool.inputSchema || tool.parameters,
|
|
227
|
-
serverId
|
|
311
|
+
serverId,
|
|
228
312
|
});
|
|
229
313
|
}
|
|
230
314
|
}
|
|
@@ -242,7 +326,9 @@ class MCPClient extends EventEmitter {
|
|
|
242
326
|
command: server.url,
|
|
243
327
|
args: [],
|
|
244
328
|
toolCount: server.tools.length,
|
|
245
|
-
|
|
329
|
+
error: server.lastError || null,
|
|
330
|
+
consecutiveFails: server.consecutiveFails || 0,
|
|
331
|
+
serverInfo: null,
|
|
246
332
|
};
|
|
247
333
|
}
|
|
248
334
|
return statuses;
|
|
@@ -258,8 +344,14 @@ class MCPClient extends EventEmitter {
|
|
|
258
344
|
await this.listTools(srv.id, userId);
|
|
259
345
|
results.push({ id: srv.id, name: srv.name, status: 'running' });
|
|
260
346
|
} catch (err) {
|
|
261
|
-
|
|
262
|
-
|
|
347
|
+
const message = err.message;
|
|
348
|
+
console.error(`[MCP] Failed to start "${srv.name}":`, message);
|
|
349
|
+
// Schedule a reconnect attempt for transient failures (not auth errors)
|
|
350
|
+
const server = this.servers.get(srv.id);
|
|
351
|
+
if (server) {
|
|
352
|
+
this._scheduleReconnect(srv.id, userId, { agentId: srv.agent_id });
|
|
353
|
+
}
|
|
354
|
+
results.push({ id: srv.id, name: srv.name, status: 'error', error: message });
|
|
263
355
|
}
|
|
264
356
|
}
|
|
265
357
|
|
|
@@ -267,6 +359,11 @@ class MCPClient extends EventEmitter {
|
|
|
267
359
|
}
|
|
268
360
|
|
|
269
361
|
async shutdown() {
|
|
362
|
+
for (const timer of this._reconnectTimers.values()) {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
}
|
|
365
|
+
this._reconnectTimers.clear();
|
|
366
|
+
|
|
270
367
|
const promises = [];
|
|
271
368
|
for (const serverId of this.servers.keys()) {
|
|
272
369
|
promises.push(this.stopServer(serverId));
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
const { BasePlatform } = require('./base');
|
|
4
4
|
const { readMeshtasticEnabled } = require('./meshtastic_env');
|
|
5
5
|
const { MeshtasticTcpTransport } = require('./meshtastic_tcp_transport');
|
|
6
|
+
const { BROADCAST_NUM } = require('./meshtastic_protocol');
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TCP_PORT = 4403;
|
|
8
9
|
const DEFAULT_CHANNEL = 0;
|
|
9
10
|
|
|
10
|
-
let meshtasticModulesPromise = null;
|
|
11
|
-
|
|
12
11
|
function requireText(value, label) {
|
|
13
12
|
const text = String(value || '').trim();
|
|
14
13
|
if (!text) throw new Error(`${label} is required`);
|
|
@@ -47,31 +46,6 @@ function parseChannel(value) {
|
|
|
47
46
|
return channel;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
async function loadMeshtasticModules() {
|
|
51
|
-
if (!meshtasticModulesPromise) {
|
|
52
|
-
meshtasticModulesPromise = import('@meshtastic/core').then((core) => ({
|
|
53
|
-
MeshDevice: core.MeshDevice,
|
|
54
|
-
Types: core.Types,
|
|
55
|
-
createTransport: (hostname, port, timeout) =>
|
|
56
|
-
MeshtasticTcpTransport.create(core, hostname, port, timeout),
|
|
57
|
-
})).catch((error) => {
|
|
58
|
-
meshtasticModulesPromise = null;
|
|
59
|
-
const message = String(error?.message || error || '');
|
|
60
|
-
if (
|
|
61
|
-
error?.code === 'ERR_MODULE_NOT_FOUND'
|
|
62
|
-
|| /Cannot find package '@meshtastic\/core'/.test(message)
|
|
63
|
-
|| /Cannot find module '@meshtastic\/core'/.test(message)
|
|
64
|
-
) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
'Meshtastic support is not installed. Install @meshtastic/core or disable the Meshtastic integration.'
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
throw error;
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
return meshtasticModulesPromise;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
49
|
function normalizeNodeId(value) {
|
|
76
50
|
const text = String(value || '').trim();
|
|
77
51
|
if (!text) return '';
|
|
@@ -99,13 +73,8 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
99
73
|
this.channel = DEFAULT_CHANNEL;
|
|
100
74
|
this.authInfo = null;
|
|
101
75
|
this._transport = null;
|
|
102
|
-
this._device = null;
|
|
103
|
-
this._modules = null;
|
|
104
76
|
this._connectPromise = null;
|
|
105
77
|
this._disconnecting = false;
|
|
106
|
-
this._configured = false;
|
|
107
|
-
this._lastMyNodeInfo = null;
|
|
108
|
-
this._lastNodeUsers = new Map();
|
|
109
78
|
}
|
|
110
79
|
|
|
111
80
|
async connect() {
|
|
@@ -131,99 +100,31 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
131
100
|
this.port = endpoint.port;
|
|
132
101
|
this.channel = parseChannel(this.config.channel);
|
|
133
102
|
this._disconnecting = false;
|
|
134
|
-
this._configured = false;
|
|
135
|
-
this._lastMyNodeInfo = null;
|
|
136
|
-
this._lastNodeUsers.clear();
|
|
137
103
|
this.status = 'connecting';
|
|
138
104
|
|
|
139
|
-
const
|
|
140
|
-
this._modules = modules;
|
|
141
|
-
|
|
142
|
-
const transport = await modules.createTransport(this.host, this.port, 60000);
|
|
105
|
+
const transport = await MeshtasticTcpTransport.create(this.host, this.port, 60000);
|
|
143
106
|
this._transport = transport;
|
|
144
107
|
|
|
145
|
-
const
|
|
146
|
-
this._device = device;
|
|
147
|
-
this._wireDeviceEvents(device, modules.Types);
|
|
148
|
-
|
|
149
|
-
const ready = new Promise((resolve, reject) => {
|
|
150
|
-
let settled = false;
|
|
151
|
-
const resolveOnce = () => {
|
|
152
|
-
if (settled) return;
|
|
153
|
-
settled = true;
|
|
154
|
-
resolve({ status: this.status });
|
|
155
|
-
};
|
|
156
|
-
const rejectOnce = (error) => {
|
|
157
|
-
if (settled) return;
|
|
158
|
-
settled = true;
|
|
159
|
-
reject(error);
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
device.events.onDeviceStatus.subscribe((status) => {
|
|
163
|
-
if (status === modules.Types.DeviceStatusEnum.DeviceConnected) {
|
|
164
|
-
device.configure().catch((error) => {
|
|
165
|
-
if (!this._disconnecting) {
|
|
166
|
-
rejectOnce(error);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (status === modules.Types.DeviceStatusEnum.DeviceConfigured) {
|
|
173
|
-
this._configured = true;
|
|
174
|
-
this.status = 'connected';
|
|
175
|
-
this.authInfo = this._buildAuthInfo();
|
|
176
|
-
this.emit('connected');
|
|
177
|
-
resolveOnce();
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (status === modules.Types.DeviceStatusEnum.DeviceDisconnected) {
|
|
182
|
-
this.status = 'disconnected';
|
|
183
|
-
if (!this._disconnecting) {
|
|
184
|
-
const error = new Error('Meshtastic device disconnected');
|
|
185
|
-
rejectOnce(error);
|
|
186
|
-
this.emit('disconnected', { reason: 'device_disconnected' });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
});
|
|
108
|
+
const conn = transport.connection;
|
|
191
109
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
_wireDeviceEvents(device, Types) {
|
|
197
|
-
device.events.onMyNodeInfo.subscribe((info) => {
|
|
198
|
-
this._lastMyNodeInfo = info;
|
|
199
|
-
this.authInfo = this._buildAuthInfo();
|
|
110
|
+
conn.on('myNodeInfo', () => {
|
|
111
|
+
this.authInfo = this._buildAuthInfo(conn);
|
|
200
112
|
});
|
|
201
113
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
114
|
+
conn.on('textMessage', (msg) => {
|
|
115
|
+
if (msg.channel !== this.channel) return;
|
|
116
|
+
|
|
117
|
+
const localNodeNum = conn.myNodeNum;
|
|
118
|
+
if (msg.from > 0 && localNodeNum > 0 && msg.from === localNodeNum) return;
|
|
208
119
|
|
|
209
|
-
|
|
210
|
-
if (!packet) return;
|
|
211
|
-
const channel = Number(packet.channel);
|
|
212
|
-
if (channel !== this.channel) return;
|
|
213
|
-
|
|
214
|
-
const senderNum = Number(packet.from || 0);
|
|
215
|
-
const localNodeNum = Number(this._lastMyNodeInfo?.myNodeNum || 0);
|
|
216
|
-
if (senderNum > 0 && localNodeNum > 0 && senderNum === localNodeNum) {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
const senderUser = this._lastNodeUsers.get(senderNum) || null;
|
|
120
|
+
const senderUser = conn.nodeUsers.get(msg.from) || null;
|
|
220
121
|
const senderName = senderUser?.longName || senderUser?.shortName || null;
|
|
221
122
|
const senderUsername = normalizeNodeId(senderUser?.id || '');
|
|
222
|
-
const chatId = `channel:${channel}`;
|
|
123
|
+
const chatId = `channel:${msg.channel}`;
|
|
223
124
|
|
|
224
125
|
const access = this._checkInboundAccess({
|
|
225
126
|
platform: this.name,
|
|
226
|
-
senderId: String(
|
|
127
|
+
senderId: String(msg.from || ''),
|
|
227
128
|
chatId,
|
|
228
129
|
isDirect: false,
|
|
229
130
|
isShared: true,
|
|
@@ -245,35 +146,46 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
245
146
|
|
|
246
147
|
this.emit('message', {
|
|
247
148
|
chatId,
|
|
248
|
-
sender: String(
|
|
149
|
+
sender: String(msg.from || ''),
|
|
249
150
|
senderName,
|
|
250
151
|
senderUsername: senderUsername || null,
|
|
251
152
|
senderTag: senderUsername || null,
|
|
252
|
-
content:
|
|
153
|
+
content: msg.data,
|
|
253
154
|
mediaType: null,
|
|
254
155
|
isGroup: true,
|
|
255
|
-
messageId: String(
|
|
256
|
-
timestamp: toIsoTimestamp(
|
|
156
|
+
messageId: String(msg.id || `${Date.now()}`),
|
|
157
|
+
timestamp: toIsoTimestamp(msg.rxTime),
|
|
257
158
|
metadata: {
|
|
258
|
-
channel,
|
|
159
|
+
channel: msg.channel,
|
|
259
160
|
host: this.host,
|
|
260
161
|
meshNodeId: senderUsername || null,
|
|
261
|
-
meshDestination:
|
|
162
|
+
meshDestination: msg.type || 'broadcast',
|
|
262
163
|
},
|
|
263
164
|
rawMessage: {
|
|
264
|
-
id:
|
|
265
|
-
from:
|
|
266
|
-
to:
|
|
267
|
-
type:
|
|
268
|
-
channel:
|
|
165
|
+
id: msg.id,
|
|
166
|
+
from: msg.from,
|
|
167
|
+
to: msg.to,
|
|
168
|
+
type: msg.type,
|
|
169
|
+
channel: msg.channel,
|
|
269
170
|
},
|
|
270
171
|
});
|
|
271
172
|
});
|
|
173
|
+
|
|
174
|
+
conn.on('disconnected', (info) => {
|
|
175
|
+
if (this._disconnecting) return;
|
|
176
|
+
this.status = 'disconnected';
|
|
177
|
+
this.emit('disconnected', { reason: info?.reason || 'device_disconnected' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.status = 'connected';
|
|
181
|
+
this.authInfo = this._buildAuthInfo(conn);
|
|
182
|
+
this.emit('connected');
|
|
183
|
+
return { status: this.status };
|
|
272
184
|
}
|
|
273
185
|
|
|
274
|
-
_buildAuthInfo() {
|
|
275
|
-
const
|
|
276
|
-
const user =
|
|
186
|
+
_buildAuthInfo(conn) {
|
|
187
|
+
const nodeNum = conn?.myNodeNum || 0;
|
|
188
|
+
const user = conn?.nodeUsers.get(nodeNum) || {};
|
|
277
189
|
return {
|
|
278
190
|
label: user.longName || user.shortName || normalizeNodeId(user.id) || this.host || 'Meshtastic',
|
|
279
191
|
nodeId: normalizeNodeId(user.id),
|
|
@@ -283,7 +195,7 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
283
195
|
}
|
|
284
196
|
|
|
285
197
|
async sendMessage(to, content) {
|
|
286
|
-
if (this.status !== 'connected' || !this.
|
|
198
|
+
if (this.status !== 'connected' || !this._transport) {
|
|
287
199
|
throw new Error('Meshtastic is not connected');
|
|
288
200
|
}
|
|
289
201
|
|
|
@@ -292,11 +204,11 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
292
204
|
throw new Error(`Meshtastic is configured for channel ${this.channel}`);
|
|
293
205
|
}
|
|
294
206
|
|
|
295
|
-
await this.
|
|
207
|
+
await this._transport.connection.sendText(
|
|
296
208
|
String(content || ''),
|
|
297
|
-
'broadcast',
|
|
298
|
-
true,
|
|
299
209
|
this.channel,
|
|
210
|
+
BROADCAST_NUM,
|
|
211
|
+
true,
|
|
300
212
|
);
|
|
301
213
|
return { success: true };
|
|
302
214
|
}
|
|
@@ -305,15 +217,10 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
305
217
|
this._disconnecting = true;
|
|
306
218
|
this.status = 'disconnected';
|
|
307
219
|
|
|
308
|
-
const device = this._device;
|
|
309
220
|
const transport = this._transport;
|
|
310
|
-
this._device = null;
|
|
311
221
|
this._transport = null;
|
|
312
|
-
this._configured = false;
|
|
313
222
|
|
|
314
|
-
if (
|
|
315
|
-
await device.disconnect().catch(() => {});
|
|
316
|
-
} else if (transport && typeof transport.disconnect === 'function') {
|
|
223
|
+
if (transport) {
|
|
317
224
|
await transport.disconnect().catch(() => {});
|
|
318
225
|
}
|
|
319
226
|
}
|
|
@@ -323,7 +230,7 @@ class MeshtasticPlatform extends BasePlatform {
|
|
|
323
230
|
}
|
|
324
231
|
|
|
325
232
|
getAuthInfo() {
|
|
326
|
-
return this.authInfo || this._buildAuthInfo();
|
|
233
|
+
return this.authInfo || this._buildAuthInfo(this._transport?.connection);
|
|
327
234
|
}
|
|
328
235
|
}
|
|
329
236
|
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Socket } = require('node:net');
|
|
4
|
+
const { EventEmitter } = require('node:events');
|
|
5
|
+
|
|
6
|
+
// Meshtastic TCP wire framing constants (from public protocol docs)
|
|
7
|
+
const FRAME_START_1 = 0x94;
|
|
8
|
+
const FRAME_START_2 = 0xC3;
|
|
9
|
+
|
|
10
|
+
const BROADCAST_NUM = 0xFFFFFFFF;
|
|
11
|
+
|
|
12
|
+
// PortNum values from public protocol specification
|
|
13
|
+
const PortNum = Object.freeze({
|
|
14
|
+
UNKNOWN_APP: 0,
|
|
15
|
+
TEXT_MESSAGE_APP: 1,
|
|
16
|
+
POSITION_APP: 3,
|
|
17
|
+
NODEINFO_APP: 4,
|
|
18
|
+
ROUTING_APP: 5,
|
|
19
|
+
ADMIN_APP: 6,
|
|
20
|
+
TELEMETRY_APP: 67,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// --------------------------------------------------------------------------
|
|
24
|
+
// Minimal protobuf wire-format encoder/decoder (Google public standard)
|
|
25
|
+
// Implements only the subset needed: varint, length-delimited, fixed32
|
|
26
|
+
// --------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const WIRE_VARINT = 0;
|
|
29
|
+
const WIRE_FIXED64 = 1;
|
|
30
|
+
const WIRE_LENGTH_DELIMITED = 2;
|
|
31
|
+
const WIRE_FIXED32 = 5;
|
|
32
|
+
|
|
33
|
+
function encodeVarint(value) {
|
|
34
|
+
const bytes = [];
|
|
35
|
+
let v = value >>> 0;
|
|
36
|
+
while (v > 0x7F) {
|
|
37
|
+
bytes.push((v & 0x7F) | 0x80);
|
|
38
|
+
v >>>= 7;
|
|
39
|
+
}
|
|
40
|
+
bytes.push(v & 0x7F);
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeTag(fieldNumber, wireType) {
|
|
45
|
+
return encodeVarint((fieldNumber << 3) | wireType);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encodeVarintField(fieldNumber, value) {
|
|
49
|
+
if (value === 0 || value == null) return [];
|
|
50
|
+
return [...encodeTag(fieldNumber, WIRE_VARINT), ...encodeVarint(value)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function encodeBoolField(fieldNumber, value) {
|
|
54
|
+
if (!value) return [];
|
|
55
|
+
return [...encodeTag(fieldNumber, WIRE_VARINT), 1];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function encodeFixed32Field(fieldNumber, value) {
|
|
59
|
+
if (value === 0 || value == null) return [];
|
|
60
|
+
const buf = Buffer.alloc(4);
|
|
61
|
+
buf.writeUInt32LE(value >>> 0);
|
|
62
|
+
return [...encodeTag(fieldNumber, WIRE_FIXED32), ...buf];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function encodeBytesField(fieldNumber, bytes) {
|
|
66
|
+
if (!bytes || bytes.length === 0) return [];
|
|
67
|
+
return [
|
|
68
|
+
...encodeTag(fieldNumber, WIRE_LENGTH_DELIMITED),
|
|
69
|
+
...encodeVarint(bytes.length),
|
|
70
|
+
...bytes,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function encodeStringField(fieldNumber, str) {
|
|
75
|
+
if (!str) return [];
|
|
76
|
+
return encodeBytesField(fieldNumber, Buffer.from(str, 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function encodeMessageField(fieldNumber, messageBytes) {
|
|
80
|
+
return encodeBytesField(fieldNumber, messageBytes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeVarint(buf, offset) {
|
|
84
|
+
let result = 0;
|
|
85
|
+
let shift = 0;
|
|
86
|
+
let pos = offset;
|
|
87
|
+
while (pos < buf.length) {
|
|
88
|
+
const b = buf[pos++];
|
|
89
|
+
result |= (b & 0x7F) << shift;
|
|
90
|
+
if ((b & 0x80) === 0) return { value: result >>> 0, offset: pos };
|
|
91
|
+
shift += 7;
|
|
92
|
+
if (shift > 35) throw new Error('Varint too long');
|
|
93
|
+
}
|
|
94
|
+
throw new Error('Unexpected end of varint');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decodeFields(buf) {
|
|
98
|
+
const fields = [];
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset < buf.length) {
|
|
101
|
+
const tag = decodeVarint(buf, offset);
|
|
102
|
+
offset = tag.offset;
|
|
103
|
+
const fieldNumber = tag.value >>> 3;
|
|
104
|
+
const wireType = tag.value & 0x07;
|
|
105
|
+
|
|
106
|
+
switch (wireType) {
|
|
107
|
+
case WIRE_VARINT: {
|
|
108
|
+
const val = decodeVarint(buf, offset);
|
|
109
|
+
offset = val.offset;
|
|
110
|
+
fields.push({ field: fieldNumber, wire: wireType, value: val.value });
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case WIRE_FIXED64: {
|
|
114
|
+
offset += 8;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case WIRE_LENGTH_DELIMITED: {
|
|
118
|
+
const len = decodeVarint(buf, offset);
|
|
119
|
+
offset = len.offset;
|
|
120
|
+
const data = buf.subarray(offset, offset + len.value);
|
|
121
|
+
offset += len.value;
|
|
122
|
+
fields.push({ field: fieldNumber, wire: wireType, value: data });
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case WIRE_FIXED32: {
|
|
126
|
+
const val32 = buf.readUInt32LE(offset);
|
|
127
|
+
offset += 4;
|
|
128
|
+
fields.push({ field: fieldNumber, wire: wireType, value: val32 });
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
throw new Error(`Unsupported wire type ${wireType}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return fields;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getField(fields, fieldNumber) {
|
|
139
|
+
return fields.find((f) => f.field === fieldNumber) || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
// Protocol message builders and parsers
|
|
144
|
+
// Field numbers from the public Meshtastic protobuf specification
|
|
145
|
+
// --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function encodeData(payload, portnum, opts = {}) {
|
|
148
|
+
return new Uint8Array([
|
|
149
|
+
...encodeVarintField(1, portnum),
|
|
150
|
+
...encodeBytesField(2, payload),
|
|
151
|
+
...encodeBoolField(3, opts.wantResponse),
|
|
152
|
+
...encodeFixed32Field(6, opts.requestId),
|
|
153
|
+
...encodeFixed32Field(7, opts.replyId),
|
|
154
|
+
...encodeFixed32Field(8, opts.emoji),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function encodeMeshPacket(from, to, channel, id, decoded, opts = {}) {
|
|
159
|
+
return new Uint8Array([
|
|
160
|
+
...encodeFixed32Field(1, from),
|
|
161
|
+
...encodeFixed32Field(2, to),
|
|
162
|
+
...encodeVarintField(3, channel),
|
|
163
|
+
...encodeMessageField(4, decoded),
|
|
164
|
+
...encodeFixed32Field(6, id),
|
|
165
|
+
...encodeBoolField(10, opts.wantAck),
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function encodeToRadioPacket(meshPacketBytes) {
|
|
170
|
+
return new Uint8Array(encodeMessageField(1, meshPacketBytes));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function encodeToRadioWantConfig(configId) {
|
|
174
|
+
return new Uint8Array(encodeVarintField(3, configId));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function decodeUser(buf) {
|
|
178
|
+
const fields = decodeFields(buf);
|
|
179
|
+
return {
|
|
180
|
+
id: getField(fields, 1)?.value?.toString('utf8') || '',
|
|
181
|
+
longName: getField(fields, 2)?.value?.toString('utf8') || '',
|
|
182
|
+
shortName: getField(fields, 3)?.value?.toString('utf8') || '',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function decodeData(buf) {
|
|
187
|
+
const fields = decodeFields(buf);
|
|
188
|
+
return {
|
|
189
|
+
portnum: getField(fields, 1)?.value || 0,
|
|
190
|
+
payload: getField(fields, 2)?.value || Buffer.alloc(0),
|
|
191
|
+
wantResponse: !!(getField(fields, 3)?.value),
|
|
192
|
+
source: getField(fields, 5)?.value || 0,
|
|
193
|
+
dest: getField(fields, 4)?.value || 0,
|
|
194
|
+
requestId: getField(fields, 6)?.value || 0,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function decodeMeshPacket(buf) {
|
|
199
|
+
const fields = decodeFields(buf);
|
|
200
|
+
const decodedField = getField(fields, 4);
|
|
201
|
+
const encryptedField = getField(fields, 5);
|
|
202
|
+
return {
|
|
203
|
+
from: getField(fields, 1)?.value || 0,
|
|
204
|
+
to: getField(fields, 2)?.value || 0,
|
|
205
|
+
channel: getField(fields, 3)?.value || 0,
|
|
206
|
+
decoded: decodedField ? decodeData(decodedField.value) : null,
|
|
207
|
+
encrypted: encryptedField ? encryptedField.value : null,
|
|
208
|
+
id: getField(fields, 6)?.value || 0,
|
|
209
|
+
rxTime: getField(fields, 7)?.value || 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function decodeNodeInfo(buf) {
|
|
214
|
+
const fields = decodeFields(buf);
|
|
215
|
+
const userField = getField(fields, 2);
|
|
216
|
+
return {
|
|
217
|
+
num: getField(fields, 1)?.value || 0,
|
|
218
|
+
user: userField ? decodeUser(userField.value) : null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function decodeMyNodeInfo(buf) {
|
|
223
|
+
const fields = decodeFields(buf);
|
|
224
|
+
return {
|
|
225
|
+
myNodeNum: getField(fields, 1)?.value || 0,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function decodeFromRadio(buf) {
|
|
230
|
+
const fields = decodeFields(buf);
|
|
231
|
+
const id = getField(fields, 1)?.value || 0;
|
|
232
|
+
|
|
233
|
+
const packetField = getField(fields, 2);
|
|
234
|
+
if (packetField) return { id, type: 'packet', packet: decodeMeshPacket(packetField.value) };
|
|
235
|
+
|
|
236
|
+
const myInfoField = getField(fields, 3);
|
|
237
|
+
if (myInfoField) return { id, type: 'myInfo', myInfo: decodeMyNodeInfo(myInfoField.value) };
|
|
238
|
+
|
|
239
|
+
const nodeInfoField = getField(fields, 4);
|
|
240
|
+
if (nodeInfoField) return { id, type: 'nodeInfo', nodeInfo: decodeNodeInfo(nodeInfoField.value) };
|
|
241
|
+
|
|
242
|
+
const configCompleteField = getField(fields, 7);
|
|
243
|
+
if (configCompleteField) return { id, type: 'configComplete', configId: configCompleteField.value };
|
|
244
|
+
|
|
245
|
+
const rebootedField = getField(fields, 8);
|
|
246
|
+
if (rebootedField) return { id, type: 'rebooted' };
|
|
247
|
+
|
|
248
|
+
return { id, type: 'unknown' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --------------------------------------------------------------------------
|
|
252
|
+
// TCP wire framing: [0x94, 0xC3, len_msb, len_lsb, ...protobuf_payload]
|
|
253
|
+
// --------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function framePacket(protobufBytes) {
|
|
256
|
+
const len = protobufBytes.length;
|
|
257
|
+
const frame = Buffer.alloc(4 + len);
|
|
258
|
+
frame[0] = FRAME_START_1;
|
|
259
|
+
frame[1] = FRAME_START_2;
|
|
260
|
+
frame[2] = (len >> 8) & 0xFF;
|
|
261
|
+
frame[3] = len & 0xFF;
|
|
262
|
+
frame.set(protobufBytes, 4);
|
|
263
|
+
return frame;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function createFrameParser(onPacket) {
|
|
267
|
+
let buffer = Buffer.alloc(0);
|
|
268
|
+
return (chunk) => {
|
|
269
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
270
|
+
while (buffer.length >= 4) {
|
|
271
|
+
const idx = buffer.indexOf(FRAME_START_1);
|
|
272
|
+
if (idx === -1) { buffer = Buffer.alloc(0); return; }
|
|
273
|
+
if (idx > 0) { buffer = buffer.subarray(idx); }
|
|
274
|
+
if (buffer.length < 2) return;
|
|
275
|
+
if (buffer[1] !== FRAME_START_2) { buffer = buffer.subarray(1); continue; }
|
|
276
|
+
if (buffer.length < 4) return;
|
|
277
|
+
const payloadLen = (buffer[2] << 8) | buffer[3];
|
|
278
|
+
if (buffer.length < 4 + payloadLen) return;
|
|
279
|
+
const payload = buffer.subarray(4, 4 + payloadLen);
|
|
280
|
+
buffer = buffer.subarray(4 + payloadLen);
|
|
281
|
+
onPacket(payload);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --------------------------------------------------------------------------
|
|
287
|
+
// MeshtasticConnection — TCP connection + protocol state machine
|
|
288
|
+
// --------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
class MeshtasticConnection extends EventEmitter {
|
|
291
|
+
constructor() {
|
|
292
|
+
super();
|
|
293
|
+
this._socket = null;
|
|
294
|
+
this._configId = (Math.random() * 0x7FFFFFFF) >>> 0;
|
|
295
|
+
this._myNodeNum = 0;
|
|
296
|
+
this._configured = false;
|
|
297
|
+
this._closing = false;
|
|
298
|
+
this._nodeUsers = new Map();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
get myNodeNum() { return this._myNodeNum; }
|
|
302
|
+
get nodeUsers() { return this._nodeUsers; }
|
|
303
|
+
get configured() { return this._configured; }
|
|
304
|
+
|
|
305
|
+
async connect(host, port, timeout = 60000) {
|
|
306
|
+
if (this._socket) throw new Error('Already connected');
|
|
307
|
+
this._closing = false;
|
|
308
|
+
this._configured = false;
|
|
309
|
+
this._myNodeNum = 0;
|
|
310
|
+
this._nodeUsers.clear();
|
|
311
|
+
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
const socket = new Socket();
|
|
314
|
+
let settled = false;
|
|
315
|
+
|
|
316
|
+
const fail = (err) => {
|
|
317
|
+
if (settled) return;
|
|
318
|
+
settled = true;
|
|
319
|
+
socket.destroy();
|
|
320
|
+
reject(err);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const timer = setTimeout(() => fail(new Error('Connection timeout')), timeout);
|
|
324
|
+
|
|
325
|
+
socket.once('error', fail);
|
|
326
|
+
socket.once('ready', () => {
|
|
327
|
+
socket.removeListener('error', fail);
|
|
328
|
+
this._socket = socket;
|
|
329
|
+
this._wireSocket(socket);
|
|
330
|
+
this.emit('status', 'connected');
|
|
331
|
+
|
|
332
|
+
const onConfigured = () => {
|
|
333
|
+
if (settled) return;
|
|
334
|
+
settled = true;
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
resolve();
|
|
337
|
+
};
|
|
338
|
+
this.once('configured', onConfigured);
|
|
339
|
+
|
|
340
|
+
const toRadio = encodeToRadioWantConfig(this._configId);
|
|
341
|
+
socket.write(framePacket(toRadio));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
socket.setTimeout(timeout);
|
|
345
|
+
socket.connect(port, host);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_wireSocket(socket) {
|
|
350
|
+
const parser = createFrameParser((payload) => {
|
|
351
|
+
if (this._closing) return;
|
|
352
|
+
try {
|
|
353
|
+
const msg = decodeFromRadio(payload);
|
|
354
|
+
this._handleFromRadio(msg);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
this.emit('error', err);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
socket.on('data', parser);
|
|
361
|
+
socket.on('error', () => this._onDisconnected('socket-error'));
|
|
362
|
+
socket.on('end', () => this._onDisconnected('socket-end'));
|
|
363
|
+
socket.on('close', () => this._onDisconnected('socket-closed'));
|
|
364
|
+
socket.on('timeout', () => {
|
|
365
|
+
this._onDisconnected('socket-timeout');
|
|
366
|
+
socket.destroy();
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_onDisconnected(reason) {
|
|
371
|
+
if (this._closing) return;
|
|
372
|
+
this._configured = false;
|
|
373
|
+
this.emit('status', 'disconnected');
|
|
374
|
+
this.emit('disconnected', { reason });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_handleFromRadio(msg) {
|
|
378
|
+
switch (msg.type) {
|
|
379
|
+
case 'myInfo':
|
|
380
|
+
this._myNodeNum = msg.myInfo.myNodeNum;
|
|
381
|
+
this.emit('myNodeInfo', msg.myInfo);
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case 'nodeInfo':
|
|
385
|
+
if (msg.nodeInfo.user && msg.nodeInfo.num) {
|
|
386
|
+
this._nodeUsers.set(msg.nodeInfo.num, msg.nodeInfo.user);
|
|
387
|
+
this.emit('nodeInfo', msg.nodeInfo);
|
|
388
|
+
}
|
|
389
|
+
break;
|
|
390
|
+
|
|
391
|
+
case 'configComplete':
|
|
392
|
+
if (msg.configId === this._configId) {
|
|
393
|
+
this._configured = true;
|
|
394
|
+
this.emit('configured');
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
|
|
398
|
+
case 'rebooted':
|
|
399
|
+
this._configured = false;
|
|
400
|
+
this._socket?.write(framePacket(encodeToRadioWantConfig(this._configId)));
|
|
401
|
+
break;
|
|
402
|
+
|
|
403
|
+
case 'packet': {
|
|
404
|
+
const pkt = msg.packet;
|
|
405
|
+
if (!pkt.decoded) break;
|
|
406
|
+
this._handleDecodedPacket(pkt);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_handleDecodedPacket(pkt) {
|
|
413
|
+
const { decoded } = pkt;
|
|
414
|
+
|
|
415
|
+
switch (decoded.portnum) {
|
|
416
|
+
case PortNum.TEXT_MESSAGE_APP:
|
|
417
|
+
this.emit('textMessage', {
|
|
418
|
+
id: pkt.id,
|
|
419
|
+
from: pkt.from,
|
|
420
|
+
to: pkt.to,
|
|
421
|
+
channel: pkt.channel,
|
|
422
|
+
rxTime: pkt.rxTime,
|
|
423
|
+
type: pkt.to === BROADCAST_NUM ? 'broadcast' : 'direct',
|
|
424
|
+
data: decoded.payload.toString('utf8'),
|
|
425
|
+
});
|
|
426
|
+
break;
|
|
427
|
+
|
|
428
|
+
case PortNum.NODEINFO_APP:
|
|
429
|
+
try {
|
|
430
|
+
const user = decodeUser(decoded.payload);
|
|
431
|
+
if (pkt.from) this._nodeUsers.set(pkt.from, user);
|
|
432
|
+
this.emit('nodeInfo', { num: pkt.from, user });
|
|
433
|
+
} catch {}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async sendText(text, channel = 0, destination = BROADCAST_NUM, wantAck = true) {
|
|
439
|
+
if (!this._socket || !this._configured) throw new Error('Not connected');
|
|
440
|
+
const payload = Buffer.from(text, 'utf8');
|
|
441
|
+
const data = encodeData(payload, PortNum.TEXT_MESSAGE_APP, { wantResponse: false });
|
|
442
|
+
const id = (Math.random() * 0x7FFFFFFF) >>> 0;
|
|
443
|
+
const packet = encodeMeshPacket(this._myNodeNum, destination, channel, id, data, { wantAck });
|
|
444
|
+
const toRadio = encodeToRadioPacket(packet);
|
|
445
|
+
this._socket.write(framePacket(toRadio));
|
|
446
|
+
return id;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async disconnect() {
|
|
450
|
+
this._closing = true;
|
|
451
|
+
this._configured = false;
|
|
452
|
+
const socket = this._socket;
|
|
453
|
+
this._socket = null;
|
|
454
|
+
if (socket) {
|
|
455
|
+
socket.removeAllListeners();
|
|
456
|
+
socket.destroy();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
module.exports = {
|
|
462
|
+
MeshtasticConnection,
|
|
463
|
+
PortNum,
|
|
464
|
+
BROADCAST_NUM,
|
|
465
|
+
encodeVarint,
|
|
466
|
+
decodeVarint,
|
|
467
|
+
decodeFields,
|
|
468
|
+
decodeFromRadio,
|
|
469
|
+
decodeMeshPacket,
|
|
470
|
+
decodeUser,
|
|
471
|
+
framePacket,
|
|
472
|
+
createFrameParser,
|
|
473
|
+
};
|
|
@@ -1,144 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const { Readable, Writable } = require('node:stream');
|
|
3
|
+
const { MeshtasticConnection } = require('./meshtastic_protocol');
|
|
5
4
|
|
|
6
5
|
class MeshtasticTcpTransport {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const socket = new Socket();
|
|
10
|
-
const onError = (error) => {
|
|
11
|
-
socket.destroy();
|
|
12
|
-
socket.removeAllListeners();
|
|
13
|
-
reject(error);
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
socket.once('error', onError);
|
|
17
|
-
socket.once('ready', () => {
|
|
18
|
-
socket.removeListener('error', onError);
|
|
19
|
-
resolve(new MeshtasticTcpTransport(core, socket));
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
socket.setTimeout(timeout);
|
|
23
|
-
socket.connect(port, hostname);
|
|
24
|
-
});
|
|
6
|
+
constructor(connection) {
|
|
7
|
+
this._connection = connection;
|
|
25
8
|
}
|
|
26
9
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this._closingByUser = false;
|
|
32
|
-
this._errored = false;
|
|
33
|
-
this._fromDeviceController = null;
|
|
34
|
-
this._pipePromise = null;
|
|
35
|
-
this._abortController = new AbortController();
|
|
36
|
-
|
|
37
|
-
this._socket.on('error', () => {
|
|
38
|
-
this._errored = true;
|
|
39
|
-
this._socket?.removeAllListeners();
|
|
40
|
-
this._socket?.destroy();
|
|
41
|
-
if (!this._closingByUser) {
|
|
42
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceDisconnected, 'socket-error');
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
this._socket.on('end', () => {
|
|
47
|
-
if (this._closingByUser) return;
|
|
48
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceDisconnected, 'socket-end');
|
|
49
|
-
this._socket?.removeAllListeners();
|
|
50
|
-
this._socket?.destroy();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
this._socket.on('timeout', () => {
|
|
54
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceDisconnected, 'socket-timeout');
|
|
55
|
-
this._socket?.removeAllListeners();
|
|
56
|
-
this._socket?.destroy();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
this._socket.on('close', () => {
|
|
60
|
-
if (this._closingByUser) return;
|
|
61
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceDisconnected, 'socket-closed');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const transformed = Readable.toWeb(connection).pipeThrough(core.Utils.fromDeviceStream());
|
|
65
|
-
this._fromDevice = new ReadableStream({
|
|
66
|
-
start: async (controller) => {
|
|
67
|
-
this._fromDeviceController = controller;
|
|
68
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceConnecting);
|
|
69
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceConnected);
|
|
70
|
-
const reader = transformed.getReader();
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
while (true) {
|
|
74
|
-
const { value, done } = await reader.read();
|
|
75
|
-
if (done) break;
|
|
76
|
-
controller.enqueue(value);
|
|
77
|
-
}
|
|
78
|
-
controller.close();
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if (this._closingByUser || this._errored) {
|
|
81
|
-
controller.close();
|
|
82
|
-
} else {
|
|
83
|
-
this._emitStatus(core.Types.DeviceStatusEnum.DeviceDisconnected, 'read-error');
|
|
84
|
-
controller.error(error instanceof Error ? error : new Error(String(error)));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
await transformed.cancel();
|
|
89
|
-
} catch {}
|
|
90
|
-
} finally {
|
|
91
|
-
reader.releaseLock();
|
|
92
|
-
}
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const toDeviceTransform = core.Utils.toDeviceStream();
|
|
97
|
-
this._toDevice = toDeviceTransform.writable;
|
|
98
|
-
this._pipePromise = toDeviceTransform.readable.pipeTo(
|
|
99
|
-
Writable.toWeb(connection),
|
|
100
|
-
{ signal: this._abortController.signal }
|
|
101
|
-
).catch((error) => {
|
|
102
|
-
if (this._abortController.signal.aborted || this._socket?.destroyed) return;
|
|
103
|
-
const socketError = error instanceof Error ? error : new Error(String(error));
|
|
104
|
-
this._socket?.destroy(socketError);
|
|
105
|
-
});
|
|
10
|
+
static async create(hostname, port = 4403, timeout = 60000) {
|
|
11
|
+
const connection = new MeshtasticConnection();
|
|
12
|
+
await connection.connect(hostname, port, timeout);
|
|
13
|
+
return new MeshtasticTcpTransport(connection);
|
|
106
14
|
}
|
|
107
15
|
|
|
108
|
-
get
|
|
109
|
-
return this._toDevice;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
get fromDevice() {
|
|
113
|
-
return this._fromDevice;
|
|
114
|
-
}
|
|
16
|
+
get connection() { return this._connection; }
|
|
115
17
|
|
|
116
18
|
async disconnect() {
|
|
117
|
-
|
|
118
|
-
this._closingByUser = true;
|
|
119
|
-
this._emitStatus(this._core.Types.DeviceStatusEnum.DeviceDisconnected, 'user');
|
|
120
|
-
this._abortController.abort();
|
|
121
|
-
if (this._pipePromise) {
|
|
122
|
-
await this._pipePromise;
|
|
123
|
-
}
|
|
124
|
-
this._socket?.destroy();
|
|
125
|
-
} finally {
|
|
126
|
-
this._socket = null;
|
|
127
|
-
this._closingByUser = false;
|
|
128
|
-
this._errored = false;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
_emitStatus(nextStatus, reason) {
|
|
133
|
-
if (nextStatus === this._lastStatus) return;
|
|
134
|
-
this._lastStatus = nextStatus;
|
|
135
|
-
this._fromDeviceController?.enqueue({
|
|
136
|
-
type: 'status',
|
|
137
|
-
data: {
|
|
138
|
-
status: nextStatus,
|
|
139
|
-
reason,
|
|
140
|
-
},
|
|
141
|
-
});
|
|
19
|
+
await this._connection.disconnect();
|
|
142
20
|
}
|
|
143
21
|
}
|
|
144
22
|
|