ring-client-api 10.0.0-beta.2 → 10.0.0-beta.3
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/CHANGELOG.md +7 -0
- package/lib/api/live-call-ring-edge.d.ts +41 -0
- package/lib/api/live-call-ring-edge.js +294 -0
- package/lib/api/live-call.d.ts +2 -6
- package/lib/api/live-call.js +28 -129
- package/lib/api/peer-connection.d.ts +4 -0
- package/lib/api/peer-connection.js +9 -1
- package/lib/api/ring-camera.d.ts +3 -2
- package/lib/api/ring-camera.js +20 -2
- package/lib/api/ring-types.d.ts +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [10.0.0-beta.3](https://github.com/dgreif/ring/compare/v10.0.0-beta.2...v10.0.0-beta.3) (2022-03-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* separate streaming setup for ring edge vs regular cameras ([6281cf9](https://github.com/dgreif/ring/commit/6281cf966de12dcea8acc6516cd9079634734dec))
|
|
11
|
+
|
|
5
12
|
## [10.0.0-beta.2](https://github.com/dgreif/ring/compare/v10.0.0-beta.1...v10.0.0-beta.2) (2022-03-26)
|
|
6
13
|
|
|
7
14
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ReplaySubject } from 'rxjs';
|
|
2
|
+
import { RingCamera } from './ring-camera';
|
|
3
|
+
import { Subscribed } from './subscribed';
|
|
4
|
+
import { RtpPacket } from '@koush/werift';
|
|
5
|
+
declare type SpawnInput = string | number;
|
|
6
|
+
export interface FfmpegOptions {
|
|
7
|
+
input?: SpawnInput[];
|
|
8
|
+
video?: SpawnInput[] | false;
|
|
9
|
+
audio?: SpawnInput[];
|
|
10
|
+
output: SpawnInput[];
|
|
11
|
+
}
|
|
12
|
+
export declare class LiveCallRingEdge extends Subscribed {
|
|
13
|
+
private camera;
|
|
14
|
+
private readonly ws;
|
|
15
|
+
private readonly onWsOpen;
|
|
16
|
+
private readonly onMessage;
|
|
17
|
+
private readonly pc;
|
|
18
|
+
readonly onSessionId: ReplaySubject<string>;
|
|
19
|
+
readonly onCallAnswered: ReplaySubject<string>;
|
|
20
|
+
readonly onCallEnded: ReplaySubject<void>;
|
|
21
|
+
private readonly audioSplitter;
|
|
22
|
+
private readonly videoSplitter;
|
|
23
|
+
readonly onVideoRtp: import("rxjs").Subject<RtpPacket>;
|
|
24
|
+
readonly onAudioRtp: import("rxjs").Subject<RtpPacket>;
|
|
25
|
+
private readonly onOfferSent;
|
|
26
|
+
constructor(authToken: string, camera: RingCamera);
|
|
27
|
+
private initiateCall;
|
|
28
|
+
private sessionId;
|
|
29
|
+
private handleMessage;
|
|
30
|
+
reservePort(bufferPorts?: number): Promise<number>;
|
|
31
|
+
startTranscoding(ffmpegOptions: FfmpegOptions): Promise<void>;
|
|
32
|
+
activated: boolean;
|
|
33
|
+
activate(): Promise<void>;
|
|
34
|
+
activateCameraSpeaker(): Promise<void>;
|
|
35
|
+
private sendSessionMessage;
|
|
36
|
+
private sendMessage;
|
|
37
|
+
private callEnded;
|
|
38
|
+
stop(): void;
|
|
39
|
+
sendAudioPacket(rtp: RtpPacket): void;
|
|
40
|
+
}
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.LiveCallRingEdge = void 0;
|
|
13
|
+
const ws_1 = require("ws");
|
|
14
|
+
const rxjs_1 = require("rxjs");
|
|
15
|
+
const peer_connection_1 = require("./peer-connection");
|
|
16
|
+
const util_1 = require("./util");
|
|
17
|
+
const operators_1 = require("rxjs/operators");
|
|
18
|
+
const subscribed_1 = require("./subscribed");
|
|
19
|
+
const camera_utils_1 = require("@homebridge/camera-utils");
|
|
20
|
+
const ffmpeg_1 = require("./ffmpeg");
|
|
21
|
+
// eslint-disable-next-line no-shadow
|
|
22
|
+
var CloseReasonCode;
|
|
23
|
+
(function (CloseReasonCode) {
|
|
24
|
+
CloseReasonCode[CloseReasonCode["NormalClose"] = 0] = "NormalClose";
|
|
25
|
+
// reason: { code: 5, text: '[rsl-apps/webrtc-liveview-server/Session.cpp:429] [Auth] [0xd540]: [rsl-apps/session-manager/Manager.cpp:227] [AppAuth] Unauthorized: invalid or expired token' }
|
|
26
|
+
// reason: { code: 5, text: 'Authentication failed: -1' }
|
|
27
|
+
// reason: { code: 5, text: 'Sessions with the provided ID not found' }
|
|
28
|
+
CloseReasonCode[CloseReasonCode["AuthenticationFailed"] = 5] = "AuthenticationFailed";
|
|
29
|
+
// reason: { code: 6, text: 'Timeout waiting for ping' }
|
|
30
|
+
CloseReasonCode[CloseReasonCode["Timeout"] = 6] = "Timeout";
|
|
31
|
+
})(CloseReasonCode || (CloseReasonCode = {}));
|
|
32
|
+
function getCleanSdp(sdp, includeVideo) {
|
|
33
|
+
return sdp
|
|
34
|
+
.split('\nm=')
|
|
35
|
+
.slice(1)
|
|
36
|
+
.map((section) => 'm=' + section)
|
|
37
|
+
.filter((section) => includeVideo || !section.startsWith('m=video'))
|
|
38
|
+
.join('\n');
|
|
39
|
+
}
|
|
40
|
+
class LiveCallRingEdge extends subscribed_1.Subscribed {
|
|
41
|
+
constructor(authToken, camera) {
|
|
42
|
+
super();
|
|
43
|
+
this.camera = camera;
|
|
44
|
+
this.onSessionId = new rxjs_1.ReplaySubject(1);
|
|
45
|
+
this.onCallAnswered = new rxjs_1.ReplaySubject(1);
|
|
46
|
+
this.onCallEnded = new rxjs_1.ReplaySubject(1);
|
|
47
|
+
this.audioSplitter = new camera_utils_1.RtpSplitter();
|
|
48
|
+
this.videoSplitter = new camera_utils_1.RtpSplitter();
|
|
49
|
+
this.onOfferSent = new rxjs_1.ReplaySubject(1);
|
|
50
|
+
this.sessionId = null;
|
|
51
|
+
this.activated = false;
|
|
52
|
+
this.pc = new peer_connection_1.PeerConnection();
|
|
53
|
+
this.ws = new ws_1.WebSocket('wss://api.prod.signalling.ring.devices.a2z.com:443/ws', {
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${authToken}`,
|
|
56
|
+
'X-Sig-API-Version': '4.0',
|
|
57
|
+
'X-Sig-Client-ID': 'ring_android-aabb123',
|
|
58
|
+
'X-Sig-Client-Info': 'Ring/3.49.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
|
|
59
|
+
'X-Sig-Auth-Type': 'ring_oauth',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
this.onAudioRtp = this.pc.onAudioRtp;
|
|
63
|
+
this.onVideoRtp = this.pc.onVideoRtp;
|
|
64
|
+
this.onMessage = (0, rxjs_1.fromEvent)(this.ws, 'message');
|
|
65
|
+
this.onWsOpen = (0, rxjs_1.fromEvent)(this.ws, 'open');
|
|
66
|
+
const onError = (0, rxjs_1.fromEvent)(this.ws, 'error'), onClose = (0, rxjs_1.fromEvent)(this.ws, 'close');
|
|
67
|
+
this.addSubscriptions(this.onMessage
|
|
68
|
+
.pipe((0, operators_1.concatMap)((message) => {
|
|
69
|
+
return this.handleMessage(JSON.parse(message.data));
|
|
70
|
+
}))
|
|
71
|
+
.subscribe(), this.onWsOpen.subscribe(() => {
|
|
72
|
+
(0, util_1.logDebug)(`WebSocket connected for ${this.camera.name} (Ring Edge)`);
|
|
73
|
+
this.initiateCall().catch((e) => {
|
|
74
|
+
(0, util_1.logError)(e);
|
|
75
|
+
this.callEnded();
|
|
76
|
+
});
|
|
77
|
+
}), onError.subscribe((e) => {
|
|
78
|
+
(0, util_1.logError)(e);
|
|
79
|
+
this.callEnded();
|
|
80
|
+
}), onClose.subscribe(() => {
|
|
81
|
+
this.callEnded();
|
|
82
|
+
}), this.onCallAnswered
|
|
83
|
+
.pipe((0, operators_1.switchMap)(() => (0, rxjs_1.interval)(5000)))
|
|
84
|
+
.subscribe(() => {
|
|
85
|
+
if (!this.sessionId) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.sendSessionMessage('ping');
|
|
89
|
+
}), this.pc.onIceCandidate.subscribe((iceCandidate) => __awaiter(this, void 0, void 0, function* () {
|
|
90
|
+
yield (0, rxjs_1.firstValueFrom)(this.onOfferSent);
|
|
91
|
+
this.sendMessage({
|
|
92
|
+
method: 'ice',
|
|
93
|
+
body: {
|
|
94
|
+
doorbot_id: this.camera.id,
|
|
95
|
+
ice: iceCandidate.candidate,
|
|
96
|
+
mlineindex: 0,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
// HACK: send ice candidate with both mline indexes to convince ring edge to connect both audio and video
|
|
100
|
+
// Without this, only audio will connect unless you connect from the network of the Ring Edge router
|
|
101
|
+
this.sendMessage({
|
|
102
|
+
method: 'ice',
|
|
103
|
+
body: {
|
|
104
|
+
doorbot_id: this.camera.id,
|
|
105
|
+
ice: iceCandidate.candidate,
|
|
106
|
+
mlineindex: 1,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
})), this.pc.onConnectionState.subscribe((state) => {
|
|
110
|
+
if (state === 'failed') {
|
|
111
|
+
(0, util_1.logError)('Stream connection failed');
|
|
112
|
+
this.callEnded();
|
|
113
|
+
}
|
|
114
|
+
if (state === 'closed') {
|
|
115
|
+
(0, util_1.logDebug)('Stream connection closed');
|
|
116
|
+
this.callEnded();
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
initiateCall() {
|
|
121
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
122
|
+
const offer = yield this.pc.createOffer();
|
|
123
|
+
offer.sdp = offer.sdp.replace('\na=group:BUNDLE 0 1', '');
|
|
124
|
+
this.sendMessage({
|
|
125
|
+
method: 'live_view',
|
|
126
|
+
body: {
|
|
127
|
+
doorbot_id: this.camera.id,
|
|
128
|
+
stream_options: { audio_enabled: true, video_enabled: true },
|
|
129
|
+
sdp: offer.sdp,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
this.onOfferSent.next();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
handleMessage(message) {
|
|
136
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
137
|
+
if (message.body.doorbot_id !== this.camera.id) {
|
|
138
|
+
// ignore messages for other cameras
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (['session_created', 'session_started'].includes(message.method) &&
|
|
142
|
+
'session_id' in message.body &&
|
|
143
|
+
!this.sessionId) {
|
|
144
|
+
this.sessionId = message.body.session_id;
|
|
145
|
+
this.onSessionId.next(this.sessionId);
|
|
146
|
+
}
|
|
147
|
+
if (message.body.session_id && message.body.session_id !== this.sessionId) {
|
|
148
|
+
// ignore messages for other sessions
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
switch (message.method) {
|
|
152
|
+
case 'session_created':
|
|
153
|
+
case 'session_started':
|
|
154
|
+
// session already stored above
|
|
155
|
+
return;
|
|
156
|
+
case 'sdp':
|
|
157
|
+
yield this.pc.acceptAnswer(message.body);
|
|
158
|
+
this.onCallAnswered.next(message.body.sdp);
|
|
159
|
+
return;
|
|
160
|
+
case 'ice':
|
|
161
|
+
yield this.pc.addIceCandidate({
|
|
162
|
+
candidate: message.body.ice,
|
|
163
|
+
sdpMLineIndex: message.body.mlineindex,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
case 'pong':
|
|
167
|
+
return;
|
|
168
|
+
case 'close':
|
|
169
|
+
(0, util_1.logError)('Video stream closed');
|
|
170
|
+
(0, util_1.logError)(message.body);
|
|
171
|
+
this.callEnded();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
(0, util_1.logError)('UNKNOWN MESSAGE');
|
|
175
|
+
(0, util_1.logError)(message);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
reservePort(bufferPorts = 0) {
|
|
179
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
180
|
+
const ports = yield (0, camera_utils_1.reservePorts)({ count: bufferPorts + 1 });
|
|
181
|
+
return ports[0];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
startTranscoding(ffmpegOptions) {
|
|
185
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
186
|
+
const videoPort = yield this.reservePort(1), audioPort = yield this.reservePort(1), transcodeVideoStream = ffmpegOptions.video !== false, ffmpegInputArguments = [
|
|
187
|
+
'-hide_banner',
|
|
188
|
+
'-protocol_whitelist',
|
|
189
|
+
'pipe,udp,rtp,file,crypto',
|
|
190
|
+
'-acodec',
|
|
191
|
+
'libopus',
|
|
192
|
+
'-f',
|
|
193
|
+
'sdp',
|
|
194
|
+
...(ffmpegOptions.input || []),
|
|
195
|
+
'-i',
|
|
196
|
+
'pipe:',
|
|
197
|
+
], ringSdp = yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered), inputSdp = getCleanSdp(ringSdp, transcodeVideoStream)
|
|
198
|
+
.replace(/m=audio \d+/, `m=audio ${audioPort}`)
|
|
199
|
+
.replace(/m=video \d+/, `m=video ${videoPort}`), ff = new camera_utils_1.FfmpegProcess({
|
|
200
|
+
ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
|
|
201
|
+
? ffmpegOptions.video || ['-vcodec', 'copy']
|
|
202
|
+
: []), ...(ffmpegOptions.output || [])),
|
|
203
|
+
ffmpegPath: (0, ffmpeg_1.getFfmpegPath)(),
|
|
204
|
+
exitCallback: () => this.callEnded(),
|
|
205
|
+
logLabel: `From Ring (${this.camera.name})`,
|
|
206
|
+
logger: {
|
|
207
|
+
error: util_1.logError,
|
|
208
|
+
info: util_1.logDebug,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
this.addSubscriptions(this.onAudioRtp
|
|
212
|
+
.pipe((0, operators_1.concatMap)((rtp) => {
|
|
213
|
+
return this.audioSplitter.send(rtp.serialize(), {
|
|
214
|
+
port: audioPort,
|
|
215
|
+
});
|
|
216
|
+
}))
|
|
217
|
+
.subscribe());
|
|
218
|
+
if (transcodeVideoStream) {
|
|
219
|
+
this.addSubscriptions(this.onVideoRtp
|
|
220
|
+
.pipe((0, operators_1.concatMap)((rtp) => {
|
|
221
|
+
return this.videoSplitter.send(rtp.serialize(), {
|
|
222
|
+
port: videoPort,
|
|
223
|
+
});
|
|
224
|
+
}))
|
|
225
|
+
.subscribe());
|
|
226
|
+
}
|
|
227
|
+
this.onCallEnded.subscribe(() => ff.stop());
|
|
228
|
+
ff.writeStdin(inputSdp);
|
|
229
|
+
// Activate the stream now that ffmpeg is ready to receive
|
|
230
|
+
yield this.activate();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
activate() {
|
|
234
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
235
|
+
if (this.activated) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.activated = true;
|
|
239
|
+
yield (0, rxjs_1.firstValueFrom)(this.pc.onConnectionState.pipe((0, operators_1.filter)((state) => state === 'connected')));
|
|
240
|
+
(0, util_1.logInfo)('Activating Session');
|
|
241
|
+
this.sendSessionMessage('activate_session');
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
activateCameraSpeaker() {
|
|
245
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
246
|
+
yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered);
|
|
247
|
+
this.sendSessionMessage('camera_options', {
|
|
248
|
+
stealth_mode: false,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
sendSessionMessage(method, body = {}) {
|
|
253
|
+
const message = {
|
|
254
|
+
method,
|
|
255
|
+
body: Object.assign(Object.assign({}, body), { doorbot_id: this.camera.id, session_id: this.sessionId }),
|
|
256
|
+
};
|
|
257
|
+
if (this.sessionId) {
|
|
258
|
+
// Send immediately if we already have a session id
|
|
259
|
+
// This is needed to send `close` before closing the websocket
|
|
260
|
+
this.sendMessage(message);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
(0, rxjs_1.firstValueFrom)(this.onSessionId)
|
|
264
|
+
.then(() => this.sendMessage(message))
|
|
265
|
+
.catch((e) => (0, util_1.logError)(e));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
sendMessage(message) {
|
|
269
|
+
this.ws.send(JSON.stringify(message));
|
|
270
|
+
}
|
|
271
|
+
callEnded() {
|
|
272
|
+
try {
|
|
273
|
+
this.sendSessionMessage('close', {
|
|
274
|
+
reason: { code: CloseReasonCode.NormalClose, text: '' },
|
|
275
|
+
});
|
|
276
|
+
this.ws.close();
|
|
277
|
+
}
|
|
278
|
+
catch (_) {
|
|
279
|
+
// ignore any errors since we are stopping the call
|
|
280
|
+
}
|
|
281
|
+
this.unsubscribe();
|
|
282
|
+
this.onCallEnded.next();
|
|
283
|
+
this.pc.close();
|
|
284
|
+
this.audioSplitter.close();
|
|
285
|
+
this.videoSplitter.close();
|
|
286
|
+
}
|
|
287
|
+
stop() {
|
|
288
|
+
this.callEnded();
|
|
289
|
+
}
|
|
290
|
+
sendAudioPacket(rtp) {
|
|
291
|
+
this.pc.returnAudioTrack.writeRtp(rtp);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
exports.LiveCallRingEdge = LiveCallRingEdge;
|
package/lib/api/live-call.d.ts
CHANGED
|
@@ -10,29 +10,25 @@ export interface FfmpegOptions {
|
|
|
10
10
|
output: SpawnInput[];
|
|
11
11
|
}
|
|
12
12
|
export declare class LiveCall extends Subscribed {
|
|
13
|
+
private sessionId;
|
|
13
14
|
private camera;
|
|
14
15
|
private readonly ws;
|
|
15
16
|
private readonly onWsOpen;
|
|
16
17
|
private readonly onMessage;
|
|
17
18
|
private readonly pc;
|
|
18
|
-
readonly onSessionId: ReplaySubject<string>;
|
|
19
19
|
readonly onCallAnswered: ReplaySubject<string>;
|
|
20
20
|
readonly onCallEnded: ReplaySubject<void>;
|
|
21
21
|
private readonly audioSplitter;
|
|
22
22
|
private readonly videoSplitter;
|
|
23
23
|
readonly onVideoRtp: import("rxjs").Subject<RtpPacket>;
|
|
24
24
|
readonly onAudioRtp: import("rxjs").Subject<RtpPacket>;
|
|
25
|
-
|
|
26
|
-
constructor(authToken: string, camera: RingCamera);
|
|
27
|
-
private initiateCall;
|
|
28
|
-
private sessionId;
|
|
25
|
+
constructor(sessionId: string, camera: RingCamera);
|
|
29
26
|
private handleMessage;
|
|
30
27
|
reservePort(bufferPorts?: number): Promise<number>;
|
|
31
28
|
startTranscoding(ffmpegOptions: FfmpegOptions): Promise<void>;
|
|
32
29
|
activated: boolean;
|
|
33
30
|
activate(): Promise<void>;
|
|
34
31
|
activateCameraSpeaker(): Promise<void>;
|
|
35
|
-
private sendSessionMessage;
|
|
36
32
|
private sendMessage;
|
|
37
33
|
private callEnded;
|
|
38
34
|
stop(): void;
|
package/lib/api/live-call.js
CHANGED
|
@@ -18,17 +18,6 @@ const operators_1 = require("rxjs/operators");
|
|
|
18
18
|
const subscribed_1 = require("./subscribed");
|
|
19
19
|
const camera_utils_1 = require("@homebridge/camera-utils");
|
|
20
20
|
const ffmpeg_1 = require("./ffmpeg");
|
|
21
|
-
// eslint-disable-next-line no-shadow
|
|
22
|
-
var CloseReasonCode;
|
|
23
|
-
(function (CloseReasonCode) {
|
|
24
|
-
CloseReasonCode[CloseReasonCode["NormalClose"] = 0] = "NormalClose";
|
|
25
|
-
// reason: { code: 5, text: '[rsl-apps/webrtc-liveview-server/Session.cpp:429] [Auth] [0xd540]: [rsl-apps/session-manager/Manager.cpp:227] [AppAuth] Unauthorized: invalid or expired token' }
|
|
26
|
-
// reason: { code: 5, text: 'Authentication failed: -1' }
|
|
27
|
-
// reason: { code: 5, text: 'Sessions with the provided ID not found' }
|
|
28
|
-
CloseReasonCode[CloseReasonCode["AuthenticationFailed"] = 5] = "AuthenticationFailed";
|
|
29
|
-
// reason: { code: 6, text: 'Timeout waiting for ping' }
|
|
30
|
-
CloseReasonCode[CloseReasonCode["Timeout"] = 6] = "Timeout";
|
|
31
|
-
})(CloseReasonCode || (CloseReasonCode = {}));
|
|
32
21
|
function getCleanSdp(sdp, includeVideo) {
|
|
33
22
|
return sdp
|
|
34
23
|
.split('\nm=')
|
|
@@ -37,26 +26,27 @@ function getCleanSdp(sdp, includeVideo) {
|
|
|
37
26
|
.filter((section) => includeVideo || !section.startsWith('m=video'))
|
|
38
27
|
.join('\n');
|
|
39
28
|
}
|
|
29
|
+
function parseLiveCallSession(sessionId) {
|
|
30
|
+
const encodedSession = sessionId.split('.')[1], buff = Buffer.from(encodedSession, 'base64'), text = buff.toString('ascii');
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|
|
40
33
|
class LiveCall extends subscribed_1.Subscribed {
|
|
41
|
-
constructor(
|
|
34
|
+
constructor(sessionId, camera) {
|
|
42
35
|
super();
|
|
36
|
+
this.sessionId = sessionId;
|
|
43
37
|
this.camera = camera;
|
|
44
|
-
this.onSessionId = new rxjs_1.ReplaySubject(1);
|
|
45
38
|
this.onCallAnswered = new rxjs_1.ReplaySubject(1);
|
|
46
39
|
this.onCallEnded = new rxjs_1.ReplaySubject(1);
|
|
47
40
|
this.audioSplitter = new camera_utils_1.RtpSplitter();
|
|
48
41
|
this.videoSplitter = new camera_utils_1.RtpSplitter();
|
|
49
|
-
this.onOfferSent = new rxjs_1.ReplaySubject(1);
|
|
50
|
-
this.sessionId = null;
|
|
51
42
|
this.activated = false;
|
|
43
|
+
const liveCallSession = parseLiveCallSession(sessionId);
|
|
52
44
|
this.pc = new peer_connection_1.PeerConnection();
|
|
53
|
-
this.ws = new ws_1.WebSocket(
|
|
45
|
+
this.ws = new ws_1.WebSocket(`wss://${liveCallSession.rms_fqdn}:${liveCallSession.webrtc_port}/`, {
|
|
54
46
|
headers: {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'X-Sig-Client-Info': 'Ring/3.49.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
|
|
59
|
-
'X-Sig-Auth-Type': 'ring_oauth',
|
|
47
|
+
API_VERSION: '3.1',
|
|
48
|
+
API_TOKEN: sessionId,
|
|
49
|
+
CLIENT_INFO: 'Ring/3.49.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
|
|
60
50
|
},
|
|
61
51
|
});
|
|
62
52
|
this.onAudioRtp = this.pc.onAudioRtp;
|
|
@@ -70,109 +60,28 @@ class LiveCall extends subscribed_1.Subscribed {
|
|
|
70
60
|
}))
|
|
71
61
|
.subscribe(), this.onWsOpen.subscribe(() => {
|
|
72
62
|
(0, util_1.logDebug)(`WebSocket connected for ${this.camera.name}`);
|
|
73
|
-
this.initiateCall().catch((e) => {
|
|
74
|
-
(0, util_1.logError)(e);
|
|
75
|
-
this.callEnded();
|
|
76
|
-
});
|
|
77
63
|
}), onError.subscribe((e) => {
|
|
78
64
|
(0, util_1.logError)(e);
|
|
79
65
|
this.callEnded();
|
|
80
66
|
}), onClose.subscribe(() => {
|
|
81
67
|
this.callEnded();
|
|
82
|
-
}), this.onCallAnswered
|
|
83
|
-
.pipe((0, operators_1.switchMap)(() => (0, rxjs_1.interval)(5000)))
|
|
84
|
-
.subscribe(() => {
|
|
85
|
-
if (!this.sessionId) {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
this.sendSessionMessage('ping');
|
|
89
|
-
}), this.pc.onIceCandidate.subscribe((iceCandidate) => __awaiter(this, void 0, void 0, function* () {
|
|
90
|
-
yield (0, rxjs_1.firstValueFrom)(this.onOfferSent);
|
|
91
|
-
this.sendMessage({
|
|
92
|
-
method: 'ice',
|
|
93
|
-
body: {
|
|
94
|
-
doorbot_id: this.camera.id,
|
|
95
|
-
ice: iceCandidate.candidate,
|
|
96
|
-
mlineindex: 0,
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
// HACK: send ice candidate with both mline indexes to convince ring edge to connect both audio and video
|
|
100
|
-
// Without this, only audio will connect unless you connect from the network of the Ring Edge router
|
|
101
|
-
this.sendMessage({
|
|
102
|
-
method: 'ice',
|
|
103
|
-
body: {
|
|
104
|
-
doorbot_id: this.camera.id,
|
|
105
|
-
ice: iceCandidate.candidate,
|
|
106
|
-
mlineindex: 1,
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
})), this.pc.onConnectionState.subscribe((state) => {
|
|
110
|
-
if (state === 'failed') {
|
|
111
|
-
(0, util_1.logError)('Stream connection failed');
|
|
112
|
-
this.callEnded();
|
|
113
|
-
}
|
|
114
|
-
if (state === 'closed') {
|
|
115
|
-
(0, util_1.logDebug)('Stream connection closed');
|
|
116
|
-
this.callEnded();
|
|
117
|
-
}
|
|
118
68
|
}));
|
|
119
69
|
}
|
|
120
|
-
initiateCall() {
|
|
121
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
122
|
-
const offer = yield this.pc.createOffer();
|
|
123
|
-
offer.sdp = offer.sdp.replace('\na=group:BUNDLE 0 1', '');
|
|
124
|
-
this.sendMessage({
|
|
125
|
-
method: 'live_view',
|
|
126
|
-
body: {
|
|
127
|
-
doorbot_id: this.camera.id,
|
|
128
|
-
stream_options: { audio_enabled: true, video_enabled: true },
|
|
129
|
-
sdp: offer.sdp,
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
this.onOfferSent.next();
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
70
|
handleMessage(message) {
|
|
136
71
|
return __awaiter(this, void 0, void 0, function* () {
|
|
137
|
-
if (message.body.doorbot_id !== this.camera.id) {
|
|
138
|
-
// ignore messages for other cameras
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (['session_created', 'session_started'].includes(message.method) &&
|
|
142
|
-
'session_id' in message.body &&
|
|
143
|
-
!this.sessionId) {
|
|
144
|
-
this.sessionId = message.body.session_id;
|
|
145
|
-
this.onSessionId.next(this.sessionId);
|
|
146
|
-
}
|
|
147
|
-
if (message.body.session_id && message.body.session_id !== this.sessionId) {
|
|
148
|
-
// ignore messages for other sessions
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
72
|
switch (message.method) {
|
|
152
|
-
case 'session_created':
|
|
153
|
-
case 'session_started':
|
|
154
|
-
// session already stored above
|
|
155
|
-
return;
|
|
156
73
|
case 'sdp':
|
|
157
|
-
yield this.pc.
|
|
158
|
-
this.
|
|
74
|
+
const answer = yield this.pc.createAnswer(message);
|
|
75
|
+
this.sendMessage(Object.assign({ method: 'sdp' }, answer));
|
|
76
|
+
this.onCallAnswered.next(message.sdp);
|
|
159
77
|
return;
|
|
160
78
|
case 'ice':
|
|
161
79
|
yield this.pc.addIceCandidate({
|
|
162
|
-
candidate: message.
|
|
163
|
-
sdpMLineIndex: message.
|
|
80
|
+
candidate: message.ice,
|
|
81
|
+
sdpMLineIndex: message.mlineindex,
|
|
164
82
|
});
|
|
165
83
|
return;
|
|
166
|
-
case 'pong':
|
|
167
|
-
return;
|
|
168
|
-
case 'close':
|
|
169
|
-
(0, util_1.logError)('Video stream closed');
|
|
170
|
-
(0, util_1.logError)(message.body);
|
|
171
|
-
this.callEnded();
|
|
172
|
-
return;
|
|
173
84
|
}
|
|
174
|
-
(0, util_1.logError)('UNKNOWN MESSAGE');
|
|
175
|
-
(0, util_1.logError)(message);
|
|
176
85
|
});
|
|
177
86
|
}
|
|
178
87
|
reservePort(bufferPorts = 0) {
|
|
@@ -236,42 +145,32 @@ class LiveCall extends subscribed_1.Subscribed {
|
|
|
236
145
|
return;
|
|
237
146
|
}
|
|
238
147
|
this.activated = true;
|
|
239
|
-
yield (0, rxjs_1.firstValueFrom)(this.
|
|
240
|
-
(
|
|
241
|
-
this.
|
|
148
|
+
yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered);
|
|
149
|
+
this.sendMessage({ method: 'activate_session' });
|
|
150
|
+
this.sendMessage({
|
|
151
|
+
video_enabled: true,
|
|
152
|
+
audio_enabled: true,
|
|
153
|
+
method: 'stream_options',
|
|
154
|
+
});
|
|
242
155
|
});
|
|
243
156
|
}
|
|
244
157
|
activateCameraSpeaker() {
|
|
245
158
|
return __awaiter(this, void 0, void 0, function* () {
|
|
246
159
|
yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered);
|
|
247
|
-
this.
|
|
160
|
+
this.sendMessage({
|
|
248
161
|
stealth_mode: false,
|
|
162
|
+
method: 'camera_options',
|
|
249
163
|
});
|
|
250
164
|
});
|
|
251
165
|
}
|
|
252
|
-
sendSessionMessage(method, body = {}) {
|
|
253
|
-
const message = {
|
|
254
|
-
method,
|
|
255
|
-
body: Object.assign(Object.assign({}, body), { doorbot_id: this.camera.id, session_id: this.sessionId }),
|
|
256
|
-
};
|
|
257
|
-
if (this.sessionId) {
|
|
258
|
-
// Send immediately if we already have a session id
|
|
259
|
-
// This is needed to send `close` before closing the websocket
|
|
260
|
-
this.sendMessage(message);
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
(0, rxjs_1.firstValueFrom)(this.onSessionId)
|
|
264
|
-
.then(() => this.sendMessage(message))
|
|
265
|
-
.catch((e) => (0, util_1.logError)(e));
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
166
|
sendMessage(message) {
|
|
269
167
|
this.ws.send(JSON.stringify(message));
|
|
270
168
|
}
|
|
271
169
|
callEnded() {
|
|
272
170
|
try {
|
|
273
|
-
this.
|
|
274
|
-
reason: { code:
|
|
171
|
+
this.sendMessage({
|
|
172
|
+
reason: { code: 0, text: '' },
|
|
173
|
+
method: 'close',
|
|
275
174
|
});
|
|
276
175
|
this.ws.close();
|
|
277
176
|
}
|
|
@@ -11,6 +11,10 @@ export declare class PeerConnection {
|
|
|
11
11
|
returnAudioTrack: MediaStreamTrack;
|
|
12
12
|
constructor();
|
|
13
13
|
createOffer(): Promise<import("@koush/werift").RTCSessionDescription>;
|
|
14
|
+
createAnswer(offer: {
|
|
15
|
+
type: 'offer';
|
|
16
|
+
sdp: string;
|
|
17
|
+
}): Promise<import("@koush/werift").RTCSessionDescription>;
|
|
14
18
|
acceptAnswer(answer: {
|
|
15
19
|
type: 'answer';
|
|
16
20
|
sdp: string;
|
|
@@ -43,7 +43,7 @@ class PeerConnection {
|
|
|
43
43
|
{ type: 'nack', parameter: 'pli' },
|
|
44
44
|
{ type: 'goog-remb' },
|
|
45
45
|
],
|
|
46
|
-
parameters: 'profile-level-id=42801F',
|
|
46
|
+
parameters: 'packetization-mode=1;profile-level-id=42801F;level-asymmetry-allowed=1',
|
|
47
47
|
}),
|
|
48
48
|
],
|
|
49
49
|
},
|
|
@@ -100,6 +100,14 @@ class PeerConnection {
|
|
|
100
100
|
return offer;
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
+
createAnswer(offer) {
|
|
104
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
105
|
+
yield this.pc.setRemoteDescription(offer);
|
|
106
|
+
const answer = yield this.pc.createAnswer();
|
|
107
|
+
yield this.pc.setLocalDescription(answer);
|
|
108
|
+
return answer;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
103
111
|
acceptAnswer(answer) {
|
|
104
112
|
return __awaiter(this, void 0, void 0, function* () {
|
|
105
113
|
yield this.pc.setRemoteDescription(answer);
|
package/lib/api/ring-camera.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { BehaviorSubject, Subject } from 'rxjs';
|
|
|
5
5
|
import { DeepPartial } from './util';
|
|
6
6
|
import { Subscribed } from './subscribed';
|
|
7
7
|
import { FfmpegOptions, LiveCall } from './live-call';
|
|
8
|
+
import { LiveCallRingEdge } from './live-call-ring-edge';
|
|
8
9
|
export declare function getBatteryLevel(data: Pick<CameraData, 'battery_life' | 'battery_life_2'>): number | null;
|
|
9
10
|
export declare function getSearchQueryString(options: CameraEventOptions | (HistoryOptions & {
|
|
10
11
|
accountId: string;
|
|
@@ -53,7 +54,7 @@ export declare class RingCamera extends Subscribed {
|
|
|
53
54
|
getDeviceSettings(): Promise<CameraDeviceSettingsData & import("./rest-client").ExtendedResponse>;
|
|
54
55
|
setInHomeDoorbell(enable: boolean): Promise<boolean>;
|
|
55
56
|
getHealth(): Promise<CameraHealth>;
|
|
56
|
-
startLiveCall(): Promise<LiveCall>;
|
|
57
|
+
startLiveCall(): Promise<LiveCall | LiveCallRingEdge>;
|
|
57
58
|
private removeDingById;
|
|
58
59
|
processActiveDing(ding: ActiveDing): void;
|
|
59
60
|
getEvents(options?: CameraEventOptions): Promise<CameraEventResponse & import("./rest-client").ExtendedResponse>;
|
|
@@ -87,7 +88,7 @@ export declare class RingCamera extends Subscribed {
|
|
|
87
88
|
force?: boolean;
|
|
88
89
|
}): Promise<Buffer & import("./rest-client").ExtendedResponse>;
|
|
89
90
|
recordToFile(outputPath: string, duration?: number): Promise<void>;
|
|
90
|
-
streamVideo(ffmpegOptions: FfmpegOptions): Promise<LiveCall>;
|
|
91
|
+
streamVideo(ffmpegOptions: FfmpegOptions): Promise<LiveCall | LiveCallRingEdge>;
|
|
91
92
|
subscribeToDingEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
|
|
92
93
|
unsubscribeFromDingEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
|
|
93
94
|
subscribeToMotionEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
|
package/lib/api/ring-camera.js
CHANGED
|
@@ -17,6 +17,7 @@ const operators_1 = require("rxjs/operators");
|
|
|
17
17
|
const util_1 = require("./util");
|
|
18
18
|
const subscribed_1 = require("./subscribed");
|
|
19
19
|
const live_call_1 = require("./live-call");
|
|
20
|
+
const live_call_ring_edge_1 = require("./live-call-ring-edge");
|
|
20
21
|
const maxSnapshotRefreshSeconds = 15, fullDayMs = 24 * 60 * 60 * 1000;
|
|
21
22
|
function parseBatteryLife(batteryLife) {
|
|
22
23
|
if (batteryLife === null || batteryLife === undefined) {
|
|
@@ -234,8 +235,25 @@ class RingCamera extends subscribed_1.Subscribed {
|
|
|
234
235
|
}
|
|
235
236
|
startLiveCall() {
|
|
236
237
|
return __awaiter(this, void 0, void 0, function* () {
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
if (this.isRingEdgeEnabled) {
|
|
239
|
+
const auth = yield this.restClient.getCurrentAuth();
|
|
240
|
+
return new live_call_ring_edge_1.LiveCallRingEdge(auth.access_token, this);
|
|
241
|
+
}
|
|
242
|
+
const liveCall = yield this.restClient
|
|
243
|
+
.request({
|
|
244
|
+
method: 'POST',
|
|
245
|
+
url: this.doorbotUrl('live_call'),
|
|
246
|
+
})
|
|
247
|
+
.catch((e) => {
|
|
248
|
+
var _a;
|
|
249
|
+
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 403) {
|
|
250
|
+
const errorMessage = `Camera ${this.name} returned 403 when starting a live stream. This usually indicates that live streaming is blocked by Modes settings. Check your Ring app and verify that you are able to stream from this camera with the current Modes settings.`;
|
|
251
|
+
(0, util_1.logError)(errorMessage);
|
|
252
|
+
throw new Error(errorMessage);
|
|
253
|
+
}
|
|
254
|
+
throw e;
|
|
255
|
+
});
|
|
256
|
+
return new live_call_1.LiveCall(liveCall.data.session_id, this);
|
|
239
257
|
});
|
|
240
258
|
}
|
|
241
259
|
removeDingById(idToRemove) {
|
package/lib/api/ring-types.d.ts
CHANGED
|
@@ -696,6 +696,21 @@ export interface ActiveDing {
|
|
|
696
696
|
sip_token: string;
|
|
697
697
|
sip_ding_id: string;
|
|
698
698
|
}
|
|
699
|
+
export interface LiveCallResponse {
|
|
700
|
+
ding_id: string;
|
|
701
|
+
ding_kind: DingKind;
|
|
702
|
+
device_id: number;
|
|
703
|
+
device_kind: RingCameraKind;
|
|
704
|
+
device_description: string;
|
|
705
|
+
state: DingState;
|
|
706
|
+
protocol: 'ring_media_server' | unknown;
|
|
707
|
+
now: number;
|
|
708
|
+
video_jitter_buffer_ms: number;
|
|
709
|
+
audio_jitter_buffer_ms: number;
|
|
710
|
+
data: {
|
|
711
|
+
session_id: string;
|
|
712
|
+
};
|
|
713
|
+
}
|
|
699
714
|
export interface AuthTokenResponse {
|
|
700
715
|
access_token: string;
|
|
701
716
|
expires_in: number;
|