dsc-itv2-client 1.0.30 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -221
- package/package.json +4 -3
- package/src/ITV2Client.js +1408 -985
- package/src/constants.js +1 -1
- package/src/event-handler.js +8 -5
- package/src/examples/arm-disarm-example.js +1 -1
- package/src/examples/basic-monitoring.js +16 -6
- package/src/examples/interactive-cli.js +267 -1033
- package/src/itv2-session.js +133 -75
- package/src/response-parsers.js +34 -25
package/src/ITV2Client.js
CHANGED
|
@@ -1,985 +1,1408 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSC ITV2 Protocol Client
|
|
3
|
-
* Event-driven client for DSC alarm panels using the ITV2 protocol
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import {EventEmitter} from 'events';
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.
|
|
24
|
-
this.
|
|
25
|
-
this.
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
this.
|
|
33
|
-
this.
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
// Bind methods
|
|
45
|
-
this._handlePacket = this._handlePacket.bind(this);
|
|
46
|
-
this._handleError = this._handleError.bind(this);
|
|
47
|
-
this._boundShutdown = null; // Track signal handler for cleanup
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Start the
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
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
|
-
if (this.
|
|
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
|
-
this.
|
|
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
|
-
if (!this._checkEstablished())
|
|
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
|
-
this.
|
|
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
|
-
this.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (parsed
|
|
471
|
-
this._log(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (this.session) {
|
|
496
|
-
this.session.disableAes();
|
|
497
|
-
this.session = null;
|
|
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
|
-
break;
|
|
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
|
-
this.handshakeState
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
this.
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
this.handshakeState = '
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this._log(`[Handshake]
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
this.
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
this.
|
|
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
|
-
this.
|
|
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
|
-
this.
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
this._log(`[
|
|
778
|
-
this.
|
|
779
|
-
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
this.
|
|
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
|
-
this.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
this.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
this.
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
this.
|
|
887
|
-
this.
|
|
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
|
-
this.
|
|
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
|
-
this.
|
|
946
|
-
this.
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* DSC ITV2 Protocol Client
|
|
3
|
+
* Event-driven client for DSC alarm panels using the ITV2 protocol
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {EventEmitter} from 'events';
|
|
7
|
+
import net from 'net';
|
|
8
|
+
import {ITv2Session, CMD, CMD_NAMES} from './itv2-session.js';
|
|
9
|
+
import {parseType1Initializer, type2InitializerTransform} from './itv2-crypto.js';
|
|
10
|
+
import {EventHandler} from './event-handler.js';
|
|
11
|
+
import {parseCommandError} from './response-parsers.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ITV2Client - Main library class
|
|
15
|
+
* Handles DSC panel communication and emits events for status updates
|
|
16
|
+
*/
|
|
17
|
+
export class ITV2Client extends EventEmitter {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
|
|
21
|
+
// Configuration
|
|
22
|
+
this.integrationId = options.integrationId;
|
|
23
|
+
this.accessCode = options.accessCode;
|
|
24
|
+
this.masterCode = options.masterCode || '5555';
|
|
25
|
+
this.port = options.port || 3072; // TCP port (panel connects to us)
|
|
26
|
+
this.logLevel = options.logLevel || (options.debug === true ? 'verbose' : 'minimal'); // 'silent', 'minimal', 'verbose'
|
|
27
|
+
this.encryptionType = options.encryptionType || null; // null = auto-detect, 1 = Type 1, 2 = Type 2
|
|
28
|
+
|
|
29
|
+
// Internal state
|
|
30
|
+
this.tcpServer = null; // TCP server
|
|
31
|
+
this.tcpSocket = null; // Active panel TCP connection
|
|
32
|
+
this.session = null;
|
|
33
|
+
this.eventHandler = new EventHandler();
|
|
34
|
+
this.handshakeState = 'WAITING';
|
|
35
|
+
this.panelAddress = null;
|
|
36
|
+
this.detectedEncryptionType = null; // Set during handshake
|
|
37
|
+
this._panelOpenSessionData = null; // Saved panel OPEN_SESSION payload for echoing
|
|
38
|
+
this._heartbeatInterval = null; // Heartbeat timer
|
|
39
|
+
this._sessionReady = false; // True after initial message burst subsides
|
|
40
|
+
this._readyTimer = null; // Timer for session readiness detection
|
|
41
|
+
this._pendingRequests = new Map(); // command code → { resolve, reject, timer }
|
|
42
|
+
this._capabilities = null; // Cached from initial capabilities query
|
|
43
|
+
|
|
44
|
+
// Bind methods
|
|
45
|
+
this._handlePacket = this._handlePacket.bind(this);
|
|
46
|
+
this._handleError = this._handleError.bind(this);
|
|
47
|
+
this._boundShutdown = null; // Track signal handler for cleanup
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start the TCP server and listen for panel connection
|
|
52
|
+
* The panel initiates a TCP connection to us on port 3072 (default)
|
|
53
|
+
*/
|
|
54
|
+
async start() {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
this.tcpServer = net.createServer((socket) => {
|
|
57
|
+
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
58
|
+
this._logMinimal(`[TCP] Panel connected: ${clientId}`);
|
|
59
|
+
|
|
60
|
+
this.panelAddress = socket.remoteAddress;
|
|
61
|
+
this.tcpSocket = socket;
|
|
62
|
+
|
|
63
|
+
let buffer = Buffer.alloc(0);
|
|
64
|
+
|
|
65
|
+
socket.on('data', (data) => {
|
|
66
|
+
this._log(`[TCP] Received ${data.length} bytes from ${clientId}`);
|
|
67
|
+
|
|
68
|
+
// Accumulate data in buffer
|
|
69
|
+
buffer = Buffer.concat([buffer, data]);
|
|
70
|
+
|
|
71
|
+
// Parse complete packets (0x7E...0x7F framing)
|
|
72
|
+
buffer = this._processTcpBuffer(buffer);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
socket.on('close', () => {
|
|
76
|
+
this._logMinimal(`[TCP] Panel disconnected: ${clientId}`);
|
|
77
|
+
this.tcpSocket = null;
|
|
78
|
+
this.handshakeState = 'WAITING';
|
|
79
|
+
if (this.session) {
|
|
80
|
+
this.session.disableAes();
|
|
81
|
+
this.session = null;
|
|
82
|
+
}
|
|
83
|
+
this.emit('session:closed');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
socket.on('error', (err) => {
|
|
87
|
+
this._log(`[TCP] Socket error for ${clientId}: ${err.message}`);
|
|
88
|
+
this.tcpSocket = null;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.tcpServer.on('error', (err) => {
|
|
93
|
+
this._log(`[Error] TCP server error: ${err.message}`);
|
|
94
|
+
this.emit('error', err);
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.tcpServer.listen(this.port, '0.0.0.0', () => {
|
|
99
|
+
const address = this.tcpServer.address();
|
|
100
|
+
this._logMinimal(`[TCP] Server listening on port ${address.port}`);
|
|
101
|
+
this.emit('listening', address);
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Register signal handlers (only once)
|
|
106
|
+
if (!this._boundShutdown) {
|
|
107
|
+
this._boundShutdown = async () => {
|
|
108
|
+
await this.stop();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
};
|
|
111
|
+
process.on('SIGINT', this._boundShutdown);
|
|
112
|
+
process.on('SIGTERM', this._boundShutdown);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Process TCP buffer looking for complete ITV2 packets
|
|
119
|
+
* Returns remaining unprocessed buffer
|
|
120
|
+
*/
|
|
121
|
+
_processTcpBuffer(buffer) {
|
|
122
|
+
while (buffer.length > 2) {
|
|
123
|
+
// Find start marker (0x7E)
|
|
124
|
+
const startIdx = buffer.indexOf(0x7E);
|
|
125
|
+
if (startIdx === -1) {
|
|
126
|
+
// No start marker, keep last 12 bytes in case integration ID is split
|
|
127
|
+
return buffer.slice(Math.max(0, buffer.length - 12));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Find end marker (0x7F) after start
|
|
131
|
+
const endIdx = buffer.indexOf(0x7F, startIdx + 1);
|
|
132
|
+
if (endIdx === -1) {
|
|
133
|
+
// No end marker yet, wait for more data
|
|
134
|
+
// Keep from the integration ID prefix (up to 12 bytes before 0x7E)
|
|
135
|
+
return buffer.slice(Math.max(0, startIdx - 12));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Extract complete packet (integration ID prefix + 0x7E...0x7F)
|
|
139
|
+
const packetStart = Math.max(0, startIdx - 12);
|
|
140
|
+
const packet = buffer.slice(packetStart, endIdx + 1);
|
|
141
|
+
|
|
142
|
+
this._log(`[TCP] Complete packet (${packet.length} bytes)`);
|
|
143
|
+
this._handlePacket(packet);
|
|
144
|
+
|
|
145
|
+
// Continue with remaining buffer
|
|
146
|
+
buffer = buffer.slice(endIdx + 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return buffer;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stop the client and close the session gracefully
|
|
154
|
+
*/
|
|
155
|
+
async stop() {
|
|
156
|
+
if (this.session && this.handshakeState === 'ESTABLISHED') {
|
|
157
|
+
try {
|
|
158
|
+
const endSessionPacket = this.session.buildEndSession();
|
|
159
|
+
this._sendPacket(endSessionPacket);
|
|
160
|
+
this._log('[Shutdown] END_SESSION sent to panel');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
this._log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Close panel socket
|
|
167
|
+
if (this.tcpSocket) {
|
|
168
|
+
this.tcpSocket.destroy();
|
|
169
|
+
this.tcpSocket = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Close TCP server
|
|
173
|
+
if (this.tcpServer) {
|
|
174
|
+
await new Promise((resolve) => {
|
|
175
|
+
this.tcpServer.close(() => {
|
|
176
|
+
this._log('[Shutdown] TCP server closed');
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
this.tcpServer = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Clear heartbeat
|
|
184
|
+
if (this._heartbeatInterval) {
|
|
185
|
+
clearInterval(this._heartbeatInterval);
|
|
186
|
+
this._heartbeatInterval = null;
|
|
187
|
+
}
|
|
188
|
+
if (this._readyTimer) {
|
|
189
|
+
clearTimeout(this._readyTimer);
|
|
190
|
+
this._readyTimer = null;
|
|
191
|
+
}
|
|
192
|
+
this._sessionReady = false;
|
|
193
|
+
|
|
194
|
+
// Clear pending requests
|
|
195
|
+
for (const [, pending] of this._pendingRequests) {
|
|
196
|
+
clearTimeout(pending.timer);
|
|
197
|
+
pending.reject(new Error('Session closed'));
|
|
198
|
+
}
|
|
199
|
+
this._pendingRequests.clear();
|
|
200
|
+
|
|
201
|
+
// Clear session and encryption state to prevent stale keys on reconnect
|
|
202
|
+
if (this.session) {
|
|
203
|
+
this.session.disableAes();
|
|
204
|
+
this.session = null;
|
|
205
|
+
}
|
|
206
|
+
this.handshakeState = 'WAITING';
|
|
207
|
+
this.panelAddress = null;
|
|
208
|
+
this.detectedEncryptionType = null;
|
|
209
|
+
this._panelOpenSessionData = null;
|
|
210
|
+
|
|
211
|
+
// Remove signal handlers to prevent stacking on restart
|
|
212
|
+
if (this._boundShutdown) {
|
|
213
|
+
process.removeListener('SIGINT', this._boundShutdown);
|
|
214
|
+
process.removeListener('SIGTERM', this._boundShutdown);
|
|
215
|
+
this._boundShutdown = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.emit('session:closed');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Arm partition in stay mode (mode 1)
|
|
223
|
+
*/
|
|
224
|
+
armStay(partition, code) {
|
|
225
|
+
if (!this._checkEstablished()) return;
|
|
226
|
+
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.STAY, code || this.masterCode);
|
|
227
|
+
this._sendPacket(packet);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Arm partition in away mode (mode 2)
|
|
232
|
+
*/
|
|
233
|
+
armAway(partition, code) {
|
|
234
|
+
if (!this._checkEstablished()) return;
|
|
235
|
+
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.AWAY, code || this.masterCode);
|
|
236
|
+
this._sendPacket(packet);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Arm partition in night mode (mode 4)
|
|
241
|
+
*/
|
|
242
|
+
armNight(partition, code) {
|
|
243
|
+
if (!this._checkEstablished()) return;
|
|
244
|
+
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NIGHT, code || this.masterCode);
|
|
245
|
+
this._sendPacket(packet);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Arm partition with no entry delay (mode 3)
|
|
250
|
+
*/
|
|
251
|
+
armNoEntryDelay(partition, code) {
|
|
252
|
+
if (!this._checkEstablished()) return;
|
|
253
|
+
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NO_ENTRY_DELAY, code || this.masterCode);
|
|
254
|
+
this._sendPacket(packet);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Disarm partition
|
|
259
|
+
*/
|
|
260
|
+
disarm(partition, code) {
|
|
261
|
+
if (!this._checkEstablished()) return;
|
|
262
|
+
const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
|
|
263
|
+
this._sendPacket(packet);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ==================== Status Query Methods ====================
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Send a 0x0800 command request and wait for the response.
|
|
270
|
+
* @param {number} expectedCmd - The inner command code we expect back (e.g., 0x0811)
|
|
271
|
+
* @param {Buffer} packet - The built packet to send
|
|
272
|
+
* @param {number} timeout - Timeout in ms (default 10000)
|
|
273
|
+
* @returns {Promise<object>} Parsed response data
|
|
274
|
+
*/
|
|
275
|
+
_sendQuery(expectedCmd, packet, timeout = 10000) {
|
|
276
|
+
if (!this._checkEstablished()) {
|
|
277
|
+
return Promise.reject(new Error('Session not established'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this._log(`[Query] Sending 0x${expectedCmd.toString(16)}`);
|
|
281
|
+
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
this._pendingRequests.delete(expectedCmd);
|
|
285
|
+
reject(new Error(`Query 0x${expectedCmd.toString(16)} timed out after ${timeout}ms`));
|
|
286
|
+
}, timeout);
|
|
287
|
+
|
|
288
|
+
this._pendingRequests.set(expectedCmd, { resolve, reject, timer });
|
|
289
|
+
this._sendPacket(packet);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a pending request if one exists for this command.
|
|
295
|
+
* Called from response handlers.
|
|
296
|
+
* @returns {boolean} true if a pending request was resolved
|
|
297
|
+
*/
|
|
298
|
+
_resolvePendingRequest(cmd, data) {
|
|
299
|
+
const pending = this._pendingRequests.get(cmd);
|
|
300
|
+
if (pending) {
|
|
301
|
+
clearTimeout(pending.timer);
|
|
302
|
+
this._pendingRequests.delete(cmd);
|
|
303
|
+
pending.resolve(data);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Query system capabilities (0x0613 wrapped in 0x0800)
|
|
311
|
+
* Returns max zones, partitions, users, etc.
|
|
312
|
+
* Neohub queries this FIRST on connect.
|
|
313
|
+
* @returns {Promise<object>} Capabilities object
|
|
314
|
+
*/
|
|
315
|
+
queryCapabilities() {
|
|
316
|
+
const packet = this.session.buildCapabilitiesRequest();
|
|
317
|
+
return this._sendQuery(CMD.SYSTEM_CAPABILITIES, packet);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Query zone status (0x0811 wrapped in 0x0800)
|
|
322
|
+
* @param {number} startZone - Starting zone number (default 1)
|
|
323
|
+
* @param {number} numZones - Number of zones (uses cached capabilities if not specified)
|
|
324
|
+
* @returns {Promise<Array>} Array of zone status objects
|
|
325
|
+
*/
|
|
326
|
+
queryZoneStatus(startZone = 1, numZones) {
|
|
327
|
+
const count = numZones || this._capabilities?.maxZones || 32;
|
|
328
|
+
const packet = this.session.buildZoneStatusRequest(startZone, count);
|
|
329
|
+
return this._sendQuery(CMD.ZONE_STATUS, packet);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Query partition status (0x0812 wrapped in 0x0800)
|
|
334
|
+
* @param {number} partition - Partition number (default 1)
|
|
335
|
+
* @returns {Promise<object>} Partition status object
|
|
336
|
+
*/
|
|
337
|
+
queryPartitionStatus(partition = 1) {
|
|
338
|
+
const packet = this.session.buildPartitionStatusRequest(partition);
|
|
339
|
+
return this._sendQuery(CMD.PARTITION_STATUS, packet);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Query global system status (0x0810 wrapped in 0x0800)
|
|
344
|
+
* @returns {Promise<Buffer>} Raw global status data
|
|
345
|
+
*/
|
|
346
|
+
queryGlobalStatus() {
|
|
347
|
+
const packet = this.session.buildGlobalStatusRequest();
|
|
348
|
+
return this._sendQuery(CMD.GLOBAL_STATUS, packet);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Query system trouble status (0x0822 wrapped in 0x0800)
|
|
353
|
+
* @returns {Promise<Array>} Array of trouble objects
|
|
354
|
+
*/
|
|
355
|
+
queryTroubleStatus() {
|
|
356
|
+
const packet = this.session.buildTroubleStatusRequest();
|
|
357
|
+
return this._sendQuery(CMD.SYSTEM_TROUBLE_STATUS, packet);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Query zone bypass status (0x0813 wrapped in 0x0800)
|
|
362
|
+
* @param {number} zone - Zone number (0 = all zones)
|
|
363
|
+
* @param {number} numZones - Number of zones (default 128)
|
|
364
|
+
* @returns {Promise<object>} Bypass status data
|
|
365
|
+
*/
|
|
366
|
+
queryZoneBypassStatus(zone = 0, numZones = 128) {
|
|
367
|
+
const packet = this.session.buildZoneBypassStatusRequest(zone, numZones);
|
|
368
|
+
return this._sendQuery(CMD.ZONE_BYPASS_STATUS, packet);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Send a raw command directly (not wrapped in 0x0800)
|
|
373
|
+
* @param {number} command - Command code (e.g., 0x0812)
|
|
374
|
+
* @param {Buffer} [payload] - Optional payload data
|
|
375
|
+
*/
|
|
376
|
+
sendDirect(command, payload = null) {
|
|
377
|
+
if (!this._checkEstablished()) return;
|
|
378
|
+
const packet = this.session.buildCommand(command, payload);
|
|
379
|
+
this._logMinimal(`[Direct] Sending command 0x${command.toString(16).padStart(4, '0')} ${payload ? 'with ' + payload.length + ' bytes payload' : 'no payload'}`);
|
|
380
|
+
this._sendPacket(packet);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ==================== Authentication Methods ====================
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Authenticate with access code - simple format (0x0400)
|
|
387
|
+
* Uses 6-digit ASCII format matching interactive CLI
|
|
388
|
+
* @param {string} code - User/master code (e.g., "5555")
|
|
389
|
+
*/
|
|
390
|
+
authenticate(code) {
|
|
391
|
+
if (!this._checkEstablished()) return;
|
|
392
|
+
const codeStr = (code || this.masterCode).toString().padStart(6, '0').slice(0, 6);
|
|
393
|
+
const payload = Buffer.from(codeStr, 'ascii');
|
|
394
|
+
const packet = this.session.buildCommand(0x0400, payload);
|
|
395
|
+
this._sendPacket(packet);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Authenticate with access code - extended format (0x0400)
|
|
400
|
+
* Includes partition and access level metadata
|
|
401
|
+
* @param {number} partition - Partition number (1-8, or 0 for all)
|
|
402
|
+
* @param {string} code - User/master code (e.g., "5555")
|
|
403
|
+
* @param {number} accessLevel - 0=User, 1=Installer, 2=Master
|
|
404
|
+
*/
|
|
405
|
+
authenticateExtended(partition = 1, code, accessLevel = 0) {
|
|
406
|
+
if (!this._checkEstablished()) return;
|
|
407
|
+
const packet = this.session.buildAccessLevelEnter(
|
|
408
|
+
code || this.masterCode,
|
|
409
|
+
partition,
|
|
410
|
+
accessLevel,
|
|
411
|
+
'bcd'
|
|
412
|
+
);
|
|
413
|
+
this._sendPacket(packet);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Exit authenticated access level (0x0401)
|
|
418
|
+
* @param {number} partition - Partition number
|
|
419
|
+
*/
|
|
420
|
+
deauthenticate(partition = 1) {
|
|
421
|
+
if (!this._checkEstablished()) return;
|
|
422
|
+
const packet = this.session.buildAccessLevelExit(partition);
|
|
423
|
+
this._sendPacket(packet);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ==================== State Accessors ====================
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get current zone states
|
|
430
|
+
*/
|
|
431
|
+
getZones() {
|
|
432
|
+
return this.eventHandler.getAllZones();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Get current partition states
|
|
437
|
+
*/
|
|
438
|
+
getPartitions() {
|
|
439
|
+
return this.eventHandler.getAllPartitions();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ==================== Internal Methods ====================
|
|
443
|
+
|
|
444
|
+
_checkEstablished() {
|
|
445
|
+
if (this.handshakeState !== 'ESTABLISHED') {
|
|
446
|
+
this.emit('error', new Error('Session not established'));
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
_sendPacket(packet) {
|
|
453
|
+
if (!this.tcpSocket || this.tcpSocket.destroyed) {
|
|
454
|
+
this._logMinimal('[Error] No panel connection');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this._log(`[TCP] TX ${packet.length} bytes`);
|
|
458
|
+
this._hexDump(packet);
|
|
459
|
+
this.tcpSocket.write(packet);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_handlePacket(data) {
|
|
463
|
+
try {
|
|
464
|
+
if (!this.session) {
|
|
465
|
+
const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {};
|
|
466
|
+
this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const parsed = this.session.parsePacket(data);
|
|
470
|
+
if (!parsed) {
|
|
471
|
+
this._log('[Error] Failed to parse packet');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this._log(`[TCP] RX ${data.length}B cmd=${CMD_NAMES[parsed.command] || parsed.command}`);
|
|
476
|
+
this._hexDump(data);
|
|
477
|
+
this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
|
|
478
|
+
this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
|
|
479
|
+
this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
|
|
480
|
+
if (parsed.appSequence !== null) {
|
|
481
|
+
this._log(`[Packet] App Seq: ${parsed.appSequence}`);
|
|
482
|
+
}
|
|
483
|
+
if (parsed.commandData) {
|
|
484
|
+
this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Route based on command
|
|
488
|
+
this._routePacket(parsed);
|
|
489
|
+
|
|
490
|
+
} catch (err) {
|
|
491
|
+
// During WAITING state, parse errors are expected (stale encrypted packets from previous session)
|
|
492
|
+
if (this.handshakeState === 'WAITING') {
|
|
493
|
+
this._log(`[Recovery] Ignoring malformed packet while waiting for fresh handshake: ${err.message}`);
|
|
494
|
+
// Reset session to ensure we start fresh on next valid packet
|
|
495
|
+
if (this.session) {
|
|
496
|
+
this.session.disableAes();
|
|
497
|
+
this.session = null;
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this._log(`[Error] ${err.message}`);
|
|
503
|
+
this.emit('error', err);
|
|
504
|
+
|
|
505
|
+
// Reset on parse errors during handshake
|
|
506
|
+
if (this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
|
|
507
|
+
this._log('[Recovery] Parse error, resetting to wait for panel restart');
|
|
508
|
+
if (this.session) {
|
|
509
|
+
this.session.disableAes();
|
|
510
|
+
this.session = null;
|
|
511
|
+
}
|
|
512
|
+
this.handshakeState = 'WAITING';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
_routePacket(parsed, skipAck = false) {
|
|
518
|
+
this._skipAck = skipAck;
|
|
519
|
+
const cmd = parsed.command;
|
|
520
|
+
|
|
521
|
+
// Handle ACKs
|
|
522
|
+
if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
|
|
523
|
+
this._handleSimpleAck(parsed);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Route based on command type
|
|
528
|
+
switch (cmd) {
|
|
529
|
+
case CMD.OPEN_SESSION:
|
|
530
|
+
this._handleOpenSession(parsed);
|
|
531
|
+
break;
|
|
532
|
+
case CMD.REQUEST_ACCESS:
|
|
533
|
+
this._handleRequestAccess(parsed);
|
|
534
|
+
break;
|
|
535
|
+
case CMD.COMMAND_RESPONSE:
|
|
536
|
+
this._handleCommandResponse(parsed);
|
|
537
|
+
break;
|
|
538
|
+
case CMD.COMMAND_ERROR:
|
|
539
|
+
this._handleCommandError(parsed);
|
|
540
|
+
break;
|
|
541
|
+
case CMD.POLL:
|
|
542
|
+
this._handlePoll(parsed);
|
|
543
|
+
break;
|
|
544
|
+
case CMD.LIFESTYLE_ZONE_STATUS:
|
|
545
|
+
this._handleLifestyleZoneStatus(parsed);
|
|
546
|
+
break;
|
|
547
|
+
case CMD.TIME_DATE_BROADCAST:
|
|
548
|
+
this._handleTimeDateBroadcast(parsed);
|
|
549
|
+
break;
|
|
550
|
+
case CMD.ZONE_STATUS:
|
|
551
|
+
this._handleZoneStatusResponse(parsed);
|
|
552
|
+
break;
|
|
553
|
+
case CMD.PARTITION_STATUS:
|
|
554
|
+
this._handlePartitionStatusResponse(parsed);
|
|
555
|
+
break;
|
|
556
|
+
case CMD.GLOBAL_STATUS:
|
|
557
|
+
this._handleGlobalStatusResponse(parsed);
|
|
558
|
+
break;
|
|
559
|
+
case CMD.SYSTEM_CAPABILITIES:
|
|
560
|
+
this._handleCapabilitiesResponse(parsed);
|
|
561
|
+
break;
|
|
562
|
+
case CMD.SYSTEM_TROUBLE_STATUS:
|
|
563
|
+
case CMD.SYSTEM_TROUBLE_STATUS_OLD:
|
|
564
|
+
this._handleTroubleStatusResponse(parsed);
|
|
565
|
+
break;
|
|
566
|
+
case CMD.TROUBLE_DETAIL:
|
|
567
|
+
this._handleTroubleDetail(parsed);
|
|
568
|
+
break;
|
|
569
|
+
case CMD.NOTIFICATION_ARMING:
|
|
570
|
+
this._handleArmingNotification(parsed);
|
|
571
|
+
break;
|
|
572
|
+
case CMD.NOTIFICATION_PARTITION_READY:
|
|
573
|
+
this._handlePartitionReadyNotification(parsed);
|
|
574
|
+
break;
|
|
575
|
+
case CMD.NOTIFICATION_PARTITION_TROUBLE:
|
|
576
|
+
this._handlePartitionTroubleNotification(parsed);
|
|
577
|
+
break;
|
|
578
|
+
case CMD.MULTIPLE_MESSAGE:
|
|
579
|
+
this._handleMultipleMessagePacket(parsed);
|
|
580
|
+
break;
|
|
581
|
+
default:
|
|
582
|
+
if (this.handshakeState === 'ESTABLISHED') {
|
|
583
|
+
const cmdHex = cmd ? '0x' + cmd.toString(16).padStart(4, '0') : 'null';
|
|
584
|
+
const cmdName = CMD_NAMES[cmd] || 'UNKNOWN';
|
|
585
|
+
this._logMinimal(`[Session] Received command ${cmdName} (${cmdHex})`);
|
|
586
|
+
if (parsed.commandData && parsed.commandData.length > 0) {
|
|
587
|
+
this._logMinimal(`[Session] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex')}`);
|
|
588
|
+
}
|
|
589
|
+
this._ack();
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ==================== Handshake Handlers ====================
|
|
596
|
+
|
|
597
|
+
_handleOpenSession(parsed) {
|
|
598
|
+
const data = parsed.commandData;
|
|
599
|
+
if (!data || data.length < 14) {
|
|
600
|
+
this._log('[Error] Invalid OPEN_SESSION data');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const deviceType = data[0];
|
|
605
|
+
const encryptionType = data[13];
|
|
606
|
+
|
|
607
|
+
this._log(`[Session] OPEN_SESSION received:`);
|
|
608
|
+
this._log(` Device Type: ${deviceType}`);
|
|
609
|
+
this._log(` Encryption Type: ${encryptionType}`);
|
|
610
|
+
|
|
611
|
+
// Emit connecting event now that we have a valid OPEN_SESSION
|
|
612
|
+
this.emit('session:connecting');
|
|
613
|
+
this._logMinimal(`[Session] Panel connecting from ${this.panelAddress}`);
|
|
614
|
+
this._logMinimal('[Handshake] Starting session establishment...');
|
|
615
|
+
|
|
616
|
+
// Handle panel restart mid-handshake
|
|
617
|
+
if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
|
|
618
|
+
this._log(`[Session] Panel restarting handshake (was in state ${this.handshakeState}), resetting session`);
|
|
619
|
+
const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {};
|
|
620
|
+
this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Save panel's OpenSession payload for echoing back
|
|
624
|
+
this._panelOpenSessionData = data;
|
|
625
|
+
|
|
626
|
+
// Send COMMAND_RESPONSE echoing the command's appSequence (NOT deviceType)
|
|
627
|
+
// OpenSession is a CommandMessageBase - appSequence is the CommandSequence
|
|
628
|
+
const cmdSeq = parsed.appSequence;
|
|
629
|
+
this._log(`[Handshake] Sending COMMAND_RESPONSE (echoing cmdSeq=${cmdSeq})`);
|
|
630
|
+
const response = this.session.buildCommandResponseWithAppSeq(cmdSeq, 0x00);
|
|
631
|
+
this._sendPacket(response);
|
|
632
|
+
this.handshakeState = 'SENT_CMD_RESPONSE_1';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
_handleSimpleAck(parsed) {
|
|
636
|
+
this._log(`[Handshake] Got ACK ${parsed.receiverSequence}, state=${this.handshakeState}`);
|
|
637
|
+
|
|
638
|
+
if (this.handshakeState === 'SENT_CMD_RESPONSE_1') {
|
|
639
|
+
// Panel ACKed our first COMMAND_RESPONSE, send our OPEN_SESSION
|
|
640
|
+
// Echo back the panel's OpenSession data (neohub echoes the same message)
|
|
641
|
+
this._log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
|
|
642
|
+
const payload = this._panelOpenSessionData || Buffer.from([
|
|
643
|
+
0x01, 0x80, 0x00, 0x00, 0x02, 0x01, 0x02, 0x41,
|
|
644
|
+
0x02, 0x00, 0x02, 0x00, 0x00, 0x01, 0x01
|
|
645
|
+
]);
|
|
646
|
+
const packet = this.session.buildOpenSessionResponse(payload);
|
|
647
|
+
this._sendPacket(packet);
|
|
648
|
+
this.handshakeState = 'SENT_OPEN_SESSION';
|
|
649
|
+
} else if (this.handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
|
|
650
|
+
// Panel ACKed our encrypted COMMAND_RESPONSE to their REQUEST_ACCESS
|
|
651
|
+
// Now send our REQUEST_ACCESS (also encrypted, send key already enabled)
|
|
652
|
+
this._log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
|
|
653
|
+
|
|
654
|
+
let reqAccessPacket;
|
|
655
|
+
if (this.detectedEncryptionType === 2) {
|
|
656
|
+
this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
|
|
657
|
+
reqAccessPacket = this.session.buildRequestAccessType2();
|
|
658
|
+
} else {
|
|
659
|
+
this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
|
|
660
|
+
reqAccessPacket = this.session.buildRequestAccessType1();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
this._sendPacket(reqAccessPacket);
|
|
664
|
+
|
|
665
|
+
// Enable receive encryption
|
|
666
|
+
this.session.receiveAesActive = true;
|
|
667
|
+
this.session.receiveAesKey = this.session.pendingReceiveKey;
|
|
668
|
+
this._log(`[Handshake] Enabled receive encryption`);
|
|
669
|
+
|
|
670
|
+
this.handshakeState = 'SENT_REQUEST_ACCESS';
|
|
671
|
+
} else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
672
|
+
// Panel ACKed our REQUEST_ACCESS - session established!
|
|
673
|
+
this._onSessionEstablished();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_onSessionEstablished() {
|
|
678
|
+
this.handshakeState = 'ESTABLISHED';
|
|
679
|
+
this._logMinimal(`[Handshake] Session established (Type ${this.detectedEncryptionType} encryption)`);
|
|
680
|
+
this._log(`[Handshake] *** SESSION ESTABLISHED ***`);
|
|
681
|
+
this._log(`[Handshake] Encryption Type: ${this.detectedEncryptionType}`);
|
|
682
|
+
this._log(`[Handshake] SEND key: ${this.session.derivedSendKey?.toString('hex')}`);
|
|
683
|
+
this._log(`[Handshake] RECV key: ${this.session.pendingReceiveKey?.toString('hex')}`);
|
|
684
|
+
|
|
685
|
+
this.emit('session:established', {
|
|
686
|
+
encryptionType: this.detectedEncryptionType,
|
|
687
|
+
sendKey: this.session.derivedSendKey?.toString('hex'),
|
|
688
|
+
recvKey: this.session.pendingReceiveKey?.toString('hex')
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Start heartbeat timer (neohub sends ConnectionPoll every 100 seconds)
|
|
692
|
+
this._startHeartbeat();
|
|
693
|
+
|
|
694
|
+
// Start session readiness timer (neohub discards notifications for first 2s)
|
|
695
|
+
this._sessionReady = false;
|
|
696
|
+
this._resetReadyTimer();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
_startHeartbeat() {
|
|
700
|
+
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
|
|
701
|
+
this._heartbeatInterval = setInterval(() => {
|
|
702
|
+
if (this.handshakeState === 'ESTABLISHED' && this.session) {
|
|
703
|
+
this._log('[Heartbeat] Sending POLL');
|
|
704
|
+
const packet = this.session.buildPoll();
|
|
705
|
+
this._sendPacket(packet);
|
|
706
|
+
}
|
|
707
|
+
}, 100_000); // 100 seconds, matching neohub
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
_resetReadyTimer() {
|
|
711
|
+
if (this._readyTimer) clearTimeout(this._readyTimer);
|
|
712
|
+
this._readyTimer = setTimeout(() => {
|
|
713
|
+
if (!this._sessionReady) {
|
|
714
|
+
this._sessionReady = true;
|
|
715
|
+
this._logMinimal('[Session] Ready - initial message burst complete');
|
|
716
|
+
this.emit('session:ready');
|
|
717
|
+
// Pull initial status like neohub's PanelConfigurationHandler
|
|
718
|
+
this._pullInitialStatus();
|
|
719
|
+
}
|
|
720
|
+
}, 2000); // 2 seconds of silence, matching neohub
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async _pullInitialStatus() {
|
|
724
|
+
this._logMinimal('[Status] Pulling initial status (neohub sequence)...');
|
|
725
|
+
|
|
726
|
+
let caps = null;
|
|
727
|
+
let zones = [];
|
|
728
|
+
let partStatus = null;
|
|
729
|
+
|
|
730
|
+
// Step 1: Query capabilities first (neohub does this first)
|
|
731
|
+
try {
|
|
732
|
+
caps = await this.queryCapabilities();
|
|
733
|
+
this._logMinimal(`[Status] Capabilities: ${caps.maxZones} zones, ${caps.maxPartitions} partitions`);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
this._logMinimal(`[Status] Capabilities query failed: ${e.message}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const maxZones = caps?.maxZones || 128;
|
|
739
|
+
const maxPartitions = caps?.maxPartitions || 8;
|
|
740
|
+
|
|
741
|
+
// Step 2: Query partition status
|
|
742
|
+
try {
|
|
743
|
+
partStatus = await this.queryPartitionStatus(1);
|
|
744
|
+
if (partStatus) {
|
|
745
|
+
const state = partStatus.awayArmed ? 'AWAY' :
|
|
746
|
+
partStatus.stayArmed ? 'STAY' :
|
|
747
|
+
partStatus.nightArmed ? 'NIGHT' :
|
|
748
|
+
partStatus.armed ? 'ARMED' : 'DISARMED';
|
|
749
|
+
this._logMinimal(`[Status] Partition 1: ${state}`);
|
|
750
|
+
}
|
|
751
|
+
} catch (e) {
|
|
752
|
+
this._logMinimal(`[Status] Partition query failed: ${e.message}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Step 3: Query zone status
|
|
756
|
+
try {
|
|
757
|
+
zones = await this.queryZoneStatus(1, maxZones);
|
|
758
|
+
const openZones = zones.filter(z => z.open);
|
|
759
|
+
this._logMinimal(`[Status] ${zones.length} zones (${openZones.length} open)`);
|
|
760
|
+
} catch (e) {
|
|
761
|
+
this._logMinimal(`[Status] Zone query failed: ${e.message}`);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.emit('status:ready', { zones, partition: partStatus, capabilities: caps });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_handleRequestAccess(parsed) {
|
|
768
|
+
const data = parsed.commandData;
|
|
769
|
+
if (!data || data.length < 17) {
|
|
770
|
+
this._log('[Error] Invalid REQUEST_ACCESS data');
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const initType = data[0];
|
|
775
|
+
const initializer = data.slice(1);
|
|
776
|
+
|
|
777
|
+
this._log(`[Handshake] Got panel's REQUEST_ACCESS`);
|
|
778
|
+
this._log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
|
|
779
|
+
|
|
780
|
+
// Determine encryption type based on initializer length
|
|
781
|
+
if (initializer.length === 48) {
|
|
782
|
+
// Type 1 encryption
|
|
783
|
+
this.detectedEncryptionType = 1;
|
|
784
|
+
this._log(`[Handshake] Encryption Type 1 (48-byte initializer)`);
|
|
785
|
+
|
|
786
|
+
const sendKey = parseType1Initializer(this.accessCode, initializer, this.integrationId, this.logLevel === 'verbose');
|
|
787
|
+
this._logMinimal('[Handshake] Type 1 encryption negotiated');
|
|
788
|
+
this._log(`[Handshake] Type 1 SEND key derived`);
|
|
789
|
+
this.session.derivedSendKey = sendKey;
|
|
790
|
+
|
|
791
|
+
} else if (initializer.length === 16) {
|
|
792
|
+
// Type 2 encryption
|
|
793
|
+
this.detectedEncryptionType = 2;
|
|
794
|
+
this._log(`[Handshake] Encryption Type 2 (16-byte initializer)`);
|
|
795
|
+
|
|
796
|
+
// Type 2: Symmetric transform with 32-hex access code
|
|
797
|
+
let accessCode32 = this.accessCode;
|
|
798
|
+
if (accessCode32.length === 8) {
|
|
799
|
+
accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const sendKey = type2InitializerTransform(accessCode32, initializer);
|
|
803
|
+
this._logMinimal('[Handshake] Type 2 encryption negotiated');
|
|
804
|
+
this._log(`[Handshake] Type 2 SEND key derived`);
|
|
805
|
+
this.session.derivedSendKey = sendKey;
|
|
806
|
+
|
|
807
|
+
} else {
|
|
808
|
+
this._log(`[Error] Unknown initializer length: ${initializer.length}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Enable outbound encryption BEFORE sending COMMAND_RESPONSE
|
|
813
|
+
// (neohub: "Configure outbound encryption IMMEDIATELY — step 8 is encrypted")
|
|
814
|
+
this.session.sendAesActive = true;
|
|
815
|
+
this.session.sendAesKey = this.session.derivedSendKey;
|
|
816
|
+
this._log(`[Handshake] Enabled outbound encryption`);
|
|
817
|
+
|
|
818
|
+
// Send ENCRYPTED COMMAND_RESPONSE (first encrypted message!)
|
|
819
|
+
const appSeq = parsed.appSequence;
|
|
820
|
+
this._log(`[Handshake] Sending ENCRYPTED COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
|
|
821
|
+
const response = this.session.buildCommandResponseWithAppSeq(appSeq, 0x00);
|
|
822
|
+
this._sendPacket(response);
|
|
823
|
+
|
|
824
|
+
// Wait for panel's ACK before sending our REQUEST_ACCESS
|
|
825
|
+
// (neohub waits for SimpleAck at step 9 before step 10)
|
|
826
|
+
this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
_handleCommandResponse(parsed) {
|
|
830
|
+
const data = parsed.commandData;
|
|
831
|
+
const responseCode = data?.[0] || 0;
|
|
832
|
+
const appSeqAsEcho = parsed.appSequence;
|
|
833
|
+
|
|
834
|
+
this._log(`[Session] Got COMMAND_RESPONSE (app_seq: ${appSeqAsEcho}, response_code: ${responseCode})`);
|
|
835
|
+
|
|
836
|
+
// COMMAND_RESPONSE is an intermediate ack - NOT a rejection.
|
|
837
|
+
// Neohub's CommandRequestReceiver treats it as an ack and waits for the
|
|
838
|
+
// actual data message. Only COMMAND_ERROR rejects pending requests.
|
|
839
|
+
// Response codes: 0=ack, 1=success, 2+=informational (panel may still send data)
|
|
840
|
+
if (this.handshakeState === 'ESTABLISHED') {
|
|
841
|
+
this.emit('command:response', { responseCode, appSequence: appSeqAsEcho, data });
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const ack = this.session.buildSimpleAck();
|
|
845
|
+
this._sendPacket(ack);
|
|
846
|
+
|
|
847
|
+
if (this.handshakeState === 'SENT_OPEN_SESSION') {
|
|
848
|
+
// Panel accepted our OPEN_SESSION
|
|
849
|
+
this.handshakeState = 'WAITING_REQUEST_ACCESS';
|
|
850
|
+
} else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
851
|
+
// Panel accepted our REQUEST_ACCESS - session established!
|
|
852
|
+
this._onSessionEstablished();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
_handleCommandError(parsed) {
|
|
857
|
+
// CommandError (IMessageData, NOT CommandMessageBase):
|
|
858
|
+
// [Command 2B BE][NackCode 1B]
|
|
859
|
+
// parseHeader puts first byte into appSequence
|
|
860
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
861
|
+
const failedCmd = fullPayload.length >= 2 ? fullPayload.readUInt16BE(0) : 0;
|
|
862
|
+
const nackCode = fullPayload.length >= 3 ? fullPayload[2] : 0;
|
|
863
|
+
const nackNames = {
|
|
864
|
+
1: 'InvalidCommandLength', 2: 'InvalidCommand', 3: 'InvalidSequence',
|
|
865
|
+
4: 'PanelNotResponding', 17: 'InvalidAccessCode', 18: 'AccessCodeRequired',
|
|
866
|
+
23: 'FunctionNotAvailable', 25: 'CommandTimeout',
|
|
867
|
+
};
|
|
868
|
+
const error = {
|
|
869
|
+
failedCommand: failedCmd,
|
|
870
|
+
nackCode,
|
|
871
|
+
message: `${CMD_NAMES[failedCmd] || '0x' + failedCmd.toString(16)}: ${nackNames[nackCode] || 'NackCode ' + nackCode}`,
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
this._log(`[Command Error] ${error.message}`);
|
|
875
|
+
this.emit('command:error', error);
|
|
876
|
+
|
|
877
|
+
// Don't reject pending requests - panel often sends COMMAND_ERROR as an
|
|
878
|
+
// intermediate response, then sends actual data afterward.
|
|
879
|
+
// Timeout handles genuine failures.
|
|
880
|
+
|
|
881
|
+
this._ack();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
_handlePoll(parsed) {
|
|
885
|
+
this._log(`[Session] Got POLL, sending ACK`);
|
|
886
|
+
if (!this._sessionReady) this._resetReadyTimer();
|
|
887
|
+
this._ack();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ==================== Notification Handlers ====================
|
|
891
|
+
|
|
892
|
+
_handleLifestyleZoneStatus(parsed) {
|
|
893
|
+
// If we receive encrypted zone data, session is established (even if we missed the final ACK)
|
|
894
|
+
if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
895
|
+
this._onSessionEstablished();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Reset ready timer on each inbound message (matching neohub)
|
|
899
|
+
if (!this._sessionReady) this._resetReadyTimer();
|
|
900
|
+
|
|
901
|
+
// Reconstruct full payload: for non-CommandMessageBase messages,
|
|
902
|
+
// parseHeader puts the first payload byte into appSequence
|
|
903
|
+
// NotificationLifestyleZoneStatus: [CompactInt:ZoneNumber][Status:1B]
|
|
904
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
905
|
+
|
|
906
|
+
if (fullPayload && fullPayload.length >= 3) {
|
|
907
|
+
// Parse CompactInteger zone number
|
|
908
|
+
const zone = ITv2Session.decodeVarBytes(fullPayload, 0);
|
|
909
|
+
const zoneNum = zone.value;
|
|
910
|
+
const statusValue = fullPayload[zone.bytesRead];
|
|
911
|
+
|
|
912
|
+
// Update internal state and get full parsed status
|
|
913
|
+
const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
|
|
914
|
+
|
|
915
|
+
// Emit events with full status object
|
|
916
|
+
this.emit('zone:status', zoneNum, fullStatus);
|
|
917
|
+
|
|
918
|
+
if (fullStatus.open) {
|
|
919
|
+
this.emit('zone:open', zoneNum);
|
|
920
|
+
this._log(`[Zone ${zoneNum}] OPEN (status: ${statusValue})`);
|
|
921
|
+
} else {
|
|
922
|
+
this.emit('zone:closed', zoneNum);
|
|
923
|
+
this._log(`[Zone ${zoneNum}] CLOSED (status: ${statusValue})`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
this._ack();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
_handleTimeDateBroadcast(parsed) {
|
|
931
|
+
if (!this._sessionReady) this._resetReadyTimer();
|
|
932
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
933
|
+
if (fullPayload && fullPayload.length >= 5) {
|
|
934
|
+
this._log(`[Time/Date] Received broadcast`);
|
|
935
|
+
this.emit('notification:timeDate', fullPayload);
|
|
936
|
+
}
|
|
937
|
+
this._ack();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ==================== Status Query Response Handlers ====================
|
|
941
|
+
|
|
942
|
+
_handleZoneStatusResponse(parsed) {
|
|
943
|
+
// ModuleZoneStatus (neohub format):
|
|
944
|
+
// [CompactInt:ZoneStart][CompactInt:ZoneCount][StatusSizeInBytes:1B][ZoneStatusBytes...]
|
|
945
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
946
|
+
this._logMinimal(`[Zone Status] Received zone status response`);
|
|
947
|
+
this._log(`[Zone Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
|
|
948
|
+
|
|
949
|
+
const zones = [];
|
|
950
|
+
|
|
951
|
+
if (fullPayload && fullPayload.length >= 4) {
|
|
952
|
+
try {
|
|
953
|
+
let offset = 0;
|
|
954
|
+
const zoneStart = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
955
|
+
offset += zoneStart.bytesRead;
|
|
956
|
+
|
|
957
|
+
const zoneCount = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
958
|
+
offset += zoneCount.bytesRead;
|
|
959
|
+
|
|
960
|
+
const statusSize = fullPayload[offset++]; // typically 1
|
|
961
|
+
|
|
962
|
+
this._logMinimal(`[Zone Status] Zones ${zoneStart.value}-${zoneStart.value + zoneCount.value - 1} (${zoneCount.value} zones, ${statusSize}B each)`);
|
|
963
|
+
|
|
964
|
+
for (let i = 0; i < zoneCount.value && offset < fullPayload.length; i++) {
|
|
965
|
+
const zoneNum = zoneStart.value + i;
|
|
966
|
+
const statusByte = fullPayload[offset];
|
|
967
|
+
offset += statusSize;
|
|
968
|
+
|
|
969
|
+
const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusByte);
|
|
970
|
+
zones.push(fullStatus);
|
|
971
|
+
this.emit('zone:statusResponse', zoneNum, fullStatus);
|
|
972
|
+
this.emit('zone:status', zoneNum, fullStatus);
|
|
973
|
+
}
|
|
974
|
+
} catch (e) {
|
|
975
|
+
this._log(`[Zone Status] Parse error: ${e.message}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
this._resolvePendingRequest(CMD.ZONE_STATUS, zones);
|
|
980
|
+
this._ack();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
_handlePartitionStatusResponse(parsed) {
|
|
984
|
+
// ModulePartitionStatus (neohub format):
|
|
985
|
+
// [CompactInt:Partition][LeadingLengthArray:PartitionStatus]
|
|
986
|
+
// PartitionStatus bytes:
|
|
987
|
+
// Byte 0 (Status1): Armed=0x01, StayArmed=0x03, ArmedAway=0x05, NightArmed=0x09,
|
|
988
|
+
// ExitDelay=0x20, EntryDelay=0x40, QuickExit=0x80
|
|
989
|
+
// Byte 1 (Status2): Alarm=0x01, Trouble=0x02, Bypass=0x04, Programming=0x08,
|
|
990
|
+
// AlarmMemory=0x10, DoorChime=0x20, Bell=0x40, Buzzer=0x80
|
|
991
|
+
// Byte 2 (Status3): FirePreAlert=0x01
|
|
992
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
993
|
+
this._logMinimal(`[Partition Status] Received partition status response`);
|
|
994
|
+
this._log(`[Partition Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
|
|
995
|
+
|
|
996
|
+
if (fullPayload && fullPayload.length >= 3) {
|
|
997
|
+
try {
|
|
998
|
+
let offset = 0;
|
|
999
|
+
const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1000
|
+
offset += partition.bytesRead;
|
|
1001
|
+
|
|
1002
|
+
// LeadingLengthArray: 1-byte length prefix followed by status bytes
|
|
1003
|
+
const statusLength = fullPayload[offset++];
|
|
1004
|
+
const statusBytes = fullPayload.slice(offset, offset + statusLength);
|
|
1005
|
+
|
|
1006
|
+
const partitionNum = partition.value;
|
|
1007
|
+
this._logMinimal(`[Partition Status] Partition ${partitionNum}: status bytes ${statusBytes.toString('hex')}`);
|
|
1008
|
+
|
|
1009
|
+
// Parse status flags from neohub's enum definitions
|
|
1010
|
+
const status1 = statusBytes.length > 0 ? statusBytes[0] : 0;
|
|
1011
|
+
const status2 = statusBytes.length > 1 ? statusBytes[1] : 0;
|
|
1012
|
+
const status3 = statusBytes.length > 2 ? statusBytes[2] : 0;
|
|
1013
|
+
|
|
1014
|
+
const partStatus = {
|
|
1015
|
+
partition: partitionNum,
|
|
1016
|
+
// Status1 flags
|
|
1017
|
+
armed: !!(status1 & 0x01),
|
|
1018
|
+
stayArmed: (status1 & 0x0F) === 0x03,
|
|
1019
|
+
awayArmed: (status1 & 0x0F) === 0x05,
|
|
1020
|
+
nightArmed: (status1 & 0x0F) === 0x09,
|
|
1021
|
+
noEntryDelay: (status1 & 0x1F) === 0x11,
|
|
1022
|
+
exitDelay: !!(status1 & 0x20),
|
|
1023
|
+
entryDelay: !!(status1 & 0x40),
|
|
1024
|
+
quickExit: !!(status1 & 0x80),
|
|
1025
|
+
// Status2 flags
|
|
1026
|
+
alarm: !!(status2 & 0x01),
|
|
1027
|
+
trouble: !!(status2 & 0x02),
|
|
1028
|
+
zonesBypassed: !!(status2 & 0x04),
|
|
1029
|
+
programmingMode: !!(status2 & 0x08),
|
|
1030
|
+
alarmInMemory: !!(status2 & 0x10),
|
|
1031
|
+
doorChime: !!(status2 & 0x20),
|
|
1032
|
+
audibleBell: !!(status2 & 0x40),
|
|
1033
|
+
buzzer: !!(status2 & 0x80),
|
|
1034
|
+
// Status3 flags
|
|
1035
|
+
firePreAlert: !!(status3 & 0x01),
|
|
1036
|
+
rawBytes: statusBytes.toString('hex'),
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// Update event handler
|
|
1040
|
+
const armedState = partStatus.awayArmed ? 0x02 :
|
|
1041
|
+
partStatus.stayArmed ? 0x01 :
|
|
1042
|
+
partStatus.nightArmed ? 0x03 :
|
|
1043
|
+
partStatus.armed ? 0x01 : 0x00;
|
|
1044
|
+
this.eventHandler.handlePartitionArming(partitionNum, armedState);
|
|
1045
|
+
|
|
1046
|
+
this.emit('partition:statusResponse', partitionNum, partStatus);
|
|
1047
|
+
this._resolvePendingRequest(CMD.PARTITION_STATUS, partStatus);
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
this._log(`[Partition Status] Parse error: ${e.message}`);
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
this._log(`[Partition Status] Empty or invalid response`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
this._ack();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
_handleCapabilitiesResponse(parsed) {
|
|
1059
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1060
|
+
this._logMinimal(`[Capabilities] Received system capabilities`);
|
|
1061
|
+
|
|
1062
|
+
const caps = {};
|
|
1063
|
+
if (fullPayload && fullPayload.length >= 2) {
|
|
1064
|
+
try {
|
|
1065
|
+
let offset = 0;
|
|
1066
|
+
const fields = ['maxZones', 'maxUsers', 'maxPartitions', 'maxFOBs', 'maxProxTags', 'maxOutputs'];
|
|
1067
|
+
for (const field of fields) {
|
|
1068
|
+
if (offset >= fullPayload.length) break;
|
|
1069
|
+
const val = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1070
|
+
caps[field] = val.value;
|
|
1071
|
+
offset += val.bytesRead;
|
|
1072
|
+
}
|
|
1073
|
+
this._logMinimal(`[Capabilities] ${caps.maxZones} zones, ${caps.maxPartitions} partitions, ${caps.maxUsers} users`);
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
this._log(`[Capabilities] Parse error: ${e.message}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
this._capabilities = caps;
|
|
1080
|
+
this._resolvePendingRequest(CMD.SYSTEM_CAPABILITIES, caps);
|
|
1081
|
+
this._ack();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
_handleGlobalStatusResponse(parsed) {
|
|
1085
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1086
|
+
this._logMinimal(`[Global Status] Received global status response`);
|
|
1087
|
+
this._log(`[Global Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
|
|
1088
|
+
|
|
1089
|
+
if (fullPayload && fullPayload.length > 0) {
|
|
1090
|
+
this.emit('global:statusResponse', fullPayload);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
this._resolvePendingRequest(CMD.GLOBAL_STATUS, fullPayload);
|
|
1094
|
+
this._ack();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
_handleTroubleStatusResponse(parsed) {
|
|
1098
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1099
|
+
this._logMinimal(`[Trouble Status] Received trouble status response`);
|
|
1100
|
+
this._log(`[Trouble Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
|
|
1101
|
+
|
|
1102
|
+
if (!fullPayload || fullPayload.length === 0) {
|
|
1103
|
+
this._logMinimal(`[Trouble Status] No active troubles`);
|
|
1104
|
+
this.emit('trouble:statusResponse', []);
|
|
1105
|
+
this._resolvePendingRequest(CMD.SYSTEM_TROUBLE_STATUS, []);
|
|
1106
|
+
this._ack();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const troubles = [];
|
|
1111
|
+
let offset = 0;
|
|
1112
|
+
|
|
1113
|
+
try {
|
|
1114
|
+
while (offset < fullPayload.length) {
|
|
1115
|
+
const device = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1116
|
+
offset += device.bytesRead;
|
|
1117
|
+
|
|
1118
|
+
const troubleCount = fullPayload[offset];
|
|
1119
|
+
offset += 1;
|
|
1120
|
+
|
|
1121
|
+
const troubleTypes = [];
|
|
1122
|
+
for (let i = 0; i < troubleCount; i++) {
|
|
1123
|
+
const trouble = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1124
|
+
offset += trouble.bytesRead;
|
|
1125
|
+
troubleTypes.push(trouble.value);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
troubles.push({ deviceType: device.value, troubles: troubleTypes });
|
|
1129
|
+
this._logMinimal(`[Trouble Status] Device ${device.value}: troubles [${troubleTypes.join(', ')}]`);
|
|
1130
|
+
}
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
this._logMinimal(`[Trouble Status] Parse error at offset ${offset}: ${e.message}`);
|
|
1133
|
+
this._logMinimal(`[Trouble Status] Raw hex: ${fullPayload.toString('hex')}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
this.emit('trouble:statusResponse', troubles);
|
|
1137
|
+
this._resolvePendingRequest(CMD.SYSTEM_TROUBLE_STATUS, troubles);
|
|
1138
|
+
this._ack();
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
_handleTroubleDetail(parsed) {
|
|
1142
|
+
// 0x0823 Trouble Detail Notification (from SDK DSC_ITroubleData interface)
|
|
1143
|
+
// Contains zero or more records, each:
|
|
1144
|
+
// [DeviceModuleType VarBytes][DeviceModuleNumber VarBytes][TroubleType VarBytes][TroubleState 1B]
|
|
1145
|
+
const TROUBLE_TYPES = {
|
|
1146
|
+
0x01: 'No Troubles', 0x02: 'Device Tamper', 0x03: 'Device Fault',
|
|
1147
|
+
0x04: 'Device Low Battery', 0x05: 'Device Delinquency', 0x06: 'Failure To Communicate',
|
|
1148
|
+
0x07: 'GSIP Receiver Trouble', 0x08: 'Receiver Not Available', 0x09: 'Receiver Supervision',
|
|
1149
|
+
0x0A: 'Device AC Trouble', 0x0B: 'Device Low Sensitivity', 0x0C: 'Device Internal Fault',
|
|
1150
|
+
0x0D: 'RF Device Not Networked', 0x0E: 'Device Mask Trouble', 0x0F: 'Power Unit Failure',
|
|
1151
|
+
0x10: 'Overcurrent', 0x21: 'System Troubles Level 1', 0x22: 'Service Request',
|
|
1152
|
+
0x23: 'Communications Troubles', 0x31: 'Bell Trouble', 0x36: 'RF Jam',
|
|
1153
|
+
0x37: 'Fire Trouble', 0x38: 'CO Trouble', 0x3A: 'Ground Fault',
|
|
1154
|
+
0x3B: 'Output Fault', 0x3C: 'TLM Trouble', 0x40: 'Time/Date Trouble',
|
|
1155
|
+
0x41: 'Configuration Trouble', 0x42: 'Warm Start', 0x43: 'USB/WIFI Trouble',
|
|
1156
|
+
0x44: 'Critical Shutdown', 0x51: 'SIM Lock', 0x56: 'GSM Trouble',
|
|
1157
|
+
0x61: 'Ethernet Trouble', 0x81: 'Module Supervisory', 0x82: 'Module Tamper',
|
|
1158
|
+
0x85: 'Module AC Trouble', 0x87: 'Module Low Battery', 0x88: 'Module Battery Missing',
|
|
1159
|
+
0x89: 'Module Battery Charger', 0x8B: 'Module Bus Low Voltage', 0x8C: 'Module Bus Fault',
|
|
1160
|
+
0x8D: 'Module AUX Trouble', 0x8E: 'Firmware Upgrade Fault', 0x91: 'Gas Trouble',
|
|
1161
|
+
0x92: 'Heat Trouble', 0x93: 'Freeze Trouble', 0x95: 'Smoke Trouble',
|
|
1162
|
+
0x96: 'Radio SIM Failure', 0x97: 'Module Battery Disconnected',
|
|
1163
|
+
0x98: 'Radio Low Signal', 0x99: 'GSM Network', 0x9A: 'IP Remote Server',
|
|
1164
|
+
};
|
|
1165
|
+
const DEVICE_TYPES = {
|
|
1166
|
+
0x01: 'System', 0x02: 'Zone', 0x03: 'Keypad', 0x04: 'Siren',
|
|
1167
|
+
0x05: 'FOB', 0x07: 'Repeater', 0x11: 'AML Module', 0x12: 'IO Expander',
|
|
1168
|
+
0x13: 'Zone Expander', 0x14: 'Wireless Transceiver', 0x15: 'Power Supply',
|
|
1169
|
+
0x1D: 'Alternate Communicator', 0xF1: 'All Modules', 0xF2: 'Interface Module',
|
|
1170
|
+
0xF3: 'IP Communicator',
|
|
1171
|
+
};
|
|
1172
|
+
const TROUBLE_STATES = { 0x01: 'Restored', 0x02: 'Trouble', 0x03: 'In Memory' };
|
|
1173
|
+
|
|
1174
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1175
|
+
const troubles = [];
|
|
1176
|
+
|
|
1177
|
+
if (fullPayload && fullPayload.length >= 4) {
|
|
1178
|
+
let offset = 0;
|
|
1179
|
+
try {
|
|
1180
|
+
while (offset < fullPayload.length) {
|
|
1181
|
+
const devType = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1182
|
+
offset += devType.bytesRead;
|
|
1183
|
+
const devNum = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1184
|
+
offset += devNum.bytesRead;
|
|
1185
|
+
const troubleType = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1186
|
+
offset += troubleType.bytesRead;
|
|
1187
|
+
if (offset >= fullPayload.length) break;
|
|
1188
|
+
const troubleState = fullPayload[offset++];
|
|
1189
|
+
|
|
1190
|
+
troubles.push({
|
|
1191
|
+
deviceType: devType.value,
|
|
1192
|
+
deviceTypeName: DEVICE_TYPES[devType.value] || `Device 0x${devType.value.toString(16)}`,
|
|
1193
|
+
deviceNumber: devNum.value,
|
|
1194
|
+
troubleType: troubleType.value,
|
|
1195
|
+
troubleTypeName: TROUBLE_TYPES[troubleType.value] || `Trouble 0x${troubleType.value.toString(16)}`,
|
|
1196
|
+
troubleState,
|
|
1197
|
+
troubleStateName: TROUBLE_STATES[troubleState] || `State ${troubleState}`,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
this._log(`[Trouble Detail] Parse error at offset ${offset}: ${e.message}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
for (const t of troubles) {
|
|
1206
|
+
this._logMinimal(`[Trouble] ${t.deviceTypeName} #${t.deviceNumber}: ${t.troubleTypeName} (${t.troubleStateName})`);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
this.emit('trouble:detail', troubles);
|
|
1210
|
+
this._ack();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ==================== Multiple Message Packet (0x0623) ====================
|
|
1214
|
+
|
|
1215
|
+
_handleMultipleMessagePacket(parsed) {
|
|
1216
|
+
// Panel batches multiple notifications with 1-byte length prefixes.
|
|
1217
|
+
// Format: [len1][cmd1 2B][payload1...][len2][cmd2 2B][payload2...]...
|
|
1218
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1219
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
1220
|
+
|
|
1221
|
+
if (!fullPayload || fullPayload.length < 3) return;
|
|
1222
|
+
|
|
1223
|
+
let offset = 0;
|
|
1224
|
+
while (offset < fullPayload.length) {
|
|
1225
|
+
const msgLen = fullPayload[offset++];
|
|
1226
|
+
if (msgLen === 0 || offset + msgLen > fullPayload.length) break;
|
|
1227
|
+
|
|
1228
|
+
const msgBytes = fullPayload.slice(offset, offset + msgLen);
|
|
1229
|
+
offset += msgLen;
|
|
1230
|
+
|
|
1231
|
+
if (msgBytes.length < 2) continue;
|
|
1232
|
+
const subCmd = msgBytes.readUInt16BE(0);
|
|
1233
|
+
const subPayload = msgBytes.slice(2);
|
|
1234
|
+
|
|
1235
|
+
this._log(`[MultiMsg] Sub-message: ${CMD_NAMES[subCmd] || '0x' + subCmd.toString(16)} (${subPayload.length}B)`);
|
|
1236
|
+
|
|
1237
|
+
// Build a fake parsed object for re-routing
|
|
1238
|
+
const subParsed = {
|
|
1239
|
+
command: subCmd,
|
|
1240
|
+
appSequence: subPayload.length > 0 ? subPayload[0] : null,
|
|
1241
|
+
commandData: subPayload.length > 1 ? subPayload.slice(1) : null,
|
|
1242
|
+
senderSequence: parsed.senderSequence,
|
|
1243
|
+
receiverSequence: parsed.receiverSequence,
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// Route the sub-message through existing handlers
|
|
1247
|
+
// (ACK already sent for the outer 0x0623 packet)
|
|
1248
|
+
this._routePacket(subParsed, true);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ==================== Notification Handlers ====================
|
|
1253
|
+
|
|
1254
|
+
_handleArmingNotification(parsed) {
|
|
1255
|
+
// NotificationArmDisarm (neohub format, IMessageData NOT CommandMessageBase):
|
|
1256
|
+
// [CompactInt:Partition][ArmMode:1B][Method:1B][CompactInt:UserId]
|
|
1257
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1258
|
+
if (!fullPayload || fullPayload.length < 4) {
|
|
1259
|
+
this._ack();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
try {
|
|
1264
|
+
let offset = 0;
|
|
1265
|
+
const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1266
|
+
offset += partition.bytesRead;
|
|
1267
|
+
const armMode = fullPayload[offset++];
|
|
1268
|
+
const method = fullPayload[offset++];
|
|
1269
|
+
let userId = 0;
|
|
1270
|
+
if (offset < fullPayload.length) {
|
|
1271
|
+
const uid = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1272
|
+
userId = uid.value;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const modeNames = {
|
|
1276
|
+
0: 'DISARMED', 1: 'STAY', 2: 'AWAY', 3: 'NO_ENTRY_DELAY',
|
|
1277
|
+
4: 'NIGHT', 5: 'QUICK', 6: 'USER'
|
|
1278
|
+
};
|
|
1279
|
+
const modeName = modeNames[armMode] || `MODE_${armMode}`;
|
|
1280
|
+
this._logMinimal(`[Partition ${partition.value}] ${modeName} (method=${method}, user=${userId})`);
|
|
1281
|
+
|
|
1282
|
+
this.eventHandler.handlePartitionArming(partition.value, armMode);
|
|
1283
|
+
this.emit('partition:arming', {
|
|
1284
|
+
partition: partition.value,
|
|
1285
|
+
armMode,
|
|
1286
|
+
modeName,
|
|
1287
|
+
method,
|
|
1288
|
+
userId,
|
|
1289
|
+
});
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
this._log(`[Arming Notification] Parse error: ${e.message}`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
this._ack();
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
_handlePartitionReadyNotification(parsed) {
|
|
1298
|
+
// NotificationPartitionReadyStatus (neohub format):
|
|
1299
|
+
// [CompactInt:PartitionNumber][Status:1B]
|
|
1300
|
+
// Status: 0=Reserved, 1=ReadyToArm, 2=ReadyToForceArm, 3=NotReadyToArm
|
|
1301
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1302
|
+
if (!fullPayload || fullPayload.length < 3) {
|
|
1303
|
+
this._ack();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
let offset = 0;
|
|
1309
|
+
const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1310
|
+
offset += partition.bytesRead;
|
|
1311
|
+
const readyStatus = fullPayload[offset];
|
|
1312
|
+
|
|
1313
|
+
const statusNames = { 1: 'READY', 2: 'FORCE_ARM_READY', 3: 'NOT_READY' };
|
|
1314
|
+
const isReady = readyStatus === 1 || readyStatus === 2;
|
|
1315
|
+
this._logMinimal(`[Partition ${partition.value}] ${statusNames[readyStatus] || 'UNKNOWN'}`);
|
|
1316
|
+
|
|
1317
|
+
this.eventHandler.handlePartitionReady(partition.value, isReady);
|
|
1318
|
+
this.emit('partition:ready', { partition: partition.value, readyStatus, isReady });
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
this._log(`[Ready Notification] Parse error: ${e.message}`);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
this._ack();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
_handlePartitionTroubleNotification(parsed) {
|
|
1327
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1328
|
+
if (!fullPayload || fullPayload.length < 3) {
|
|
1329
|
+
this._ack();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
let offset = 0;
|
|
1335
|
+
const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1336
|
+
offset += partition.bytesRead;
|
|
1337
|
+
const troubleFlags = fullPayload[offset];
|
|
1338
|
+
|
|
1339
|
+
this._logMinimal(`[Partition ${partition.value}] Trouble: 0x${troubleFlags.toString(16)}`);
|
|
1340
|
+
this.eventHandler.handlePartitionTrouble(partition.value, troubleFlags);
|
|
1341
|
+
this.emit('partition:trouble', { partition: partition.value, troubleFlags });
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
this._log(`[Trouble Notification] Parse error: ${e.message}`);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
this._ack();
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ==================== Payload Helpers ====================
|
|
1350
|
+
|
|
1351
|
+
_ack() {
|
|
1352
|
+
if (!this._skipAck) {
|
|
1353
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Reconstruct full payload for non-CommandMessageBase messages.
|
|
1359
|
+
* parseHeader always treats byte 4 as appSequence, but for non-command messages
|
|
1360
|
+
* (notifications, status responses), that byte is actually the first payload byte
|
|
1361
|
+
* (typically a CompactInteger length prefix). This method reassembles the full payload.
|
|
1362
|
+
*/
|
|
1363
|
+
_reconstructPayload(parsed) {
|
|
1364
|
+
if (parsed.appSequence !== null && parsed.commandData) {
|
|
1365
|
+
return Buffer.concat([Buffer.from([parsed.appSequence]), parsed.commandData]);
|
|
1366
|
+
} else if (parsed.appSequence !== null) {
|
|
1367
|
+
return Buffer.from([parsed.appSequence]);
|
|
1368
|
+
}
|
|
1369
|
+
return parsed.commandData || Buffer.alloc(0);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ==================== Utility Methods ====================
|
|
1373
|
+
|
|
1374
|
+
_handleError(error) {
|
|
1375
|
+
this._log(`[Error] ${error.message}`);
|
|
1376
|
+
this.emit('error', error);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
_log(message) {
|
|
1380
|
+
if (this.logLevel === 'verbose') {
|
|
1381
|
+
const timestamp = new Date().toISOString();
|
|
1382
|
+
console.log(`dsc itv2: [${timestamp}] ${message}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
_logMinimal(message) {
|
|
1387
|
+
if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
|
|
1388
|
+
console.log(`dsc itv2: ${message}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
_hexDump(buffer) {
|
|
1393
|
+
if (this.logLevel !== 'verbose') return;
|
|
1394
|
+
|
|
1395
|
+
for (let i = 0; i < buffer.length; i += 16) {
|
|
1396
|
+
const chunk = buffer.slice(i, i + 16);
|
|
1397
|
+
const hex = Array.from(chunk)
|
|
1398
|
+
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
|
|
1399
|
+
.join(' ');
|
|
1400
|
+
const ascii = Array.from(chunk)
|
|
1401
|
+
.map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
|
|
1402
|
+
.join('');
|
|
1403
|
+
|
|
1404
|
+
const offset = i.toString(16).padStart(4, '0').toUpperCase();
|
|
1405
|
+
console.log(`dsc itv2: ${offset} ${hex.padEnd(48)} |${ascii}|`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|