-
-
Notifications
You must be signed in to change notification settings - Fork 72
Expand file tree
/
Copy pathmeshcoreManager.ts
More file actions
6818 lines (6320 loc) · 286 KB
/
Copy pathmeshcoreManager.ts
File metadata and controls
6818 lines (6320 loc) · 286 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
/**
* MeshCore Manager - Core connection and communication layer for MeshCore devices
*
* This replaces MeshtasticManager for MeshCore protocol support.
*
* MeshCore has two firmware types:
* - Companion: Full-featured, uses the meshcore.js native JS backend
* - Repeater: Lightweight, uses text CLI commands over direct serial
*/
import { EventEmitter } from 'events';
import { logger } from '../utils/logger.js';
import { isNullIsland } from '../utils/nullIsland.js';
import databaseService from '../services/database.js';
import { dataEventEmitter } from './services/dataEventEmitter.js';
import { compileAutoAckRegex } from './utils/autoAckRegex.js';
import { resolveAutoAckPreSendDelaySeconds } from './autoAckDelay.js';
import { scheduleCron, validateCron, type CronJob } from './utils/cronScheduler.js';
import { replaceMeshCoreAnnounceTokens } from './utils/meshcoreAnnounceTokens.js';
import { runScript, type RunScriptResult } from './utils/scriptRunner.js';
import { MeshCoreNativeBackend, type BridgeShapedEvent } from './meshcoreNativeBackend.js';
import { resolveMessageScope } from './meshcoreScopeResolve.js';
import {
MeshCoreVirtualNodeServer,
type MeshCoreVirtualNodeConfig,
} from './meshcoreVirtualNodeServer.js';
import meshcorePacketLogService from './services/meshcorePacketLogService.js';
import { notificationService } from './services/notificationService.js';
import { DistanceDeleteScheduler } from './services/distanceDeleteScheduler.js';
import { HeartbeatScheduler } from './services/heartbeatScheduler.js';
import type { DbMeshCorePacket } from '../db/repositories/meshcore.js';
import type { ISourceManager, SourceStatus } from './sourceManagerRegistry.js';
import { decodeMeshCorePacket } from '../utils/meshcorePacketDecode.js';
import { MESHCORE_SECRET_BYTES } from '../utils/meshcoreHelpers.js';
import { tryDecodeGroupTextPayload } from './utils/meshcoreGroupEcho.js';
// Dynamic imports for optional serialport dependency
// These are loaded only when MeshCore is enabled to avoid requiring native build tools
let SerialPort: typeof import('serialport').SerialPort | null = null;
let ReadlineParser: typeof import('@serialport/parser-readline').ReadlineParser | null = null;
async function loadSerialPort(): Promise<boolean> {
if (SerialPort !== null) return true;
try {
const serialportModule = await import('serialport');
const parserModule = await import('@serialport/parser-readline');
SerialPort = serialportModule.SerialPort;
ReadlineParser = parserModule.ReadlineParser;
logger.info('[MeshCore] Serial port support loaded');
return true;
} catch (error) {
logger.warn('[MeshCore] Serial port not available - install serialport package for serial support');
return false;
}
}
// Telemetry mode wire values: 0 = never, 1 = device (only added contacts), 2 = always
function parseTelemetryMode(value: unknown): TelemetryMode | undefined {
if (value === 0) return 'never';
if (value === 1) return 'device';
if (value === 2) return 'always';
return undefined;
}
/**
* Decode the hop count from the packed MeshCore `pathLen` byte. The wire
* format packs the hash-size in the top 2 bits and the hop count in the
* bottom 6 bits. The sentinel value 0xFF means "sent direct" (no flood),
* which we report as 0 hops. Returns null when no value is available.
*/
function decodePathLenHopCount(pathLen: number | undefined | null): number | null {
if (pathLen === undefined || pathLen === null) return null;
if (pathLen === 0xff) return 0;
return pathLen & 0x3f;
}
/**
* Render a relay-hash hop list (already-hex strings from LogRxData parsing)
* into the comma-separated form `replaceAutoAckTokens` expects for
* {ROUTE}. Returns null when no hops are present.
*/
function formatPathHops(hops: unknown): string | null {
if (!Array.isArray(hops) || hops.length === 0) return null;
const filtered = hops.filter((h): h is string => typeof h === 'string' && h.length > 0);
return filtered.length > 0 ? filtered.join(',') : null;
}
/**
* Validate-and-extract barrier for a caller-supplied MeshCore public key.
*
* Used by code paths that may either target a remote contact (64-char
* hex pubkey) or fall through to a local CLI variant when no key is
* supplied. Returning the sanitised value as a NEW variable — rather
* than just throwing on bad input — gives CodeQL a recognisable
* sanitiser barrier for the `js/user-controlled-bypass` query: every
* downstream branch reads `sanitizedKey`, not the original input.
*
* - undefined / null / '' → null (route to the local variant)
* - 64-char hex string → lowercase normalised form
* - anything else → throws an explanatory Error
*/
function validateMeshCorePubKey(input: string | null | undefined): string | null {
if (input === undefined || input === null || input === '') return null;
const normalized = input.toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) {
throw new Error('publicKey must be a 64-char hex string');
}
return normalized;
}
/**
* Decide whether a channel slot reported by the firmware is actually in use.
* MeshCore Companion firmware doesn't error on unconfigured slots — it
* returns success with an empty name and a 16-byte all-zero secret. We
* treat that exact shape as "empty slot" and skip it during DB sync. A slot
* with EITHER a non-empty name OR a non-zero secret is considered
* configured (so a user who chose to leave the name blank but generated a
* real key is preserved).
*/
function isConfiguredMeshCoreChannel(ch: { name: string; secretHex: string }): boolean {
const nameTrim = (ch.name || '').trim();
const hasName = nameTrim.length > 0;
const hasSecret = !!ch.secretHex && /[1-9a-f]/i.test(ch.secretHex);
return hasName || hasSecret;
}
// MeshCore device types
export enum MeshCoreDeviceType {
UNKNOWN = 0,
COMPANION = 1,
REPEATER = 2,
ROOM_SERVER = 3,
}
// Human-readable labels for the device types worth surfacing in a
// "new node discovered" notification (UNKNOWN is intentionally omitted so the
// notification simply shows no type rather than "Unknown").
const MESHCORE_DEVICE_TYPE_LABELS: Record<number, string> = {
[MeshCoreDeviceType.COMPANION]: 'Companion',
[MeshCoreDeviceType.REPEATER]: 'Repeater',
[MeshCoreDeviceType.ROOM_SERVER]: 'Room Server',
};
/**
* Node-type filter bitmasks for active discovery (CTL_TYPE_NODE_DISCOVER_REQ).
* The wire `filter` byte is a bitmask of `(1 << ADV_TYPE)` — only nodes whose
* advert type bit is set will respond. ADV_TYPE values (firmware): Chat=1,
* Repeater=2, Room=3, Sensor=4. See `reference_meshcore_node_discovery_protocol`.
*/
export const MeshCoreDiscoverFilter = {
/**
* All node types (Chat | Repeater | Room | Sensor) — the broad "nearby"
* sweep, matching the mobile app's "Discover Nearby Nodes". In practice
* only repeaters/room-servers/sensors answer (their firmware self-responds);
* companion (Chat) devices do not reply to discovery in current MeshCore
* firmware, so they won't appear here. The Chat bit is still set so they'd
* be surfaced if a responder ever exists (see MeshCore issue #1027).
*/
NEARBY: (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4), // 0x1E
/** Infrastructure only: repeaters + room servers (ADV_TYPE_REPEATER | ADV_TYPE_ROOM). */
REPEATERS: (1 << 2) | (1 << 3), // 0x0C
/** Sensors only (ADV_TYPE_SENSOR) — matches the mobile app's "Discover Sensors". */
SENSORS: 1 << 4, // 0x10
} as const;
export type MeshCoreDiscoverMode = 'nearby' | 'repeaters' | 'sensors';
/** Result of a share-contact request, carrying an actionable reason on failure. */
export interface ShareContactResult {
ok: boolean;
error?: string;
}
/**
* Result of {@link MeshCoreManager.syncDeviceTime}. `reason` distinguishes the
* pre-flight guard failures (which the route reports as a 409) from an actual
* device/command failure (`command-failed`, reported as a 502 with `error`).
*/
export type SyncDeviceTimeResult =
| { ok: true }
| { ok: false; reason: 'not-companion' | 'disconnected' | 'command-failed'; error?: string };
// Connection types
export enum ConnectionType {
SERIAL = 'serial',
TCP = 'tcp',
}
/**
* Operator-defined auto-responder trigger, persisted per source as a
* JSON array at the `meshcoreAutoResponderTriggers` setting key. Fires
* from the manager's incoming-message handler — one regex per row, one
* text response per match. Intentionally narrower than the Meshtastic
* AutoResponder (no HTTP/script/traceroute response types) so v1 can
* land without dragging the script-runner sandbox into the MeshCore
* surface.
*/
/** Scope/region selection shared by every MeshCore automation that sends a
* message (auto-responder, auto-ack, auto-announce, timer triggers) — #3833. */
export type MeshCoreAutomationScopeMode = 'inherit' | 'trigger' | 'unscoped' | 'named';
export interface MeshCoreAutomationScopeConfig {
/**
* Region a sent message floods to (the only per-message propagation lever in
* MeshCore — repeaters only forward a scoped flood for regions they carry).
* `inherit` (default) = channel scope → source default; `trigger` = the
* triggering message's own scope; `unscoped` = flood with no region;
* `named` = the region in `scopeName`.
*/
scopeMode?: MeshCoreAutomationScopeMode;
/** Region name when `scopeMode === 'named'`. */
scopeName?: string;
}
export interface MeshCoreAutoResponderTrigger extends MeshCoreAutomationScopeConfig {
id: string;
name: string;
enabled: boolean;
/** Case-insensitive regex matched against incoming message text. */
pattern: string;
/**
* Action to take on match. `text` sends `response` rendered against
* announce tokens; `script` runs `scriptPath` and sends each entry of
* the script's `wouldSendMessages` output.
*/
responseType?: 'text' | 'script';
/** Response template (only consulted for responseType === 'text'). */
response: string;
/** Script filename inside /data/scripts (responseType === 'script'). */
scriptPath?: string;
/** Whitespace-separated argv passed to the script. Token-expanded. */
scriptArgs?: string;
/** Channel indexes the trigger should listen on. Omit/empty = none. */
channels: number[];
/** Listen on DMs in addition to channels. */
listenDMs: boolean;
/** Always answer as a DM, even when the trigger came from a channel. */
replyAsDM: boolean;
/** Per-sender cooldown in seconds. 0 disables. */
cooldownSeconds: number;
}
/**
* Operator-defined timer trigger persisted (per source) as a JSON array
* at the `meshcoreTimerTriggers` setting key. One scheduler entry per
* row; the runner reads the row by id at fire time so a freshly-saved
* template applies on the next tick.
*/
export interface MeshCoreTimerTrigger extends MeshCoreAutomationScopeConfig {
id: string;
name: string;
enabled: boolean;
/** `cron` uses cronExpression; `interval` uses intervalMinutes. */
scheduleType: 'cron' | 'interval';
cronExpression?: string;
intervalMinutes?: number;
/**
* `text` sends `response` rendered against announce tokens;
* `advert` ignores `response`;
* `script` runs `scriptPath` and routes wouldSendMessages to
* `destination` (channel or dm).
*/
responseType: 'text' | 'advert' | 'script';
response?: string;
/** Script filename inside /data/scripts (responseType === 'script'). */
scriptPath?: string;
/** Whitespace-separated argv passed to the script. Token-expanded. */
scriptArgs?: string;
/** Where to send a `text` / `script` response. `channel` requires `channelIndex`; `dm` requires `contactPublicKey`. */
destination?: 'channel' | 'dm';
channelIndex?: number;
contactPublicKey?: string;
// Last-run telemetry. Written by `runTimerTrigger` so the UI can show
// "ran 5m ago" without re-querying the device.
lastRun?: number;
lastResult?: 'success' | 'error';
lastError?: string;
}
export interface MeshCoreConfig {
connectionType: ConnectionType;
serialPort?: string;
tcpHost?: string;
tcpPort?: number;
baudRate?: number;
firmwareType?: 'companion' | 'repeater';
// Heartbeat / auto-reconnect (native-backend only; default off).
// See docs/internal/meshcore-design/meshcore-heartbeat-proposal.md.
heartbeatIntervalSeconds?: number; // 0 = disabled. v1 native default: 0.
heartbeatTimeoutMs?: number; // default 5000.
heartbeatMaxFailures?: number; // default 3.
reconnectInitialDelayMs?: number; // default 1000.
reconnectMaxDelayMs?: number; // default 60000.
reconnectMaxAttempts?: number; // default 0 (forever).
// Virtual Node server (opt-in). Exposes this MeshCore node over TCP so the
// MeshCore mobile app can connect through MeshMonitor over WiFi (issue #3535).
// See docs/internal/dev-notes/MESHCORE_VIRTUAL_NODE_DESIGN.md.
virtualNode?: MeshCoreVirtualNodeConfig;
}
export type MeshCoreConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'failed';
export interface MeshCoreHeartbeatStatus {
state: MeshCoreConnectionState;
consecutiveFailures: number;
lastSuccessfulProbeAt: number | null;
nextReconnectAt: number | null;
reconnectAttempts: number;
}
export type TelemetryMode = 'always' | 'device' | 'never';
export interface MeshCoreNode {
publicKey: string;
name: string;
advType: MeshCoreDeviceType;
txPower?: number;
maxTxPower?: number;
radioFreq?: number;
radioBw?: number;
radioSf?: number;
radioCr?: number;
lastHeard?: number;
rssi?: number;
snr?: number;
batteryMv?: number;
uptimeSecs?: number;
latitude?: number;
longitude?: number;
advLocPolicy?: number;
/** Add contacts only on explicit request (1) vs automatically (0). From SelfInfo. */
manualAddContacts?: number;
/**
* Server-side favorite flag (migration 094). Stored locally only — never
* pushed to the device. Favorited nodes pin to the top of the node list.
*/
isFavorite?: boolean;
telemetryModeBase?: TelemetryMode;
telemetryModeLoc?: TelemetryMode;
telemetryModeEnv?: TelemetryMode;
/** From DeviceQuery — populated by the telemetry poller. In-memory only;
* re-derived from the device on each poll cycle (no DB persistence). */
firmwareVer?: number;
firmwareBuild?: string;
model?: string;
ver?: string;
}
export interface MeshCoreContact {
publicKey: string;
advName?: string;
name?: string;
lastSeen?: number;
rssi?: number;
snr?: number;
advType?: MeshCoreDeviceType;
latitude?: number;
longitude?: number;
lastAdvert?: number;
/**
* Hop count of the cached forwarding route to this contact. `null` /
* undefined means the firmware's OUT_PATH_UNKNOWN sentinel is set —
* the next send will be flooded.
*/
pathLen?: number | null;
/**
* Comma-separated hex chain of hop hashes for the cached forwarding
* route (e.g. "a3,7f,02"). `null` / undefined means OUT_PATH_UNKNOWN.
*/
outPath?: string | null;
}
/**
* Result of a MeshCore send. `ok` mirrors the legacy boolean return of
* `sendMessage`; for a DM the firmware also assigns an `expectedAckCrc` (the
* value a later SendConfirmed push will carry) and an `estTimeout` hint, both
* surfaced via {@link MeshCoreManager.sendMessageWithResult} for the Virtual
* Node ack bridge (#3869). Channel sends are unacked, so these are undefined.
*/
export interface MeshCoreSendResult {
ok: boolean;
expectedAckCrc?: number;
estTimeout?: number;
}
export interface MeshCoreMessage {
id: string;
fromPublicKey: string;
/** Sender display name. For channel messages, parsed from the "Name: " prefix
* MeshCore devices add to the body (channel packets carry no per-sender identity
* on the wire). For DMs and room posts, taken from the resolved contact. */
fromName?: string;
toPublicKey?: string; // null for broadcast
text: string;
timestamp: number;
rssi?: number;
snr?: number;
/**
* Owning source. Set by the MeshCoreManager that produced the message;
* persisted into meshcore_messages.sourceId so the row can be filtered
* per source.
*/
sourceId?: string;
/** 'text' (default, DMs + channel) or 'room_post' (room server posts). */
messageType?: string;
/** 4-byte CRC from RESP_CODE_SENT — correlates with PUSH_CODE_SEND_CONFIRMED
* to confirm delivery. Only set on outgoing DMs (not channels). */
expectedAckCrc?: number;
/** Estimated timeout in ms before the message should be considered failed.
* Only set on outgoing DMs. */
estTimeout?: number;
/** Repeaters that re-flooded this (outgoing channel) message, inferred by
* self-echo correlation (#3700). Best-effort; only set on outgoing channel
* messages that were heard re-flooded. `name` is null when the relay hash
* couldn't be resolved to a known contact. */
heardBy?: Array<{ hash: string; name?: string | null; snr?: number | null }>;
/** Hop count for a received message (from path_len); null = direct / unknown.
* Room messages carry no path, so this stays null for them. (#3742) */
hopCount?: number | null;
/** Raw packed `path_len` byte as reported by the device for a received message
* (0xff = direct). Preserved verbatim so the Virtual Node bridge can forward
* the real hop count to a companion instead of always "direct" (#3871). */
pathLen?: number | null;
/** Relay-hash chain the message traveled, comma-separated (e.g. "a3,7f,02");
* null when no path was reported. (#3742) */
routePath?: string | null;
/** Scope/region the message was sent with (#3742 Phase 2). `scopeCode` is the
* packet's transport_code_1: 0 = sent unscoped, null = no scope info. */
scopeCode?: number | null;
/** Region name resolved from `scopeCode` against this source's known scopes;
* null = unscoped, or scoped-but-unknown (then the UI shows `#<code-hex>`). */
scopeName?: string | null;
}
export interface MeshCoreStatus {
batteryMv?: number;
uptimeSecs?: number;
// Repeater operational stats exposed by SendStatusReq → StatusResponse.
// Always populated when the remote node is a Repeater or Room Server;
// typically absent when the target is another Companion since Companion
// firmware doesn't ship these counters.
queueLen?: number;
noiseFloor?: number;
lastRssi?: number;
lastSnr?: number;
packetsRecv?: number;
packetsSent?: number;
airTimeSecs?: number;
sentFlood?: number;
sentDirect?: number;
recvFlood?: number;
recvDirect?: number;
errors?: number;
directDups?: number;
floodDups?: number;
// Companion-only fields (radio config etc.). Kept on the interface for
// backwards compatibility with callers that ask Companion targets for status.
txPower?: number;
radioFreq?: number;
radioBw?: number;
radioSf?: number;
radioCr?: number;
}
/**
* A single channel slot on a MeshCore device. The wire model is just
* { channelIdx, name, secret(16 bytes) } — see meshcore.js connection.js:605.
* The secret travels as a hex string in our API for human readability;
* we re-encode to base64 when mirroring into the shared `channels.psk` column.
*/
export interface MeshCoreChannel {
channelIdx: number;
name: string;
/** 32-char lowercase hex (16 bytes). Empty string if the firmware reports an empty slot. */
secretHex: string;
}
/**
* Local-node stats fetched over the companion-protocol link. These never
* touch the air — they read counters/state from the directly-connected node.
*/
export interface MeshCoreStatsCore {
batteryMv?: number;
uptimeSecs?: number;
errors?: number;
queueLen?: number;
}
export interface MeshCoreStatsRadio {
noiseFloor?: number;
lastRssi?: number;
lastSnr?: number;
txAirSecs?: number;
rxAirSecs?: number;
}
export interface MeshCoreStatsPackets {
recv?: number;
sent?: number;
floodTx?: number;
directTx?: number;
floodRx?: number;
directRx?: number;
recvErrors?: number | null;
}
/**
* One LPP telemetry record decoded from a remote `req_telemetry_sync`
* response. `type` is the raw Cayenne-LPP type id (e.g. 116=voltage,
* 103=temperature, 104=humidity, 115=barometer, 121=altitude, 136=gps).
* `value` is whatever the encoder produced — a scalar for single-value
* types, a dict for multi-axis types like gps.
*/
export interface MeshCoreTelemetryRecord {
channel: number;
type: number | null;
value: number | string | Record<string, number> | number[] | null;
}
export interface MeshCoreDeviceInfo {
firmwareVer?: number;
firmwareBuild?: string;
model?: string;
ver?: string;
maxContacts?: number;
maxChannels?: number;
blePin?: number;
repeat?: boolean;
pathHashMode?: number;
}
// Bridge-shaped command response. The wire vocabulary ("bridge") is preserved
// from the original Python-bridge era so the manager's command surface didn't
// have to change when the native JS backend took over.
interface BridgeResponse {
id: string;
success: boolean;
data?: any;
error?: string;
}
/**
* MeshCore Manager class
* Handles connection and communication with MeshCore devices
*/
class MeshCoreManager extends EventEmitter implements ISourceManager {
/**
* The owning source this manager belongs to. Every write the manager
* performs into `meshcore_nodes` / `meshcore_messages` is stamped with
* this id. Required since slice 1 of the multi-source MeshCore refactor
* (migration 056).
*/
public readonly sourceId: string;
/** Human-readable name for aggregate status; defaults to sourceId when absent. */
private sourceName: string;
/** Config stored by configure() so parameterless start() can connect. */
private pendingConfig: MeshCoreConfig | null = null;
private config: MeshCoreConfig | null = null;
private connected: boolean = false;
private deviceType: MeshCoreDeviceType = MeshCoreDeviceType.UNKNOWN;
// Public keys we've already fired a "new node discovered" notification for
// this session. Guards against re-notifying when a brand-new contact
// re-advertises before the in-memory contact store reflects it.
private notifiedNewNodes: Set<string> = new Set();
// Repeater: direct serial
private serialPort: InstanceType<typeof import('serialport').SerialPort> | null = null;
private parser: InstanceType<typeof import('@serialport/parser-readline').ReadlineParser> | null = null;
// Companion: native JS backend (meshcore.js). sendBridgeCommand delegates here.
private nativeBackend: MeshCoreNativeBackend | null = null;
private virtualNodeServer: MeshCoreVirtualNodeServer | null = null;
// Heartbeat / auto-reconnect state (native-backend only).
private connectionState: MeshCoreConnectionState = 'disconnected';
private heartbeatScheduler: HeartbeatScheduler | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private heartbeatConsecutiveFailures: number = 0;
/** Coalescing state for `contact_path_updated` pushes. Multiple pushes
* within {@link PATH_REFRESH_DEBOUNCE_MS} collapse to a single
* refreshContacts() call so a chatty contact churning its route doesn't
* thunder the device. The set tracks which pubkeys had pushes during
* the window — purely for logging; the refresh fetches everything. */
private pathRefreshTimer: NodeJS.Timeout | null = null;
private pathRefreshPendingKeys: Set<string> = new Set();
/**
* Keeps the local Companion node's RTC honest. MeshCore stamps every
* outbound frame (adverts, DMs, and crucially remote-admin `clock sync`
* commands) with the local node's own clock; if that clock is never set it
* drifts and poisons those timestamps (issue #3954). We push the server's
* wall clock on connect and then periodically for the life of the session.
*/
private deviceTimeSyncTimer: NodeJS.Timeout | null = null;
private static readonly DEVICE_TIME_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
private heartbeatLastSuccessAt: number | null = null;
private reconnectAttempts: number = 0;
private nextReconnectAt: number | null = null;
private shouldReconnect: boolean = false;
/**
* True while an *intentional* teardown is in progress (disconnect() or
* teardownTransportOnly()). Closing the native backend makes meshcore.js emit
* its own 'disconnected' event; this flag lets the manager's handler tell an
* operator/heartbeat-driven teardown apart from an unexpected socket drop so
* it doesn't double-tear-down or fight an in-flight reconnect. Reset in
* connect() once a fresh attempt begins.
*/
private intentionalTeardown: boolean = false;
// MeshCore region/scope (#3667). The device holds a SINGLE global flood
// scope, asserted via the `set_flood_scope` bridge command before a send.
// `activeFloodScope` caches the region name currently set on the device
// (`null` = unscoped, `undefined` = unknown/not-yet-asserted) so we skip the
// extra round-trip when the next send needs the same scope. `sendScopeLock`
// serialises the set-scope→send pair per source: because the scope is global
// and stateful, two concurrent sends with different scopes must not interleave.
private activeFloodScope: string | null | undefined = undefined;
private sendScopeLock: Promise<unknown> = Promise.resolve();
// Known scope/region names for this source (#3742 Phase 2): the candidate set
// a received message's transport code is matched against to resolve its scope
// name. Sourced from per-channel scopes + the source default scope. Cached so
// the synchronous inbound-message path resolves with no DB round-trip;
// refreshed on connect and whenever a scope changes.
private knownScopes: Set<string> = new Set();
// Shared state
private localNode: MeshCoreNode | null = null;
private contacts: Map<string, MeshCoreContact> = new Map();
/**
* Recently-removed contacts (lowercased publicKey → tombstone-expiry ms).
* When a contact still lives on the companion's saved-contact list, an
* advert-triggered or reconnect `get_contacts` re-sync would otherwise
* re-insert the row we just deleted, so a "Remove" appears to do nothing
* (#3878). While a key is tombstoned we skip re-adding it on sync; the entry
* self-expires after TOMBSTONE_TTL_MS so a genuinely re-added contact can
* come back, and is cleared immediately if the user re-adds it.
*/
private removedContacts: Map<string, number> = new Map();
private static readonly TOMBSTONE_TTL_MS = 60 * 60 * 1000; // 1 hour
/**
* Collector for an in-flight `discoverNodes()` burst. NODE_DISCOVER_RESP
* pushes arrive asynchronously over a few seconds; `node_discovered` bridge
* events feed this so the awaiting call can report how many unique nodes
* responded and how many were not previously known. Null when idle.
*/
private activeDiscovery: { seen: Set<string>; returned: number; newCount: number } | null = null;
private messages: MeshCoreMessage[] = [];
private pendingCommands: Map<string, { resolve: (value: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }> = new Map();
private commandId: number = 0;
/**
* Pending outgoing channel sends awaiting self-echo correlation (#3700,
* #3979). Keyed by message id. Each entry remembers the channel index, send
* time, and the exact `text` we sent, so an inbound GRP_TXT OTA packet
* arriving within HEARD_WINDOW_MS can be attributed to the SPECIFIC send whose
* decrypted plaintext matches — not merely the most recent send. Pruned lazily
* on each inbound packet and on each new send.
*/
private pendingChannelSends: Map<string, { channelIdx: number; sentAt: number; text: string }> = new Map();
/**
* De-dup guard so a single echoed channel packet is attributed at most ONCE
* (#3979). Keyed by the GRP_TXT payload hex (channel_hash + MAC + ciphertext)
* — the MeshCore dedup identity, invariant across re-floods — mapped to the
* time it was attributed, so entries can be pruned by the heard window.
*/
private attributedChannelEchoes: Map<string, number> = new Map();
/**
* In-flight DM sends awaiting a `SendConfirmed` ack, keyed by the *current*
* attempt's firmware `expectedAckCrc` (#3977). If the ack doesn't arrive
* within `estTimeout` (+margin), the send is retried following the official
* MeshCore app's cadence (MeshCore FAQ §5.3): resend on the current cached
* path {@link DM_SAME_PATH_RETRIES} times, then reset the path and resend via
* flood {@link DM_FLOOD_RETRIES} time(s); learn the new path from whichever
* ACK arrives (firmware auto-emits `contact_path_updated`, persisted by the
* existing handler). A single logical send is represented by exactly one
* entry at a time — the map key is re-pointed to each new attempt's CRC and
* the original message row is *updated* (not duplicated) so the UI shows one
* bubble that ends `delivered` on any ACK or `failed` only after all attempts
* are exhausted.
*/
private pendingDmRetries: Map<
number,
{
messageId: string;
toPublicKey: string;
text: string;
/** Remaining same-path (current cached route) resends. */
samePathRetriesLeft: number;
/** Remaining flood (reset-path) resends after same-path exhausts. */
floodRetriesLeft: number;
timer: NodeJS.Timeout;
}
> = new Map();
/**
* In-flight AUTOMATED channel sends awaiting a heard-repeater signal (#3979,
* Part 2). Keyed by the outgoing message id. A channel/broadcast send is an
* unacked fire-and-forget flood, so we can't tell delivery from a firmware
* ACK; instead we lean on the Part 1 echo-attribution (#3987): if NO repeater
* has been heard re-flooding our packet within {@link CHANNEL_RETRY_WINDOW_MS},
* the message likely reached no one, so we resend it exactly ONCE.
*
* This machine is armed ONLY for automated senders (Automation Engine
* `action.sendMessage`, Auto-Acknowledge, auto-responder, auto-announce, timer
* triggers) that pass `autoRetryOnMiss=true` AND only when the global
* {@link meshcoreChannelRetryEnabled} opt-in setting is on. User-initiated
* sends never arm it. It is DISTINCT from and non-colliding with the DM
* ack-retry ({@link pendingDmRetries}): the DM path has `toPublicKey` set and
* keys on the firmware ACK CRC; the channel path has `channelIdx` set (no
* `toPublicKey`) and keys on the echo-heard signal — mutually exclusive
* branches of {@link performScopedSend}. Cleared in bulk on disconnect.
*/
private pendingChannelRetries: Map<
string,
{
text: string;
channelIdx: number;
scopeOverride: string | null | undefined;
/** Remaining resends. Starts at 1 (one-shot); the resend itself never re-arms. */
retriesLeft: number;
timer: NodeJS.Timeout;
}
> = new Map();
// Message limit to prevent unbounded growth
private static readonly MAX_MESSAGES = 1000;
/**
* How long after an automated channel send we wait for a heard-repeater
* signal before treating the send as a likely miss and resending once
* (#3979). Matches {@link HEARD_WINDOW_MS} — the echo-attribution window —
* so the heard-repeater set is fully populated for genuine echoes by the
* time the timer reads it.
*/
private static readonly CHANNEL_RETRY_WINDOW_MS = 30_000;
/**
* MeshCore GRP_TXT (channel/broadcast text) payload type (0x05). Inbound OTA
* packets of this type are self-echo candidates for outgoing channel sends.
*/
private static readonly PAYLOAD_TYPE_GRP_TXT = 0x05;
/**
* How long after an outgoing channel send we will attribute an inbound
* GRP_TXT OTA packet to it as a self-echo (#3700). Bounded so we never
* credit unrelated later channel chatter. Mirrors the bufferedAt-style
* staleness window used elsewhere (#3589).
*/
private static readonly HEARD_WINDOW_MS = 30_000;
/** Window over which we coalesce `contact_path_updated` pushes into a
* single device-side refreshContacts() call. Long enough to absorb a
* flurry from one chatty contact churning its route, short enough that
* the UI feels live. */
private static readonly PATH_REFRESH_DEBOUNCE_MS = 1500;
/**
* Wall-clock timestamp (ms) of the most recent outbound RF operation
* for this source. Today only the remote-telemetry scheduler stamps
* it (after `requestRemoteTelemetry`), but it's intended as the
* shared throttling primitive for any future scheduled mesh-op on
* this manager (auto-traceroute, periodic adverts, status sweeps,
* …). Cross-source: only this manager's value — different sources
* are different radios.
*/
private lastMeshTxAt: number = 0;
// Auto-pathfinding scheduler state
private autoPathfindingTimer: NodeJS.Timeout | null = null;
private autoPathfindingJitterTimeout: NodeJS.Timeout | null = null;
private autoPathfindingLastRunAt: number = 0;
// Auto-announce scheduler state. One of these holds the recurring
// trigger: cron job when useSchedule=true, setInterval handle when
// running on a plain hour interval. lastRunAt is exposed to the UI so
// operators can confirm the scheduler is actually firing.
private autoAnnounceTimer: NodeJS.Timeout | null = null;
private autoAnnounceCron: CronJob | null = null;
private autoAnnounceLastRunAt: number = 0;
private autoAnnounceAdvertTimer: NodeJS.Timeout | null = null;
// Timer-trigger scheduler state. Each configured timer trigger gets
// either a CronJob (cron mode) or a setInterval handle (interval
// mode); they're parallel because the UI lets the operator switch
// modes per-trigger. lastRun is persisted via settings so it survives
// a process restart and the UI can show "ran 5m ago" reliably.
private timerTriggerCrons: Map<string, CronJob> = new Map();
private timerTriggerIntervals: Map<string, NodeJS.Timeout> = new Map();
// Per-trigger × per-sender cooldown for the auto-responder. Key is
// `${triggerId}:${pubkeyPrefix}` so two triggers can each answer the
// same chatty sender without stomping each other's cooldown.
private autoResponderCooldowns: Map<string, number> = new Map();
// Auto-acknowledge per-sender cooldown (keyed by sender pubkey prefix).
// Records the last time we acknowledged a sender so a chatty contact
// doesn't burn airtime if they spam the trigger phrase.
private autoAckCooldowns: Map<string, number> = new Map();
private readonly distanceDeleteScheduler: DistanceDeleteScheduler;
constructor(sourceId: string, sourceName?: string) {
super();
if (!sourceId) {
throw new Error('MeshCoreManager requires a sourceId');
}
this.sourceId = sourceId;
this.sourceName = sourceName ?? sourceId;
this.distanceDeleteScheduler = new DistanceDeleteScheduler(sourceId);
logger.info(`[MeshCore:${sourceId}] Manager initialized`);
}
/** ISourceManager: source type discriminant — drives type guards in sourceManagerTypes.ts. */
get sourceType(): 'meshcore' {
return 'meshcore';
}
/**
* Store the connection config so parameterless start() can call connect().
* Call this before addManager() (which invokes start() automatically).
*/
configure(cfg: MeshCoreConfig): void {
this.pendingConfig = cfg;
}
/**
* Update the stored display name used by aggregate getAllStatuses() calls.
* Call from the source-rename handler so getStatus() stays fresh without
* requiring a full manager restart.
*/
setSourceName(name: string): void {
this.sourceName = name;
}
/**
* ISourceManager: parameterless start — delegates to connect() using the
* config stored by configure(). If no config has been stored, logs a warning
* and returns without connecting. Swallows connect()'s boolean; logs result.
*/
async start(): Promise<void> {
if (!this.pendingConfig) {
logger.warn(`[MeshCore:${this.sourceId} (${this.sourceName})] start() called but no config stored — call configure() first`);
return;
}
const ok = await this.connect(this.pendingConfig);
if (ok) {
logger.info(`[MeshCore:${this.sourceId} (${this.sourceName})] Auto-connected`);
} else {
logger.warn(`[MeshCore:${this.sourceId} (${this.sourceName})] Auto-connect failed`);
}
}
/**
* ISourceManager: parameterless stop — delegates to disconnect().
* Does NOT remove this manager from any registry; that is the registry's
* responsibility. This preserves the "manual disconnect keeps manager
* registered" semantics required by the /disconnect route.
*/
async stop(): Promise<void> {
await this.disconnect();
}
/** Start this source's per-source auto-delete-by-distance scheduler (#3901). */
async startDistanceDeleteScheduler(): Promise<void> {
await this.distanceDeleteScheduler.start();
}
/** Stop this source's per-source auto-delete-by-distance scheduler (#3901). */
stopDistanceDeleteScheduler(): void {
this.distanceDeleteScheduler.stop();
}
/**
* Connect to a MeshCore device
*/
async connect(config: MeshCoreConfig): Promise<boolean> {
if (this.connected) {
logger.warn('[MeshCore] Already connected, disconnecting first');
await this.disconnect();
}
this.config = config;
this.pendingConfig = config; // keep staging field in sync with direct connect() calls
// Pre-seed in-memory message cache from DB so history survives restarts.
// Reset the array first to avoid duplicates on reconnect within the same
// process (the previous session's messages are already in DB).
this.messages = [];
try {
const stored = await databaseService.meshcore.getRecentMessages(
MeshCoreManager.MAX_MESSAGES,
this.sourceId,
);
// DB returns newest-first; reverse to oldest-first for the in-memory array.
this.messages = stored.reverse().map(dbMsg => ({
id: dbMsg.id,
fromPublicKey: dbMsg.fromPublicKey,
fromName: dbMsg.fromName ?? undefined,
toPublicKey: dbMsg.toPublicKey ?? undefined,
text: dbMsg.text,
timestamp: dbMsg.timestamp,
rssi: dbMsg.rssi ?? undefined,
snr: dbMsg.snr ?? undefined,
sourceId: dbMsg.sourceId ?? undefined,
hopCount: dbMsg.hopCount ?? null,
routePath: dbMsg.routePath ?? null,
scopeCode: dbMsg.scopeCode ?? null,
scopeName: dbMsg.scopeName ?? null,
}));
// Enrich with heardBy so relay info survives server restarts (#3813).
if (this.messages.length > 0) {
const heardByMap = await databaseService.meshcore.getHeardRepeatersForMessages(
this.messages.map(m => m.id),
this.sourceId,
);
this.messages = this.messages.map(m => {
const heard = heardByMap[m.id];
return heard && heard.length > 0
? { ...m, heardBy: heard.map(r => ({ hash: r.repeaterHash, name: r.repeaterName, snr: r.snr })) }
: m;
});
}
} catch (loadErr) {
logger.warn(`[MeshCore:${this.sourceId}] Failed to load messages from DB: ${(loadErr as Error).message}`);
this.messages = [];
}
// Prime the known-scope cache so the first received message can resolve its
// scope name without a DB round-trip (#3742 Phase 2).
await this.refreshKnownScopes();
logger.info(`[MeshCore] Connecting via ${this.config.connectionType}...`);
this.connectionState = 'connecting';
// A fresh attempt begins: clear any leftover teardown guard so the new
// backend's socket-drop events are treated as real (and so a late event
// from the previous backend, already nulled, stays suppressed).
this.intentionalTeardown = false;
try {
if (this.config.connectionType === ConnectionType.SERIAL && this.config.firmwareType === 'repeater') {
// Explicit Repeater mode: use direct serial connection
const serialAvailable = await loadSerialPort();
if (!serialAvailable) {
throw new Error('Serial port support not available — install serialport package for Repeater mode');
}
await this.connectSerialDirect();
this.deviceType = MeshCoreDeviceType.REPEATER;
logger.info('[MeshCore] Using Repeater mode (direct serial)');
} else {
// Companion (default) or TCP: use the native JS backend (meshcore.js).
await this.startNativeBackend();
this.deviceType = MeshCoreDeviceType.COMPANION;
}
// Get initial info
await this.refreshLocalNode();
// Pre-seed the in-memory contact list from the DB BEFORE the live
// get_contacts. On a flaky/slow companion the live refresh can return
// empty or time out (and refreshContacts deliberately won't wipe on
// empty), which previously left getContacts() — the source for the DM
// contact list — nearly empty even though the node list (DB-backed) was
// full. Seeding first means the DM list mirrors the known contacts when
// the live sync degrades; a successful refresh then replaces it with the
// device-authoritative list.
await this.seedContactsFromDb();
await this.refreshContacts();
// Pull the device's channel list and mirror it into the DB. MeshCore has
// no push event for channel changes, so re-sync is connect-time and
// after every local write. Failure here is non-fatal.
if (this.deviceType === MeshCoreDeviceType.COMPANION) {
try {
await this.syncChannelsFromDevice();
} catch (err) {
logger.warn(`[MeshCore:${this.sourceId}] syncChannelsFromDevice failed: ${(err as Error).message}`);
}
}
this.connected = true;
// The device's flood scope is unknown right after (re)connect — force the
// next send to re-assert it (#3667).
this.activeFloodScope = undefined;
this.connectionState = 'connected';
this.heartbeatConsecutiveFailures = 0;
this.reconnectAttempts = 0;
this.nextReconnectAt = null;
// A prior failed attempt (see the catch block below) may have armed
// shouldReconnect to drive its own retry loop. Reset it here so a