dsc-itv2-client 1.0.7
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 +342 -0
- package/package.json +46 -0
- package/src/ITV2Client.js +502 -0
- package/src/constants.js +106 -0
- package/src/event-handler.js +323 -0
- package/src/examples/README.md +287 -0
- package/src/examples/arm-disarm-example.js +152 -0
- package/src/examples/basic-monitoring.js +82 -0
- package/src/examples/interactive-cli.js +1033 -0
- package/src/index.js +17 -0
- package/src/itv2-crypto.js +310 -0
- package/src/itv2-session.js +1069 -0
- package/src/response-parsers.js +185 -0
- package/src/utils.js +240 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSC ITV2 Protocol Response Parsers
|
|
3
|
+
* Based on decompiled SDK structures
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse Zone Status response (0x0811)
|
|
8
|
+
* Each zone status is a single byte with bit fields
|
|
9
|
+
*/
|
|
10
|
+
export function parseZoneStatus(statusByte) {
|
|
11
|
+
return {
|
|
12
|
+
zoneNumber: null, // Set by caller
|
|
13
|
+
open: (statusByte & 0x01) !== 0, // Bit 0: Open/Closed
|
|
14
|
+
tamper: (statusByte & 0x02) !== 0, // Bit 1: Tamper
|
|
15
|
+
fault: (statusByte & 0x04) !== 0, // Bit 2: Fault
|
|
16
|
+
lowBattery: (statusByte & 0x08) !== 0, // Bit 3: Low Battery
|
|
17
|
+
delinquency: (statusByte & 0x10) !== 0, // Bit 4: Communication issue
|
|
18
|
+
alarm: (statusByte & 0x20) !== 0, // Bit 5: Alarm
|
|
19
|
+
alarmInMemory: (statusByte & 0x40) !== 0, // Bit 6: Alarm memory
|
|
20
|
+
bypassed: (statusByte & 0x80) !== 0, // Bit 7: Bypassed
|
|
21
|
+
rawByte: statusByte
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format zone status as human-readable string
|
|
27
|
+
*/
|
|
28
|
+
export function formatZoneStatus(zone) {
|
|
29
|
+
const state = zone.open ? 'OPEN' : 'CLOSED';
|
|
30
|
+
const flags = [];
|
|
31
|
+
|
|
32
|
+
if (zone.alarm) flags.push('ALARM');
|
|
33
|
+
if (zone.tamper) flags.push('TAMPER');
|
|
34
|
+
if (zone.fault) flags.push('FAULT');
|
|
35
|
+
if (zone.bypassed) flags.push('BYPASSED');
|
|
36
|
+
if (zone.lowBattery) flags.push('LOW-BATT');
|
|
37
|
+
if (zone.delinquency) flags.push('COMM-ERR');
|
|
38
|
+
if (zone.alarmInMemory) flags.push('ALARM-MEM');
|
|
39
|
+
|
|
40
|
+
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
41
|
+
return `Zone ${zone.zoneNumber}: ${state}${flagStr}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse Partition Status response (0x0812)
|
|
46
|
+
* Status is encoded across multiple bytes using bit fields
|
|
47
|
+
*/
|
|
48
|
+
export function parsePartitionStatus(statusBytes) {
|
|
49
|
+
if (!statusBytes || statusBytes.length < 3) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Helper: Check bit at specific index
|
|
54
|
+
const checkBit = (bitIndex) => {
|
|
55
|
+
const byteOffset = Math.floor(bitIndex / 8);
|
|
56
|
+
const bitMask = 1 << (bitIndex % 8);
|
|
57
|
+
return byteOffset < statusBytes.length && (statusBytes[byteOffset] & bitMask) !== 0;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Armed state is encoded in bits 0-3 (lower nibble of first byte)
|
|
61
|
+
const armedValue = statusBytes[0] & 0x0F;
|
|
62
|
+
const armedStates = {
|
|
63
|
+
1: 'Disarmed',
|
|
64
|
+
2: 'Armed Away',
|
|
65
|
+
3: 'Armed Stay',
|
|
66
|
+
4: 'Armed Stay (No Entry)',
|
|
67
|
+
5: 'Armed Stay (Instant)',
|
|
68
|
+
6: 'Armed Away (Instant)'
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
partitionNumber: null, // Set by caller
|
|
73
|
+
armedState: armedStates[armedValue] || `Unknown (${armedValue})`,
|
|
74
|
+
armedValue: armedValue,
|
|
75
|
+
ready: checkBit(0) || checkBit(1) || checkBit(2), // Bits 0-2: Ready state
|
|
76
|
+
alarm: checkBit(8), // Bit 8: Alarm
|
|
77
|
+
trouble: checkBit(9), // Bit 9: Trouble
|
|
78
|
+
bypass: checkBit(10), // Bit 10: Bypass active
|
|
79
|
+
busy: checkBit(11), // Bit 11: Busy (processing)
|
|
80
|
+
alarmMemory: checkBit(12), // Bit 12: Alarm in memory
|
|
81
|
+
audibleBell: checkBit(14), // Bit 14: Siren/bell
|
|
82
|
+
buzzer: checkBit(15), // Bit 15: Buzzer
|
|
83
|
+
blank: checkBit(19), // Bit 19: Display blank
|
|
84
|
+
rawBytes: Buffer.from(statusBytes).toString('hex')
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format partition status as human-readable string
|
|
90
|
+
*/
|
|
91
|
+
export function formatPartitionStatus(partition) {
|
|
92
|
+
const flags = [];
|
|
93
|
+
|
|
94
|
+
if (partition.alarm) flags.push('ALARM');
|
|
95
|
+
if (partition.trouble) flags.push('TROUBLE');
|
|
96
|
+
if (partition.bypass) flags.push('BYPASS');
|
|
97
|
+
if (partition.busy) flags.push('BUSY');
|
|
98
|
+
if (partition.audibleBell) flags.push('SIREN');
|
|
99
|
+
if (partition.buzzer) flags.push('BUZZER');
|
|
100
|
+
if (partition.alarmMemory) flags.push('ALARM-MEM');
|
|
101
|
+
|
|
102
|
+
const readyStr = partition.ready ? 'Ready' : 'Not Ready';
|
|
103
|
+
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
104
|
+
|
|
105
|
+
return `Partition ${partition.partitionNumber}: ${partition.armedState} - ${readyStr}${flagStr}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse COMMAND_RESPONSE (0x0502)
|
|
110
|
+
* Response code indicates success/failure
|
|
111
|
+
*/
|
|
112
|
+
export function parseCommandResponse(responseCode) {
|
|
113
|
+
const codes = {
|
|
114
|
+
0x00: 'Success',
|
|
115
|
+
0x01: 'Informational/Success',
|
|
116
|
+
0x02: 'Invalid Command',
|
|
117
|
+
0x03: 'Invalid Parameter',
|
|
118
|
+
0x04: 'Access Denied',
|
|
119
|
+
0x05: 'Busy',
|
|
120
|
+
0x06: 'Not Ready',
|
|
121
|
+
0x07: 'Timeout',
|
|
122
|
+
0xFF: 'General Error'
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
code: responseCode,
|
|
127
|
+
message: codes[responseCode] || `Unknown (0x${responseCode.toString(16).padStart(2, '0')})`,
|
|
128
|
+
success: responseCode === 0x00 || responseCode === 0x01
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse COMMAND_ERROR (0x0501)
|
|
134
|
+
*/
|
|
135
|
+
export function parseCommandError(errorData) {
|
|
136
|
+
if (!errorData || errorData.length < 2) {
|
|
137
|
+
return { message: 'Unknown error', rawData: errorData?.toString('hex') || 'none' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const errorCode = (errorData[0] << 8) | errorData[1];
|
|
141
|
+
|
|
142
|
+
const errorCodes = {
|
|
143
|
+
0x0002: 'Authentication required / Access denied',
|
|
144
|
+
0x1002: 'Global status query not supported or auth required',
|
|
145
|
+
0x1102: 'Zone status query not supported or auth required',
|
|
146
|
+
0x1202: 'Partition status query not supported or auth required',
|
|
147
|
+
0x1302: 'Invalid capabilities query',
|
|
148
|
+
0x1402: 'Invalid panel status query',
|
|
149
|
+
0x0C01: 'Invalid buffer sizes',
|
|
150
|
+
0x1100: 'Invalid zone number',
|
|
151
|
+
0x1200: 'Invalid partition number'
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
code: errorCode,
|
|
156
|
+
message: errorCodes[errorCode] || `Error code 0x${errorCode.toString(16).padStart(4, '0')}`,
|
|
157
|
+
rawData: errorData.toString('hex')
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extract variable-length value from buffer
|
|
163
|
+
*/
|
|
164
|
+
export function parseVarLengthValue(buffer, offset = 0) {
|
|
165
|
+
if (offset >= buffer.length) return null;
|
|
166
|
+
|
|
167
|
+
const firstByte = buffer[offset];
|
|
168
|
+
|
|
169
|
+
// Determine size based on first byte
|
|
170
|
+
if (firstByte < 0xFD) {
|
|
171
|
+
// 1-byte value
|
|
172
|
+
return { value: firstByte, bytesRead: 1 };
|
|
173
|
+
} else if (firstByte === 0xFD && offset + 2 < buffer.length) {
|
|
174
|
+
// 2-byte value
|
|
175
|
+
const value = (buffer[offset + 1] << 8) | buffer[offset + 2];
|
|
176
|
+
return { value, bytesRead: 3 };
|
|
177
|
+
} else if (firstByte === 0xFE && offset + 4 < buffer.length) {
|
|
178
|
+
// 4-byte value
|
|
179
|
+
const value = (buffer[offset + 1] << 24) | (buffer[offset + 2] << 16) |
|
|
180
|
+
(buffer[offset + 3] << 8) | buffer[offset + 4];
|
|
181
|
+
return { value, bytesRead: 5 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for ITV2 protocol
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const LOGS_DIR = join(__dirname, '..', 'logs');
|
|
11
|
+
|
|
12
|
+
// Ensure logs directory exists
|
|
13
|
+
if (!existsSync(LOGS_DIR)) {
|
|
14
|
+
mkdirSync(LOGS_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert buffer to hex string with optional spacing
|
|
19
|
+
*/
|
|
20
|
+
export function toHex(buffer, separator = ' ') {
|
|
21
|
+
if (!buffer || buffer.length === 0) return '';
|
|
22
|
+
return Array.from(buffer)
|
|
23
|
+
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
|
24
|
+
.join(separator);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert hex string to buffer
|
|
29
|
+
*/
|
|
30
|
+
export function fromHex(hexString) {
|
|
31
|
+
const clean = hexString.replace(/\s+/g, '');
|
|
32
|
+
const bytes = [];
|
|
33
|
+
for (let i = 0; i < clean.length; i += 2) {
|
|
34
|
+
bytes.push(parseInt(clean.substr(i, 2), 16));
|
|
35
|
+
}
|
|
36
|
+
return Buffer.from(bytes);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format buffer for display with ASCII representation
|
|
41
|
+
*/
|
|
42
|
+
export function hexDump(buffer, bytesPerLine = 16) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (let i = 0; i < buffer.length; i += bytesPerLine) {
|
|
45
|
+
const slice = buffer.slice(i, Math.min(i + bytesPerLine, buffer.length));
|
|
46
|
+
const hex = toHex(slice, ' ').padEnd(bytesPerLine * 3 - 1);
|
|
47
|
+
const ascii = Array.from(slice)
|
|
48
|
+
.map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.')
|
|
49
|
+
.join('');
|
|
50
|
+
lines.push(`${i.toString(16).padStart(4, '0')} ${hex} |${ascii}|`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get timestamp for logging
|
|
57
|
+
*/
|
|
58
|
+
export function timestamp() {
|
|
59
|
+
return new Date().toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Log to console and file
|
|
64
|
+
*/
|
|
65
|
+
export function log(message, data = null) {
|
|
66
|
+
const ts = timestamp();
|
|
67
|
+
const logLine = `[${ts}] ${message}`;
|
|
68
|
+
console.log(logLine);
|
|
69
|
+
|
|
70
|
+
if (data) {
|
|
71
|
+
if (Buffer.isBuffer(data)) {
|
|
72
|
+
console.log(hexDump(data));
|
|
73
|
+
} else {
|
|
74
|
+
console.log(data);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Append to log file
|
|
79
|
+
const logFile = join(LOGS_DIR, `capture_${new Date().toISOString().split('T')[0]}.log`);
|
|
80
|
+
let fileContent = logLine + '\n';
|
|
81
|
+
if (data) {
|
|
82
|
+
if (Buffer.isBuffer(data)) {
|
|
83
|
+
fileContent += hexDump(data) + '\n';
|
|
84
|
+
} else if (typeof data === 'object') {
|
|
85
|
+
fileContent += JSON.stringify(data, null, 2) + '\n';
|
|
86
|
+
} else {
|
|
87
|
+
fileContent += data + '\n';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
appendFileSync(logFile, fileContent);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Log raw packet data to binary file for later analysis
|
|
95
|
+
*/
|
|
96
|
+
export function logRawPacket(direction, data) {
|
|
97
|
+
const ts = Date.now();
|
|
98
|
+
const filename = join(LOGS_DIR, `packets_${new Date().toISOString().split('T')[0]}.bin`);
|
|
99
|
+
|
|
100
|
+
// Format: [timestamp 8 bytes][direction 1 byte][length 2 bytes][data]
|
|
101
|
+
const header = Buffer.alloc(11);
|
|
102
|
+
header.writeBigInt64BE(BigInt(ts), 0);
|
|
103
|
+
header.writeUInt8(direction === 'TX' ? 0x01 : 0x02, 8);
|
|
104
|
+
header.writeUInt16BE(data.length, 9);
|
|
105
|
+
|
|
106
|
+
appendFileSync(filename, Buffer.concat([header, data]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* CRC-16 calculation with configurable polynomial and seed
|
|
111
|
+
*/
|
|
112
|
+
export function crc16(data, polynomial = 0x1021, seed = 0xFFFF, reflectInput = false, reflectOutput = false, xorOut = 0x0000) {
|
|
113
|
+
let crc = seed;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < data.length; i++) {
|
|
116
|
+
let byte = data[i];
|
|
117
|
+
if (reflectInput) {
|
|
118
|
+
byte = reflect8(byte);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
crc ^= (byte << 8);
|
|
122
|
+
|
|
123
|
+
for (let j = 0; j < 8; j++) {
|
|
124
|
+
if (crc & 0x8000) {
|
|
125
|
+
crc = ((crc << 1) ^ polynomial) & 0xFFFF;
|
|
126
|
+
} else {
|
|
127
|
+
crc = (crc << 1) & 0xFFFF;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (reflectOutput) {
|
|
133
|
+
crc = reflect16(crc);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (crc ^ xorOut) & 0xFFFF;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reflect bits in a byte
|
|
141
|
+
*/
|
|
142
|
+
function reflect8(value) {
|
|
143
|
+
let result = 0;
|
|
144
|
+
for (let i = 0; i < 8; i++) {
|
|
145
|
+
if (value & (1 << i)) {
|
|
146
|
+
result |= (1 << (7 - i));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reflect bits in a 16-bit value
|
|
154
|
+
*/
|
|
155
|
+
function reflect16(value) {
|
|
156
|
+
let result = 0;
|
|
157
|
+
for (let i = 0; i < 16; i++) {
|
|
158
|
+
if (value & (1 << i)) {
|
|
159
|
+
result |= (1 << (15 - i));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Try multiple CRC algorithms to find matching one
|
|
167
|
+
*/
|
|
168
|
+
export function tryCrcAlgorithms(data, expectedCrc) {
|
|
169
|
+
const algorithms = [
|
|
170
|
+
{ name: 'CRC-16-CCITT', poly: 0x1021, seed: 0xFFFF, refIn: false, refOut: false, xorOut: 0x0000 },
|
|
171
|
+
{ name: 'CRC-16-CCITT-FALSE', poly: 0x1021, seed: 0xFFFF, refIn: false, refOut: false, xorOut: 0x0000 },
|
|
172
|
+
{ name: 'CRC-16-XMODEM', poly: 0x1021, seed: 0x0000, refIn: false, refOut: false, xorOut: 0x0000 },
|
|
173
|
+
{ name: 'CRC-16-IBM', poly: 0x8005, seed: 0x0000, refIn: true, refOut: true, xorOut: 0x0000 },
|
|
174
|
+
{ name: 'CRC-16-MODBUS', poly: 0x8005, seed: 0xFFFF, refIn: true, refOut: true, xorOut: 0x0000 },
|
|
175
|
+
{ name: 'CRC-16-USB', poly: 0x8005, seed: 0xFFFF, refIn: true, refOut: true, xorOut: 0xFFFF },
|
|
176
|
+
{ name: 'CRC-16-MAXIM', poly: 0x8005, seed: 0x0000, refIn: true, refOut: true, xorOut: 0xFFFF },
|
|
177
|
+
{ name: 'CRC-16-DNP', poly: 0x3D65, seed: 0x0000, refIn: true, refOut: true, xorOut: 0xFFFF },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const matches = [];
|
|
181
|
+
for (const alg of algorithms) {
|
|
182
|
+
const calc = crc16(data, alg.poly, alg.seed, alg.refIn, alg.refOut, alg.xorOut);
|
|
183
|
+
if (calc === expectedCrc) {
|
|
184
|
+
matches.push({ ...alg, calculated: calc });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return matches;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Byte unstuffing (HDLC-like)
|
|
192
|
+
* Escape byte 0x7D followed by XOR'd byte
|
|
193
|
+
*/
|
|
194
|
+
export function byteUnstuff(data, escapeChar = 0x7D, xorValue = 0x20) {
|
|
195
|
+
const result = [];
|
|
196
|
+
let i = 0;
|
|
197
|
+
while (i < data.length) {
|
|
198
|
+
if (data[i] === escapeChar && i + 1 < data.length) {
|
|
199
|
+
result.push(data[i + 1] ^ xorValue);
|
|
200
|
+
i += 2;
|
|
201
|
+
} else {
|
|
202
|
+
result.push(data[i]);
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return Buffer.from(result);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Byte stuffing (HDLC-like)
|
|
211
|
+
*/
|
|
212
|
+
export function byteStuff(data, escapeChar = 0x7D, xorValue = 0x20, specialChars = [0x7E, 0x7D]) {
|
|
213
|
+
const result = [];
|
|
214
|
+
for (const byte of data) {
|
|
215
|
+
if (specialChars.includes(byte)) {
|
|
216
|
+
result.push(escapeChar);
|
|
217
|
+
result.push(byte ^ xorValue);
|
|
218
|
+
} else {
|
|
219
|
+
result.push(byte);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return Buffer.from(result);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parse a 16-bit command code
|
|
227
|
+
*/
|
|
228
|
+
export function parseCommand(high, low) {
|
|
229
|
+
return (high << 8) | low;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create a 16-bit command code
|
|
234
|
+
*/
|
|
235
|
+
export function makeCommand(code) {
|
|
236
|
+
return {
|
|
237
|
+
high: (code >> 8) & 0xFF,
|
|
238
|
+
low: code & 0xFF,
|
|
239
|
+
};
|
|
240
|
+
}
|