-
-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathmeshtasticManager.ts
More file actions
11305 lines (9875 loc) · 485 KB
/
meshtasticManager.ts
File metadata and controls
11305 lines (9875 loc) · 485 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import databaseService, { type DbMessage } from '../services/database.js';
import meshtasticProtobufService from './meshtasticProtobufService.js';
import protobufService, { convertIpv4ConfigToStrings } from './protobufService.js';
import { getProtobufRoot } from './protobufLoader.js';
import { TcpTransport } from './tcpTransport.js';
import { calculateDistance } from '../utils/distance.js';
import { isPointInGeofence, distanceToGeofenceCenter } from '../utils/geometry.js';
import { formatTime, formatDate } from '../utils/datetime.js';
import { logger } from '../utils/logger.js';
import { calculateLoRaFrequency } from '../utils/loraFrequency.js';
import { getEnvironmentConfig } from './config/environment.js';
import { notificationService } from './services/notificationService.js';
import { serverEventNotificationService } from './services/serverEventNotificationService.js';
import packetLogService from './services/packetLogService.js';
import { channelDecryptionService } from './services/channelDecryptionService.js';
import { dataEventEmitter } from './services/dataEventEmitter.js';
import { messageQueueService } from './messageQueueService.js';
import { normalizeTriggerPatterns, normalizeTriggerChannels } from '../utils/autoResponderUtils.js';
import { isWithinTimeWindow } from './utils/timeWindow.js';
import { isNodeComplete } from '../utils/nodeHelpers.js';
import { applyHomoglyphOptimization } from '../utils/homoglyph.js';
import { PortNum, RoutingError, isPkiError, getRoutingErrorName, CHANNEL_DB_OFFSET, TransportMechanism, isViaMqtt, MIN_TRACEROUTE_INTERVAL_MS } from './constants/meshtastic.js';
import { isAutoFavoriteEligible } from './constants/autoFavorite.js';
import { createRequire } from 'module';
import * as cron from 'node-cron';
import fs from 'fs';
import path from 'path';
const require = createRequire(import.meta.url);
const packageJson = require('../../package.json');
export interface MeshtasticConfig {
nodeIp: string;
tcpPort: number;
}
export interface ProcessingContext {
skipVirtualNodeBroadcast?: boolean;
virtualNodeRequestId?: number; // Packet ID from Virtual Node client for ACK matching
decryptedBy?: 'node' | 'server' | null; // How the packet was decrypted
decryptedChannelId?: number; // Channel Database entry ID for server-decrypted messages
}
// CHANNEL_DB_OFFSET is imported from './constants/meshtastic.js'
// Re-export for consumers who import from meshtasticManager
export { CHANNEL_DB_OFFSET } from './constants/meshtastic.js';
/**
* Link Quality scoring constants.
* Link Quality is a 0-10 score tracking the reliability of message routing to a node.
*/
export const LINK_QUALITY = {
/** Maximum quality score */
MAX: 10,
/** Minimum quality score (0 = dead link) */
MIN: 0,
/** Base value for initial calculation (LQ = BASE - hops) */
INITIAL_BASE: 8,
/** Default quality when hop count is unknown */
DEFAULT_QUALITY: 5,
/** Default hop count when unknown */
DEFAULT_HOPS: 3,
/** Bonus for stable/improved message delivery */
STABLE_MESSAGE_BONUS: 1,
/** Penalty for degraded routing (hops increased by 2+) */
DEGRADED_PATH_PENALTY: -1,
/** Penalty for failed traceroute */
TRACEROUTE_FAIL_PENALTY: -2,
/** Penalty for PKI/encryption error */
PKI_ERROR_PENALTY: -5,
/** Traceroute timeout in milliseconds (5 minutes) */
TRACEROUTE_TIMEOUT_MS: 5 * 60 * 1000,
} as const;
export interface DeviceInfo {
nodeNum: number;
user?: {
id: string;
longName: string;
shortName: string;
hwModel?: number;
role?: string;
};
position?: {
latitude: number;
longitude: number;
altitude?: number;
};
deviceMetrics?: {
batteryLevel?: number;
voltage?: number;
channelUtilization?: number;
airUtilTx?: number;
uptimeSeconds?: number;
};
hopsAway?: number;
lastHeard?: number;
snr?: number;
rssi?: number;
mobile?: number; // Database field: 0 = not mobile, 1 = mobile (moved >100m)
// Position precision fields
positionGpsAccuracy?: number; // GPS accuracy in meters
// Position override fields
positionOverrideEnabled?: boolean;
latitudeOverride?: number;
longitudeOverride?: number;
altitudeOverride?: number;
positionOverrideIsPrivate?: boolean;
positionIsOverride?: boolean;
}
export interface MeshMessage {
id: string;
from: string;
to: string;
fromNodeId: string; // For consistency with database
toNodeId: string; // For consistency with database
text: string;
channel: number;
portnum?: number;
timestamp: Date;
rxSnr?: number;
rxRssi?: number;
}
/**
* Determines if a packet should be excluded from the packet log.
* Internal packets (ADMIN_APP and ROUTING_APP) to/from the local node are excluded
* since they are management traffic, not actual mesh traffic.
*
* @param fromNum - Source node number
* @param toNum - Destination node number (null for broadcast)
* @param portnum - Port number indicating packet type
* @param localNodeNum - The local node's number (null if not connected)
* @returns true if the packet should be excluded from logging
*/
export function shouldExcludeFromPacketLog(
fromNum: number,
toNum: number | null,
portnum: number,
localNodeNum: number | null
): boolean {
// If we don't know the local node, can't determine if it's local traffic
if (!localNodeNum) return false;
// Check if packet is to/from the local node
const isLocalPacket = fromNum === localNodeNum || toNum === localNodeNum;
// Check if it's an internal portnum (ROUTING_APP or ADMIN_APP)
const isInternalPortnum = portnum === PortNum.ROUTING_APP || portnum === PortNum.ADMIN_APP;
return isLocalPacket && isInternalPortnum;
}
/**
* Determines if a packet is a "phantom" internal state update from the local device.
* These are packets the Meshtastic device sends to TCP clients to report its internal
* state, but they are NOT actual RF transmissions. They should not be logged as "TX"
* packets because they clutter the packet log and don't represent actual mesh traffic.
*
* Phantom packets are identified by:
* - from_node === localNodeNum (originated from local device)
* - transport_mechanism === INTERNAL (0) or undefined
* - hop_start === 0 or undefined (hasn't traveled any hops)
*
* @param fromNum - Source node number
* @param localNodeNum - The local node's number (null if not connected)
* @param transportMechanism - Transport mechanism from the packet (0 = INTERNAL)
* @param hopStart - Hop start value from the packet
* @returns true if the packet is a phantom internal state update
*/
export function isPhantomInternalPacket(
fromNum: number,
localNodeNum: number | null,
transportMechanism: number | undefined,
hopStart: number | undefined
): boolean {
// If we don't know the local node, can't determine if it's local traffic
if (!localNodeNum) return false;
// Must be from the local node
if (fromNum !== localNodeNum) return false;
// Transport mechanism must be INTERNAL (0) or undefined
// Note: TransportMechanism.INTERNAL === 0
const isInternalTransport = transportMechanism === undefined || transportMechanism === 0;
if (!isInternalTransport) return false;
// Hop start must be 0 or undefined (hasn't traveled any hops)
const hasNotTraveled = hopStart === undefined || hopStart === 0;
if (!hasNotTraveled) return false;
return true;
}
type TextMessage = {
id: string;
fromNodeNum: number;
toNodeNum: number;
fromNodeId: string;
toNodeId: string;
text: string;
channel: number;
portnum: 1; // TEXT_MESSAGE_APP
requestId?: number; // For Virtual Node messages, preserve packet ID for ACK matching
timestamp: number;
rxTime: number;
hopStart?: number;
hopLimit?: number;
relayNode?: number; // Last byte of the node that relayed this message
replyId?: number;
emoji?: number;
viaMqtt: boolean; // Capture whether message was received via MQTT bridge
rxSnr?: number; // SNR of received packet
rxRssi?: number; // RSSI of received packet
wantAck?: boolean; // Expect ACK for Virtual Node messages
deliveryState?: string; // Track delivery for Virtual Node messages
ackFailed?: boolean; // Whether ACK failed
routingErrorReceived?: boolean; // Whether a routing error was received
ackFromNode?: number; // Node that sent the ACK
createdAt: number;
decryptedBy?: 'node' | 'server' | null; // Decryption source - 'server' means read-only
};
/**
* Auto-responder trigger configuration
*/
interface AutoResponderTrigger {
trigger: string | string[];
response: string;
responseType?: 'text' | 'http' | 'script';
channel?: number | 'dm' | 'none';
verifyResponse?: boolean;
multiline?: boolean;
scriptArgs?: string; // Optional CLI arguments for script execution (supports token expansion)
}
/**
* Geofence trigger configuration
*/
interface GeofenceTriggerConfig {
id: string;
name: string;
enabled: boolean;
shape: { type: 'circle'; center: { lat: number; lng: number }; radiusKm: number }
| { type: 'polygon'; vertices: Array<{ lat: number; lng: number }> };
event: 'entry' | 'exit' | 'while_inside';
whileInsideIntervalMinutes?: number;
cooldownMinutes?: number; // Minimum time between triggers per node (0 = no cooldown)
nodeFilter: { type: 'all' } | { type: 'selected'; nodeNums: number[] };
responseType: 'text' | 'script';
response?: string;
scriptPath?: string;
scriptArgs?: string; // Optional CLI arguments for script execution (supports token expansion)
channel: number | 'dm' | 'none';
verifyResponse?: boolean; // Enable retry logic (3 attempts) for DM messages
lastRun?: number;
lastResult?: 'success' | 'error';
lastError?: string;
}
interface AutoPingSession {
requestedBy: number; // nodeNum of the user who requested
channel: number; // channel the DM came on
totalPings: number;
completedPings: number;
successfulPings: number;
failedPings: number;
intervalMs: number;
timer: ReturnType<typeof setInterval> | null;
pendingRequestId: number | null;
pendingTimeout: ReturnType<typeof setTimeout> | null;
startTime: number;
lastPingSentAt: number;
results: Array<{ pingNum: number; status: 'ack' | 'nak' | 'timeout'; durationMs?: number; sentAt: number }>;
}
class MeshtasticManager {
private transport: TcpTransport | null = null;
private isConnected = false;
private userDisconnectedState = false; // Track user-initiated disconnect
private tracerouteInterval: NodeJS.Timeout | null = null;
private tracerouteJitterTimeout: NodeJS.Timeout | null = null;
private tracerouteIntervalMinutes: number = 0;
private lastTracerouteSentTime: number = 0;
private localStatsInterval: NodeJS.Timeout | null = null;
private timeOffsetSamples: number[] = [];
private timeOffsetInterval: NodeJS.Timeout | null = null;
private localStatsIntervalMinutes: number = 15; // Default 5 minutes
private announceInterval: NodeJS.Timeout | null = null;
private announceCronJob: cron.ScheduledTask | null = null;
private timerCronJobs: Map<string, cron.ScheduledTask> = new Map();
private geofenceNodeState: Map<string, Set<number>> = new Map(); // geofenceId -> set of nodeNums currently inside
private geofenceWhileInsideTimers: Map<string, NodeJS.Timeout> = new Map(); // geofenceId -> interval timer
private geofenceCooldowns: Map<string, number> = new Map(); // "triggerId:nodeNum" -> firedAt timestamp
private pendingAutoTraceroutes: Set<number> = new Set(); // Track auto-traceroute targets for logging
private pendingTracerouteTimestamps: Map<number, number> = new Map(); // Track when traceroutes were initiated for timeout detection
private nodeLinkQuality: Map<number, { quality: number; lastHops: number }> = new Map(); // Track link quality per node
private remoteAdminScannerInterval: NodeJS.Timeout | null = null;
private remoteAdminScannerIntervalMinutes: number = 0; // 0 = disabled
private pendingRemoteAdminScans: Set<number> = new Set(); // Track nodes being scanned
private timeSyncInterval: NodeJS.Timeout | null = null;
private timeSyncIntervalMinutes: number = 0; // 0 = disabled
private pendingTimeSyncs: Set<number> = new Set(); // Track nodes being synced
private keyRepairInterval: NodeJS.Timeout | null = null;
private keyRepairEnabled: boolean = false;
private keyRepairIntervalMinutes: number = 5; // Default 5 minutes
private keyRepairMaxExchanges: number = 3; // Default 3 attempts
private keyRepairAutoPurge: boolean = false; // Default: don't auto-purge
private keyRepairImmediatePurge: boolean = false; // Default: don't immediately purge on detection
private serverStartTime: number = Date.now();
private localNodeInfo: {
nodeNum: number;
nodeId: string;
longName: string;
shortName: string;
hwModel?: number;
firmwareVersion?: string;
rebootCount?: number;
isLocked?: boolean; // Flag to prevent overwrites after initial setup
} | null = null;
private actualDeviceConfig: any = null; // Store actual device config (local node)
private actualModuleConfig: any = null; // Store actual module config (local node)
private sessionPasskey: Uint8Array | null = null; // Session passkey for local node (backward compatibility)
private sessionPasskeyExpiry: number | null = null; // Expiry time for local node (expires after 300 seconds)
// Per-node session passkey storage for remote admin commands
private remoteSessionPasskeys: Map<number, {
passkey: Uint8Array;
expiry: number
}> = new Map();
// Per-node config storage for remote nodes
private remoteNodeConfigs: Map<number, {
deviceConfig: any;
moduleConfig: any;
lastUpdated: number;
}> = new Map();
// Track pending module config requests so empty Proto3 responses can be mapped to the correct key
private pendingModuleConfigRequests: Map<number, string> = new Map();
// Track whether module configs have ever been fetched this process lifetime (skip on reconnect)
private moduleConfigsEverFetched: boolean = false;
// Per-node channel storage for remote nodes
private remoteNodeChannels: Map<number, Map<number, any>> = new Map();
// Per-node owner storage for remote nodes
private remoteNodeOwners: Map<number, any> = new Map();
// Per-node device metadata storage for remote nodes
private remoteNodeDeviceMetadata: Map<number, any> = new Map();
private favoritesSupportCache: boolean | null = null; // Cache firmware support check result
private cachedAutoAckRegex: { pattern: string; regex: RegExp } | null = null; // Cached compiled regex
// Auto-ping session tracking
private autoPingSessions: Map<number, AutoPingSession> = new Map(); // keyed by requester nodeNum
// Auto-welcome tracking to prevent race conditions
private welcomingNodes: Set<number> = new Set(); // Track nodes currently being welcomed
private autoFavoritingNodes = new Set<number>(); // Track nodes currently being auto-favorited
private deviceNodeNums: Set<number> = new Set(); // Nodes in the connected radio's local database
private autoFavoriteSweepRunning = false; // Prevent concurrent sweep operations
private rebootMergeInProgress = false; // Guard against broadcasts during node identity merge
// Virtual Node Server - Message capture for initialization sequence
private initConfigCache: Array<{ type: string; data: Uint8Array }> = []; // Store raw FromRadio messages with type metadata during init
private isCapturingInitConfig = false; // Flag to track when we're capturing messages
private configCaptureComplete = false; // Flag to track when capture is done
private onConfigCaptureComplete: (() => void) | null = null; // Callback for when config capture completes
constructor() {
// Initialize message queue service with send callback
messageQueueService.setSendCallback(async (text: string, destination: number, replyId?: number, channel?: number, emoji?: number) => {
// For channel messages: channel is specified, destination is 0 (undefined in sendTextMessage)
// For DMs: channel is undefined, destination is the node number
if (channel !== undefined) {
// Channel message - send to channel, no specific destination
return await this.sendTextMessage(text, channel, undefined, replyId, emoji);
} else {
// DM - use the channel we last heard the target node on
const targetNode = await databaseService.nodes.getNode(destination);
const dmChannel = (targetNode?.channel !== undefined && targetNode?.channel !== null) ? targetNode.channel : 0;
logger.debug(`📨 Queue DM to ${destination} - Using channel: ${dmChannel}`);
return await this.sendTextMessage(text, dmChannel, destination, replyId, emoji);
}
});
// Check if we need to recalculate estimated positions from historical traceroutes
this.checkAndRecalculatePositions();
}
/**
* Check if estimated position recalculation is needed and perform it.
* This is triggered by migration 038 which deletes old estimates and sets a flag.
*/
private async checkAndRecalculatePositions(): Promise<void> {
try {
await databaseService.waitForReady();
const recalculateFlag = await databaseService.settings.getSetting('recalculate_estimated_positions');
if (recalculateFlag !== 'pending') {
return;
}
logger.info('📍 Recalculating estimated positions from historical traceroutes...');
// Get all traceroutes with route data
const traceroutes = await databaseService.getAllTraceroutesForRecalculationAsync();
logger.info(`Found ${traceroutes.length} traceroutes to process for position estimation`);
let processedCount = 0;
for (const traceroute of traceroutes) {
try {
// Parse route array from JSON
const route = traceroute.route ? JSON.parse(traceroute.route) : [];
if (!Array.isArray(route) || route.length === 0) {
continue;
}
// Build the full route path: fromNode (requester/origin) -> route intermediates -> toNode (destination)
const fullRoute = [traceroute.fromNodeNum, ...route, traceroute.toNodeNum];
// Parse SNR array if available
let snrArray: number[] | undefined;
if (traceroute.snrTowards) {
const snrData = JSON.parse(traceroute.snrTowards);
if (Array.isArray(snrData) && snrData.length > 0) {
snrArray = snrData;
}
}
// Process the traceroute for position estimation
await this.estimateIntermediatePositions(fullRoute, traceroute.timestamp, snrArray);
processedCount++;
} catch (err) {
logger.debug(`Skipping traceroute ${traceroute.id} due to error: ${err}`);
}
}
logger.info(`✅ Processed ${processedCount} traceroutes for position estimation`);
// Clear the flag
await databaseService.settings.setSetting('recalculate_estimated_positions', 'completed');
} catch (error) {
logger.error('❌ Error recalculating estimated positions:', error);
}
}
/**
* Get environment configuration (always uses fresh values from getEnvironmentConfig)
* This ensures .env values are respected even if the manager is instantiated before dotenv loads
*/
private async getConfig(): Promise<MeshtasticConfig> {
const env = getEnvironmentConfig();
// Check for runtime override in settings (set via UI)
const overrideIp = await databaseService.settings.getSetting('meshtasticNodeIpOverride');
const overridePortStr = await databaseService.settings.getSetting('meshtasticTcpPortOverride');
const overridePort = overridePortStr ? parseInt(overridePortStr, 10) : null;
return {
nodeIp: overrideIp || env.meshtasticNodeIp,
tcpPort: (overridePort && !isNaN(overridePort)) ? overridePort : env.meshtasticTcpPort
};
}
/**
* Get connection config for scripts. When Virtual Node is enabled, returns
* localhost + virtual node port so scripts connect through the Virtual Node
* instead of opening a second TCP connection to the physical node (which would
* kill MeshMonitor's connection). Falls back to getConfig() when Virtual Node
* is disabled.
*/
private async getScriptConnectionConfig(): Promise<MeshtasticConfig> {
const env = getEnvironmentConfig();
if (env.enableVirtualNode) {
return {
nodeIp: '127.0.0.1',
tcpPort: env.virtualNodePort,
};
}
return await this.getConfig();
}
/**
* Set a runtime IP (and optionally port) override and reconnect
* Accepts formats: "192.168.1.100", "192.168.1.100:4403", "hostname", "hostname:4403"
* This setting is temporary and will reset when the container restarts
*/
async setNodeIpOverride(address: string): Promise<void> {
// Parse IP and optional port from address
let ip = address;
let port: string | null = null;
// Check for port suffix (handle both IPv4 and hostname with port)
const portMatch = address.match(/^(.+):(\d+)$/);
if (portMatch) {
ip = portMatch[1];
port = portMatch[2];
}
await databaseService.settings.setSetting('meshtasticNodeIpOverride', ip);
if (port) {
await databaseService.settings.setSetting('meshtasticTcpPortOverride', port);
} else {
// Clear port override if not specified (use default)
await databaseService.settings.setSetting('meshtasticTcpPortOverride', '');
}
// Disconnect and reconnect with new IP/port
this.disconnect();
await this.connect();
}
/**
* Clear the runtime IP/port override and revert to defaults
*/
async clearNodeIpOverride(): Promise<void> {
await databaseService.settings.setSetting('meshtasticNodeIpOverride', '');
await databaseService.settings.setSetting('meshtasticTcpPortOverride', '');
this.disconnect();
await this.connect();
}
/**
* Save an array of telemetry metrics to the database
* Filters out undefined/null/NaN values before inserting
*/
private async saveTelemetryMetrics(
metricsToSave: Array<{ type: string; value: number | undefined; unit: string }>,
nodeId: string,
fromNum: number,
timestamp: number,
packetTimestamp: number | undefined,
packetId?: number
): Promise<void> {
const now = Date.now();
for (const metric of metricsToSave) {
if (metric.value !== undefined && metric.value !== null && !isNaN(Number(metric.value))) {
await databaseService.telemetry.insertTelemetry({
nodeId,
nodeNum: fromNum,
telemetryType: metric.type,
timestamp,
value: Number(metric.value),
unit: metric.unit,
createdAt: now,
packetTimestamp,
packetId
});
}
}
}
async connect(): Promise<boolean> {
try {
const config = await this.getConfig();
logger.debug(`Connecting to Meshtastic node at ${config.nodeIp}:${config.tcpPort}...`);
// Initialize protobuf service first
await meshtasticProtobufService.initialize();
// Create TCP transport
this.transport = new TcpTransport();
// Configure connection timing from environment
const env = getEnvironmentConfig();
this.transport.setStaleConnectionTimeout(env.meshtasticStaleConnectionTimeout);
this.transport.setConnectTimeout(env.meshtasticConnectTimeoutMs);
this.transport.setReconnectTiming(env.meshtasticReconnectInitialDelayMs, env.meshtasticReconnectMaxDelayMs);
// Setup event handlers
this.transport.on('connect', () => {
this.handleConnected().catch((error) => {
logger.error('Error in handleConnected:', error);
});
});
this.transport.on('message', (data: Uint8Array) => {
this.processIncomingData(data);
});
this.transport.on('disconnect', () => {
this.handleDisconnected().catch((error) => {
logger.error('Error in handleDisconnected:', error);
});
});
this.transport.on('error', (error: Error) => {
logger.error('❌ TCP transport error:', error.message);
});
// Connect to node
// Note: isConnected will be set to true in handleConnected() callback
// when the connection is actually established
await this.transport.connect(config.nodeIp, config.tcpPort);
return true;
} catch (error) {
this.isConnected = false;
logger.error('Failed to connect to Meshtastic node:', error);
throw error;
}
}
private async handleConnected(): Promise<void> {
logger.debug('TCP connection established, requesting configuration...');
this.isConnected = true;
// Emit WebSocket event for connection status change
dataEventEmitter.emitConnectionStatus({
connected: true,
reason: 'TCP connection established'
});
// Clear localNodeInfo so node will be marked as not responsive until it sends MyNodeInfo
this.localNodeInfo = null;
// Notify server event service of connection (handles initial vs reconnect logic)
await serverEventNotificationService.notifyNodeConnected();
try {
// Enable message capture for virtual node server
// Clear any previous cache and start capturing
this.initConfigCache = [];
this.configCaptureComplete = false;
this.isCapturingInitConfig = true;
this.deviceNodeNums.clear();
logger.info('📸 Starting init config capture for virtual node server');
// Send want_config_id to request full node DB and config
await this.sendWantConfigId();
logger.debug('⏳ Waiting for configuration data from node...');
// Note: With TCP, we don't need to poll - messages arrive via events
// The configuration will come in automatically as the node sends it
// Explicitly request LoRa config (config type 5) for Configuration tab
// Give the device a moment to process want_config_id first
setTimeout(async () => {
try {
logger.info('📡 Requesting LoRa config from device...');
await this.requestConfig(5); // LORA_CONFIG = 5
} catch (error) {
logger.error('❌ Failed to request LoRa config:', error);
}
}, 2000);
// Request all module configs for complete device backup capability (skip on reconnect)
if (!this.moduleConfigsEverFetched) {
setTimeout(async () => {
try {
logger.info('📦 Requesting all module configs for backup...');
await this.requestAllModuleConfigs();
this.moduleConfigsEverFetched = true;
} catch (error) {
logger.error('❌ Failed to request all module configs:', error);
}
}, 3000); // Start after LoRa config request
} else {
logger.info('📦 Skipping module config request on reconnect (already fetched this session)');
}
// Give the node a moment to send initial config, then do basic setup
setTimeout(async () => {
// Channel 0 will be created automatically when device config syncs
// If localNodeInfo wasn't set during configuration, initialize it from database
if (!this.localNodeInfo) {
await this.initializeLocalNodeInfoFromDatabase();
}
// Start automatic traceroute scheduler
this.startTracerouteScheduler();
// Start remote admin discovery scanner
await this.startRemoteAdminScanner();
// Start automatic time sync scheduler
await this.startTimeSyncScheduler();
// Start automatic LocalStats collection
this.startLocalStatsScheduler();
// Start time-offset telemetry scheduler
this.startTimeOffsetScheduler();
// Start automatic announcement scheduler
await this.startAnnounceScheduler();
// Start timer trigger scheduler
await this.startTimerScheduler();
// Start geofence engine
await this.initGeofenceEngine();
// Start auto key repair scheduler
this.startKeyRepairScheduler();
// Auto-favorite staleness sweep - runs every 60 minutes
setInterval(() => {
this.autoFavoriteSweep().catch(error => {
logger.error('❌ Error in auto-favorite sweep interval:', error);
});
}, 60 * 60 * 1000);
// Run initial sweep after 30 seconds to handle cleanup from previous session
setTimeout(() => {
this.autoFavoriteSweep().catch(error => {
logger.error('❌ Error in initial auto-favorite sweep:', error);
});
}, 30000);
logger.debug(`✅ Configuration complete: ${await databaseService.nodes.getNodeCount()} nodes, ${await databaseService.channels.getChannelCount()} channels`);
}, 5000);
} catch (error) {
logger.error('❌ Failed to request configuration:', error);
await this.ensureBasicSetup();
}
}
private async handleDisconnected(): Promise<void> {
logger.debug('TCP connection lost');
this.isConnected = false;
// Emit WebSocket event for connection status change
dataEventEmitter.emitConnectionStatus({
connected: false,
nodeNum: this.localNodeInfo?.nodeNum,
nodeId: this.localNodeInfo?.nodeId,
reason: 'TCP connection lost'
});
// Clear localNodeInfo so node will be marked as not responsive
this.localNodeInfo = null;
// Clear favorites support cache on disconnect
this.favoritesSupportCache = null;
// Clear device/module config cache on disconnect
// This ensures fresh config is fetched on reconnect (prevents stale data after reboot)
this.actualDeviceConfig = null;
this.actualModuleConfig = null;
logger.debug('📸 Cleared device and module config cache on disconnect');
// Clear init config cache - will be repopulated on reconnect
// This ensures virtual node clients get fresh data if a different node reconnects
this.initConfigCache = [];
this.configCaptureComplete = false;
logger.debug('📸 Cleared init config cache on disconnect');
// Notify server event service of disconnection
// Skip notification if this is a user-initiated disconnect (already notified in userDisconnect())
if (!this.userDisconnectedState) {
await serverEventNotificationService.notifyNodeDisconnected();
}
// Only auto-reconnect if not in user-disconnected state
if (this.userDisconnectedState) {
logger.debug('User-initiated disconnect active, skipping auto-reconnect');
} else {
// Transport will handle automatic reconnection
logger.debug('Auto-reconnection will be attempted by transport');
}
}
private async createDefaultChannels(): Promise<void> {
logger.debug('📡 Creating default channel configuration...');
// Create default channel with ID 0 for messages that use channel 0
// This is Meshtastic's default channel when no specific channel is configured
try {
const existingChannel0 = await databaseService.channels.getChannelById(0);
if (!existingChannel0) {
// Manually insert channel with ID 0 since it might not come from device
// Use upsertChannel to properly set role=PRIMARY (1)
await databaseService.channels.upsertChannel({
id: 0,
name: 'Primary',
role: 1 // PRIMARY
});
logger.debug('📡 Created Primary channel with ID 0 and role PRIMARY');
}
} catch (error) {
logger.error('❌ Failed to create Primary channel:', error);
}
}
private async ensureBasicSetup(): Promise<void> {
logger.debug('🔧 Ensuring basic setup is complete...');
// Ensure we have at least a Primary channel
const channelCount = await databaseService.channels.getChannelCount();
if (channelCount === 0) {
await this.createDefaultChannels();
}
// Note: Don't create fake nodes - they will be discovered naturally through mesh traffic
logger.debug('✅ Basic setup ensured');
}
/**
* Log an outgoing packet to the packet monitor
* @param portnum The portnum (e.g., 1 for TEXT_MESSAGE, 6 for ADMIN, 70 for TRACEROUTE)
* @param destination The destination node number
* @param channel The channel number
* @param payloadPreview Human-readable preview of what was sent
* @param metadata Additional metadata object
*/
private async logOutgoingPacket(
portnum: number,
destination: number,
channel: number,
payloadPreview: string,
metadata: Record<string, unknown> = {}
): Promise<void> {
if (!await packetLogService.isEnabled()) return;
const localNodeNum = this.localNodeInfo?.nodeNum;
if (!localNodeNum) return;
const localNodeId = `!${localNodeNum.toString(16).padStart(8, '0')}`;
const toNodeId = destination === 0xffffffff
? 'broadcast'
: `!${destination.toString(16).padStart(8, '0')}`;
packetLogService.logPacket({
timestamp: Date.now(),
from_node: localNodeNum,
from_node_id: localNodeId,
to_node: destination,
to_node_id: toNodeId,
channel: channel,
portnum: portnum,
portnum_name: meshtasticProtobufService.getPortNumName(portnum),
encrypted: false, // Outgoing packets are logged before encryption
payload_preview: payloadPreview,
metadata: JSON.stringify({ ...metadata, direction: 'tx' }),
direction: 'tx',
transport_mechanism: TransportMechanism.INTERNAL, // Outgoing packets are sent via direct connection
});
}
private async sendWantConfigId(): Promise<void> {
if (!this.transport) {
throw new Error('Transport not initialized');
}
try {
logger.debug('Sending want_config_id to trigger configuration data...');
// Use the new protobuf service to create a proper want_config_id message
const wantConfigMessage = meshtasticProtobufService.createWantConfigRequest();
await this.transport.send(wantConfigMessage);
logger.debug('Successfully sent want_config_id request');
} catch (error) {
logger.error('Error sending want_config_id:', error);
throw error;
}
}
disconnect(): void {
this.isConnected = false;
if (this.transport) {
this.transport.disconnect();
this.transport = null;
}
if (this.tracerouteJitterTimeout) {
clearTimeout(this.tracerouteJitterTimeout);
this.tracerouteJitterTimeout = null;
}
if (this.tracerouteInterval) {
clearInterval(this.tracerouteInterval);
this.tracerouteInterval = null;
}
if (this.remoteAdminScannerInterval) {
clearInterval(this.remoteAdminScannerInterval);
this.remoteAdminScannerInterval = null;
}
if (this.timeSyncInterval) {
clearInterval(this.timeSyncInterval);
this.timeSyncInterval = null;
}
// Stop LocalStats collection
this.stopLocalStatsScheduler();
// Stop time-offset telemetry collection
this.stopTimeOffsetScheduler();
this.timeOffsetSamples = [];
logger.debug('Disconnected from Meshtastic node');
}
/**
* Register a callback to be called when config capture is complete
* This is used to initialize the virtual node server after connection is ready
*/
public registerConfigCaptureCompleteCallback(callback: () => void): void {
this.onConfigCaptureComplete = callback;
}
private startTracerouteScheduler(): void {
// Clear any pending jitter timeout to prevent leaked timers
if (this.tracerouteJitterTimeout) {
clearTimeout(this.tracerouteJitterTimeout);
this.tracerouteJitterTimeout = null;
}
if (this.tracerouteInterval) {
clearInterval(this.tracerouteInterval);
this.tracerouteInterval = null;
}
// If interval is 0, traceroute is disabled
if (this.tracerouteIntervalMinutes === 0) {
logger.debug('🗺️ Automatic traceroute is disabled');
return;
}
const intervalMs = this.tracerouteIntervalMinutes * 60 * 1000;
// Add random initial jitter (0 to min of interval or 5 minutes) to prevent network bursts
// when multiple MeshMonitor instances start at similar times with the same interval.
// Only the first execution is delayed; subsequent runs use the regular interval.
const maxJitterMs = Math.min(intervalMs, 5 * 60 * 1000); // Cap at 5 minutes
const initialJitterMs = Math.random() * maxJitterMs;
const jitterSeconds = Math.round(initialJitterMs / 1000);
logger.debug(`🗺️ Starting traceroute scheduler with ${this.tracerouteIntervalMinutes} minute interval (initial jitter: ${jitterSeconds}s)`);
// The traceroute execution logic
const executeTraceroute = async () => {
// Check time window schedule
const scheduleEnabled = await databaseService.settings.getSetting('tracerouteScheduleEnabled');
if (scheduleEnabled === 'true') {
const start = await databaseService.settings.getSetting('tracerouteScheduleStart') || '00:00';
const end = await databaseService.settings.getSetting('tracerouteScheduleEnd') || '00:00';
if (!isWithinTimeWindow(start, end)) {
logger.debug(`🗺️ Auto-traceroute: Skipping - outside schedule window (${start}-${end})`);
return;
}
}
if (this.isConnected && this.localNodeInfo) {
try {
// Enforce minimum interval between traceroute sends (Meshtastic firmware rate limit)
const timeSinceLastSend = Date.now() - this.lastTracerouteSentTime;
if (this.lastTracerouteSentTime > 0 && timeSinceLastSend < MIN_TRACEROUTE_INTERVAL_MS) {
logger.debug(`🗺️ Auto-traceroute: Skipping - only ${Math.round(timeSinceLastSend / 1000)}s since last send (minimum ${MIN_TRACEROUTE_INTERVAL_MS / 1000}s)`);
return;
}
// Use async version which supports PostgreSQL/MySQL
const targetNode = await databaseService.getNodeNeedingTracerouteAsync(this.localNodeInfo.nodeNum);
if (targetNode) {
const channel = targetNode.channel ?? 0; // Use node's channel, default to 0
const targetName = targetNode.longName || targetNode.nodeId;
logger.info(`🗺️ Auto-traceroute: Sending traceroute to ${targetName} (${targetNode.nodeId}) on channel ${channel}`);
// Log the auto-traceroute attempt to database
await databaseService.logAutoTracerouteAttemptAsync(targetNode.nodeNum, targetName);
this.pendingAutoTraceroutes.add(targetNode.nodeNum);
this.pendingTracerouteTimestamps.set(targetNode.nodeNum, Date.now());
this.lastTracerouteSentTime = Date.now();
await this.sendTraceroute(targetNode.nodeNum, channel);
// Check for timed-out traceroutes (> 5 minutes old)
this.checkTracerouteTimeouts();
} else {
logger.info('🗺️ Auto-traceroute: No nodes available for traceroute');
}
} catch (error) {
logger.error('❌ Error in auto-traceroute:', error);
}
} else {
logger.info('🗺️ Auto-traceroute: Skipping - not connected or no local node info');
}
};
// Delay first execution by jitter, then start regular interval
this.tracerouteJitterTimeout = setTimeout(() => {
this.tracerouteJitterTimeout = null;
// Execute first traceroute
executeTraceroute();
// Start regular interval (no jitter on subsequent runs)
this.tracerouteInterval = setInterval(executeTraceroute, intervalMs);
}, initialJitterMs);
}
setTracerouteInterval(minutes: number): void {
if (minutes < 0 || minutes > 60) {
throw new Error('Traceroute interval must be between 0 and 60 minutes (0 = disabled)');
}
this.tracerouteIntervalMinutes = minutes;
if (minutes === 0) {
logger.debug('🗺️ Traceroute interval set to 0 (disabled)');
} else {
logger.debug(`🗺️ Traceroute interval updated to ${minutes} minutes`);