camstreamerlib 1.3.1 → 1.6.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/.github/workflows/github-ci.yml +46 -0
- package/jest.config.js +11 -0
- package/package.json +20 -3
- package/src/CamOverlayAPI.ts +370 -0
- package/src/CamStreamerAPI.ts +58 -0
- package/src/CamSwitcherAPI.ts +130 -0
- package/src/CameraVapix.ts +398 -0
- package/src/Digest.ts +41 -0
- package/src/HTTPRequest.ts +97 -0
- package/{HttpServer.js → src/HttpServer.ts} +43 -34
- package/src/RtspClient.ts +343 -0
- package/test/Digest.test.ts +13 -0
- package/tsconfig.json +11 -0
- package/CamOverlayAPI.js +0 -365
- package/CamStreamerAPI.js +0 -77
- package/CamSwitcherAPI.js +0 -128
- package/CameraVapix.js +0 -258
- package/Digest.js +0 -41
- package/HTTPRequest.js +0 -78
- package/RtspClient.js +0 -327
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import * as EventEmitter from 'events';
|
|
3
|
+
|
|
4
|
+
import { Digest } from './Digest';
|
|
5
|
+
|
|
6
|
+
export type RtspClientOptions = {
|
|
7
|
+
ip?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
auth?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class RtspClient extends EventEmitter {
|
|
13
|
+
private ip: string;
|
|
14
|
+
private port: number;
|
|
15
|
+
private auth: string;
|
|
16
|
+
|
|
17
|
+
private authorizationType = 'basic';
|
|
18
|
+
private wwwAuthenticateHeader = '';
|
|
19
|
+
private sessioncookie = Math.random().toString(36).substring(7);
|
|
20
|
+
private clientGet: net.Socket = null;
|
|
21
|
+
private clientPost: net.Socket = null;
|
|
22
|
+
private inputBuffer: Buffer = null;
|
|
23
|
+
private state = 'HTTP_INIT';
|
|
24
|
+
private rtspPath = '';
|
|
25
|
+
private rtspCSeq = 0;
|
|
26
|
+
private control = '';
|
|
27
|
+
private session = '';
|
|
28
|
+
private authorizationSent = false;
|
|
29
|
+
private keepAliveTimer = null;
|
|
30
|
+
private disconnected = false;
|
|
31
|
+
private rtpMsgBuffer = '';
|
|
32
|
+
|
|
33
|
+
constructor(options?: RtspClientOptions) {
|
|
34
|
+
super();
|
|
35
|
+
|
|
36
|
+
this.ip = options?.ip ?? '127.0.0.1';
|
|
37
|
+
this.port = options?.port ?? 80;
|
|
38
|
+
this.auth = options?.auth ?? '';
|
|
39
|
+
|
|
40
|
+
EventEmitter.call(this);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
connect(eventTopicFilter: string) {
|
|
44
|
+
this.rtspPath = '/axis-media/media.amp?video=0&audio=0&event=on';
|
|
45
|
+
if (eventTopicFilter.length != 0) {
|
|
46
|
+
this.rtspPath += '&eventtopic=' + eventTopicFilter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Start GET connection
|
|
50
|
+
this.clientGet = new net.Socket();
|
|
51
|
+
this.clientGet.connect(this.port, this.ip, () => {
|
|
52
|
+
this.clientGet.write(this.getInitializationMessageGet());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.clientGet.on('data', (data) => this.processGetMessage(data));
|
|
56
|
+
|
|
57
|
+
this.clientGet.on('close', () => this.closeConnection);
|
|
58
|
+
|
|
59
|
+
// Start POST connection
|
|
60
|
+
this.clientPost = new net.Socket();
|
|
61
|
+
this.clientPost.connect(this.port, this.ip, () => {
|
|
62
|
+
this.clientPost.write(this.getInitializationMessagePost());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.clientPost.on('data', (data) => {});
|
|
66
|
+
|
|
67
|
+
this.clientPost.on('close', () => this.closeConnection);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disconnect() {
|
|
71
|
+
this.closeConnection('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
closeConnection(reason: string) {
|
|
75
|
+
if (!this.disconnected) {
|
|
76
|
+
this.disconnected = true;
|
|
77
|
+
this.emit('disconnect', reason);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clearInterval(this.keepAliveTimer);
|
|
81
|
+
this.clientGet.destroy();
|
|
82
|
+
this.clientPost.destroy();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
processGetMessage(data) {
|
|
86
|
+
if (this.inputBuffer == null) {
|
|
87
|
+
this.inputBuffer = Buffer.from(data, 'binary');
|
|
88
|
+
} else {
|
|
89
|
+
this.inputBuffer = Buffer.concat([this.inputBuffer, Buffer.from(data, 'binary')]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.state == 'HTTP_INIT') {
|
|
93
|
+
const msg = this.parseRtspMessage();
|
|
94
|
+
if (msg != null) {
|
|
95
|
+
if (msg.statusLine.indexOf('200 OK') != -1) {
|
|
96
|
+
this.state = 'RTSP_OPTION';
|
|
97
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
98
|
+
} else if (msg.statusLine.indexOf('401') != -1 && !this.authorizationSent) {
|
|
99
|
+
this.authorizationSent = true;
|
|
100
|
+
this.authorizationType = 'digest';
|
|
101
|
+
this.wwwAuthenticateHeader = msg.headers['www-authenticate'];
|
|
102
|
+
this.clientGet.write(this.getInitializationMessageGet());
|
|
103
|
+
} else {
|
|
104
|
+
this.closeConnection(msg.statusLine.toString());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
while (this.inputBuffer.length > 0) {
|
|
111
|
+
let msgType = '';
|
|
112
|
+
let firstChar = String.fromCharCode(this.inputBuffer[0]);
|
|
113
|
+
if (firstChar == 'R') {
|
|
114
|
+
msgType = 'RTSP';
|
|
115
|
+
} else if (firstChar == '$') {
|
|
116
|
+
msgType = 'RTP';
|
|
117
|
+
} else {
|
|
118
|
+
this.closeConnection('Unknown message found: ' + this.inputBuffer);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (msgType == 'RTSP') {
|
|
123
|
+
let msg = this.parseRtspMessage();
|
|
124
|
+
if (!msg) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (msg.statusLine.indexOf('401') != -1 && !this.authorizationSent) {
|
|
129
|
+
this.authorizationSent = true;
|
|
130
|
+
this.authorizationType = 'digest';
|
|
131
|
+
this.wwwAuthenticateHeader = msg.headers['www-authenticate'];
|
|
132
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.state != 'HTTP_INIT' && msg.statusLine.indexOf('RTSP/1.0 200 OK') == -1) {
|
|
137
|
+
this.closeConnection('Invalid RTSP response: ' + msg.headersRaw + msg.body);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.authorizationSent = false; // Authorization expires for digest method
|
|
142
|
+
switch (this.state) {
|
|
143
|
+
case 'RTSP_OPTION': {
|
|
144
|
+
this.state = 'RTSP_DESCRIBE';
|
|
145
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'RTSP_DESCRIBE': {
|
|
149
|
+
const regex = /(a=control):(.*)/g;
|
|
150
|
+
this.control = regex.exec(msg.body.toString())[2];
|
|
151
|
+
this.state = 'RTSP_SETUP';
|
|
152
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'RTSP_SETUP': {
|
|
156
|
+
this.session = msg.headers['session'].split(';')[0];
|
|
157
|
+
this.state = 'RTSP_PLAY';
|
|
158
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'RTSP_PLAY': {
|
|
162
|
+
this.emit('connect');
|
|
163
|
+
this.state = 'RTSP_GET_PARAMETER';
|
|
164
|
+
this.keepAliveTimer = setInterval(() => {
|
|
165
|
+
this.sendRtspMessage(this.getRtspMessage());
|
|
166
|
+
}, 30000);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else if (msgType == 'RTP') {
|
|
171
|
+
let msg = this.parseRtpMessage();
|
|
172
|
+
if (!msg) {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (msg.channel == 0) {
|
|
177
|
+
this.rtpMsgBuffer += msg.body.toString();
|
|
178
|
+
while (true) {
|
|
179
|
+
let msgEnd = this.rtpMsgBuffer.indexOf('</tt:MetadataStream>');
|
|
180
|
+
if (msgEnd == -1) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
this.emit('event', this.rtpMsgBuffer.substring(0, msgEnd + 20));
|
|
184
|
+
this.rtpMsgBuffer = this.rtpMsgBuffer.substring(msgEnd + 20);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
parseRtspMessage() {
|
|
192
|
+
const msgEndFound = this.inputBuffer.indexOf('\r\n\r\n');
|
|
193
|
+
if (msgEndFound == -1) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const msgHeaders = this.inputBuffer.subarray(0, msgEndFound + 4);
|
|
198
|
+
const headers = {};
|
|
199
|
+
const regex = /([\w-]+): (.*)/g;
|
|
200
|
+
|
|
201
|
+
const stringHeaders = msgHeaders.toString();
|
|
202
|
+
let tmp: RegExpExecArray;
|
|
203
|
+
while ((tmp = regex.exec(stringHeaders))) {
|
|
204
|
+
headers[tmp[1].toLowerCase()] = tmp[2];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let msgSize = msgEndFound + 4;
|
|
208
|
+
if (headers['content-length'] != undefined) {
|
|
209
|
+
msgSize += parseInt(headers['content-length']);
|
|
210
|
+
}
|
|
211
|
+
let body = this.inputBuffer.subarray(msgEndFound + 4, msgSize);
|
|
212
|
+
this.inputBuffer = this.inputBuffer.subarray(msgSize);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
statusLine: msgHeaders.subarray(0, msgHeaders.indexOf('\r\n')),
|
|
216
|
+
headers: headers,
|
|
217
|
+
headersRaw: msgHeaders,
|
|
218
|
+
body: body,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
parseRtpMessage() {
|
|
223
|
+
const INTERLEAVED_HEADER_SIZE = 4;
|
|
224
|
+
const RTP_HEADER_SIZE = 12;
|
|
225
|
+
if (this.inputBuffer.length < INTERLEAVED_HEADER_SIZE) {
|
|
226
|
+
// Not enough data even for rtp header. Try again when more data is available
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let channel = this.inputBuffer[1]; // Interleaved channel, 0 - RTP, 1 - RTCP
|
|
231
|
+
let msgDataSize = (this.inputBuffer[2] << 8) | this.inputBuffer[3];
|
|
232
|
+
msgDataSize += INTERLEAVED_HEADER_SIZE;
|
|
233
|
+
|
|
234
|
+
// Ivalid input data - invalid channel
|
|
235
|
+
if (channel < 0 || channel > 1) {
|
|
236
|
+
this.closeConnection(`InterleavedMessage - invalid channel found: ${channel}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The complete RTP package is not here yet, wait for more data
|
|
241
|
+
if (msgDataSize > this.inputBuffer.length) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let body = this.inputBuffer.subarray(INTERLEAVED_HEADER_SIZE + RTP_HEADER_SIZE, msgDataSize);
|
|
246
|
+
this.inputBuffer = this.inputBuffer.subarray(msgDataSize);
|
|
247
|
+
return { channel: channel, body: body };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
getInitializationMessageGet() {
|
|
251
|
+
return (
|
|
252
|
+
'GET /axis-media/media.amp HTTP/1.1\r\n' +
|
|
253
|
+
'CSeq: 1\r\n' +
|
|
254
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
255
|
+
`Host: ${this.ip}'\r\n` +
|
|
256
|
+
`x-sessioncookie: ${this.sessioncookie}\r\n` +
|
|
257
|
+
'Accept: application/x-rtsp-tunnelled\r\n' +
|
|
258
|
+
'Pragma: no-cache\r\n' +
|
|
259
|
+
`Authorization: ${this.getAuthHeader('GET', '/axis-media/media.amp')}\r\n` +
|
|
260
|
+
'Cache-Control: no-cache\r\n\r\n'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getInitializationMessagePost() {
|
|
265
|
+
return (
|
|
266
|
+
'POST /axis-media/media.amp HTTP/1.1\r\n' +
|
|
267
|
+
'CSeq: 1\r\n' +
|
|
268
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
269
|
+
`Host: ${this.ip}\r\n` +
|
|
270
|
+
`x-sessioncookie: ${this.sessioncookie}\r\n` +
|
|
271
|
+
'Content-Type: application/x-rtsp-tunnelled\r\n' +
|
|
272
|
+
'Pragma: no-cache\r\n' +
|
|
273
|
+
'Cache-Control: no-cache\r\n' +
|
|
274
|
+
`Authorization: ${this.getAuthHeader('POST', '/axis-media/media.amp')}\r\n` +
|
|
275
|
+
'Content-Length: 32767\r\n\r\n'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getRtspMessage() {
|
|
280
|
+
this.rtspCSeq++;
|
|
281
|
+
switch (this.state) {
|
|
282
|
+
case 'RTSP_OPTION': {
|
|
283
|
+
return (
|
|
284
|
+
`OPTIONS ${this.rtspPath} RTSP/1.0\r\n` +
|
|
285
|
+
`CSeq: ${this.rtspCSeq}\r\n` +
|
|
286
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
287
|
+
`Authorization: ${this.getAuthHeader('OPTIONS', this.rtspPath)}\r\n\r\n`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
case 'RTSP_DESCRIBE': {
|
|
291
|
+
return (
|
|
292
|
+
`DESCRIBE ${this.rtspPath} RTSP/1.0\r\n` +
|
|
293
|
+
`CSeq: ${this.rtspCSeq}\r\n` +
|
|
294
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
295
|
+
`Authorization: ${this.getAuthHeader('DESCRIBE', this.rtspPath)}\r\n` +
|
|
296
|
+
'Accept: application/sdp\r\n\r\n'
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
case 'RTSP_SETUP': {
|
|
300
|
+
return (
|
|
301
|
+
`SETUP ${this.control} RTSP/1.0\r\n` +
|
|
302
|
+
`CSeq: ${this.rtspCSeq}\r\n` +
|
|
303
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
304
|
+
`Authorization: ${this.getAuthHeader('SETUP', this.rtspPath)}\r\n` +
|
|
305
|
+
'Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n'
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
case 'RTSP_PLAY': {
|
|
309
|
+
return (
|
|
310
|
+
`PLAY ${this.rtspPath} RTSP/1.0\r\n` +
|
|
311
|
+
`CSeq: ${this.rtspCSeq}\r\n` +
|
|
312
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
313
|
+
`Authorization: ${this.getAuthHeader('PLAY', this.rtspPath)}\r\n` +
|
|
314
|
+
`Session: ${this.session}\r\n` +
|
|
315
|
+
'Range: npt=0.000-\r\n\r\n'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
case 'RTSP_GET_PARAMETER': {
|
|
319
|
+
return (
|
|
320
|
+
`GET_PARAMETER ${this.rtspPath} RTSP/1.0\r\n` +
|
|
321
|
+
`CSeq: ${this.rtspCSeq}\r\n` +
|
|
322
|
+
'User-Agent: Camstreamer RTSP Client\r\n' +
|
|
323
|
+
`Authorization: ${this.getAuthHeader('GET_PARAMETER', this.rtspPath)}\r\n` +
|
|
324
|
+
`Session: ${this.session}\r\n\r\n`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
getAuthHeader(method: string, path: string) {
|
|
331
|
+
if (this.authorizationType == 'basic') {
|
|
332
|
+
return `Basic ${Buffer.from(this.auth).toString('base64')}`;
|
|
333
|
+
} else {
|
|
334
|
+
const userPass = this.auth.split(':');
|
|
335
|
+
return Digest.getAuthHeader(userPass[0], userPass[1], method, path, this.wwwAuthenticateHeader);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
sendRtspMessage(message: string) {
|
|
340
|
+
const msgBase64 = Buffer.from(message).toString('base64');
|
|
341
|
+
this.clientPost.write(msgBase64);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {Digest} from "../dist/Digest";
|
|
2
|
+
|
|
3
|
+
import {describe, test, expect} from "@jest/globals";
|
|
4
|
+
|
|
5
|
+
describe("Digest", () => {
|
|
6
|
+
describe("getAuthHeader", () => {
|
|
7
|
+
test('Checks, that Digest.getAuthHeader() returns correct value.', () => {
|
|
8
|
+
const testString = 'Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
|
|
9
|
+
const value = 'Digest username="root",realm="testrealm@host.com",nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",uri="www.cz",response="63f54af3ce5cf193a7435d5c68625472",qop=auth,nc=00000001,cnonce="162d50aa594e9648"';
|
|
10
|
+
expect(Digest.getAuthHeader("root", "pass", "GET", "www.cz", testString)).toBe(value);
|
|
11
|
+
});
|
|
12
|
+
})
|
|
13
|
+
})
|