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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.3.1-beta.32",
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: "987887827" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
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
- const mcpResult = await mcpManager.callToolByName(toolName, args, userId, { agentId });
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: err.message });
170
+ this.emit('server_status', { serverId, status: 'error', error: message });
141
171
  }
142
- throw err;
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(() => { }); // Reconnect using tokens
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 MCP client ${serverId}:`, err);
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 || server.status !== 'running') {
193
- throw new Error(`Server ${serverId} not running`);
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
- return await server.client.callTool({
197
- name: toolName,
198
- arguments: args
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
- const originalName = fullName.substring(prefix.length);
209
- return await this.callTool(serverId, originalName, args, userId);
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
- serverInfo: null
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
- console.error(`Failed to start MCP server ${srv.name}:`, err.message);
262
- results.push({ id: srv.id, name: srv.name, status: 'error', error: err.message });
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 modules = await loadMeshtasticModules();
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 device = new modules.MeshDevice(transport);
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
- await ready;
193
- return { status: this.status };
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
- device.events.onUserPacket.subscribe((packet) => {
203
- const user = packet?.data;
204
- const from = Number(packet?.from || 0);
205
- if (!user || !Number.isFinite(from) || from <= 0) return;
206
- this._lastNodeUsers.set(from, user);
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
- device.events.onMessagePacket.subscribe((packet) => {
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(senderNum || ''),
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(senderNum || ''),
149
+ sender: String(msg.from || ''),
249
150
  senderName,
250
151
  senderUsername: senderUsername || null,
251
152
  senderTag: senderUsername || null,
252
- content: String(packet.data || ''),
153
+ content: msg.data,
253
154
  mediaType: null,
254
155
  isGroup: true,
255
- messageId: String(packet.id || `${Date.now()}`),
256
- timestamp: toIsoTimestamp(packet.rxTime),
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: packet.type || 'broadcast',
162
+ meshDestination: msg.type || 'broadcast',
262
163
  },
263
164
  rawMessage: {
264
- id: packet.id,
265
- from: packet.from,
266
- to: packet.to,
267
- type: packet.type,
268
- channel: packet.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 info = this._lastMyNodeInfo || {};
276
- const user = info.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._device || !this._modules) {
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._device.sendText(
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 (device && typeof device.disconnect === 'function') {
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 { Socket } = require('node:net');
4
- const { Readable, Writable } = require('node:stream');
3
+ const { MeshtasticConnection } = require('./meshtastic_protocol');
5
4
 
6
5
  class MeshtasticTcpTransport {
7
- static async create(core, hostname, port = 4403, timeout = 60000) {
8
- return await new Promise((resolve, reject) => {
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
- constructor(core, connection) {
28
- this._core = core;
29
- this._socket = connection;
30
- this._lastStatus = core.Types.DeviceStatusEnum.DeviceDisconnected;
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 toDevice() {
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
- try {
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