homebridge-ring 10.0.0-beta.0 → 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 CHANGED
@@ -2,6 +2,37 @@
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
+
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)
13
+
14
+
15
+ ### Features
16
+
17
+ * support ring edge camera streaming ([b30d99f](https://github.com/dgreif/ring/commit/b30d99f9ffaa1fabc1d09b830cf8ba0e69fe84a0))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **api:** allow snapshot to be force-updated based on options ([fa0212a](https://github.com/dgreif/ring/commit/fa0212ab539cc268f8000c5b9ea39a00799cb8d7))
23
+
24
+ ## [10.0.0-beta.1](https://github.com/dgreif/ring/compare/v10.0.0-beta.0...v10.0.0-beta.1) (2022-02-27)
25
+
26
+
27
+ ### ⚠ BREAKING CHANGES
28
+
29
+ * **homebridge:** For anyone who updated to the 10.0 beta and had their cameras reset in HomeKit, this change will cause another reset. This moves the audio codecs back to what they were before the 10.0 beta, which means anyone upgrading directly from v9 will _not_ experience a reset of the cameras. Using native Opus was an experimental feature, which ultimately lead to a degraded user experience.
30
+
31
+ ### Bug Fixes
32
+
33
+ * allow video transcoding to be skipped ([1ef8cdd](https://github.com/dgreif/ring/commit/1ef8cdd54d1f118a8fdcfd28f6970d9c8b7c65f9))
34
+ * **homebridge:** switch back to aac-eld from opus ([7dcd2a0](https://github.com/dgreif/ring/commit/7dcd2a087da7c1b24cc9bdd3c3919af5e6fb7997))
35
+
5
36
  ## [10.0.0-beta.0](https://github.com/dgreif/ring/compare/v9.25.0-beta.1...v10.0.0-beta.0) (2022-02-18)
6
37
 
7
38
 
@@ -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;
@@ -10,7 +10,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.LiveCall = void 0;
13
- const rtp_utils_1 = require("./rtp-utils");
14
13
  const ws_1 = require("ws");
15
14
  const rxjs_1 = require("rxjs");
16
15
  const peer_connection_1 = require("./peer-connection");
@@ -19,6 +18,18 @@ const operators_1 = require("rxjs/operators");
19
18
  const subscribed_1 = require("./subscribed");
20
19
  const camera_utils_1 = require("@homebridge/camera-utils");
21
20
  const ffmpeg_1 = require("./ffmpeg");
21
+ function getCleanSdp(sdp, includeVideo) {
22
+ return sdp
23
+ .split('\nm=')
24
+ .slice(1)
25
+ .map((section) => 'm=' + section)
26
+ .filter((section) => includeVideo || !section.startsWith('m=video'))
27
+ .join('\n');
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
+ }
22
33
  class LiveCall extends subscribed_1.Subscribed {
23
34
  constructor(sessionId, camera) {
24
35
  super();
@@ -29,13 +40,13 @@ class LiveCall extends subscribed_1.Subscribed {
29
40
  this.audioSplitter = new camera_utils_1.RtpSplitter();
30
41
  this.videoSplitter = new camera_utils_1.RtpSplitter();
31
42
  this.activated = false;
32
- const liveCallSession = (0, rtp_utils_1.parseLiveCallSession)(sessionId);
43
+ const liveCallSession = parseLiveCallSession(sessionId);
33
44
  this.pc = new peer_connection_1.PeerConnection();
34
45
  this.ws = new ws_1.WebSocket(`wss://${liveCallSession.rms_fqdn}:${liveCallSession.webrtc_port}/`, {
35
46
  headers: {
36
47
  API_VERSION: '3.1',
37
48
  API_TOKEN: sessionId,
38
- CLIENT_INFO: 'Ring/3.48.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
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',
39
50
  },
40
51
  });
41
52
  this.onAudioRtp = this.pc.onAudioRtp;
@@ -92,7 +103,7 @@ class LiveCall extends subscribed_1.Subscribed {
92
103
  ...(ffmpegOptions.input || []),
93
104
  '-i',
94
105
  'pipe:',
95
- ], inputSdp = (yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered))
106
+ ], ringSdp = yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered), inputSdp = getCleanSdp(ringSdp, transcodeVideoStream)
96
107
  .replace(/m=audio \d+/, `m=audio ${audioPort}`)
97
108
  .replace(/m=video \d+/, `m=video ${videoPort}`), ff = new camera_utils_1.FfmpegProcess({
98
109
  ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
@@ -1,17 +1,24 @@
1
1
  import { MediaStreamTrack, RTCIceCandidate, RTCPeerConnection, RtcpPacket, RtpPacket } from '@koush/werift';
2
- import { Subject } from 'rxjs';
2
+ import { ReplaySubject, Subject } from 'rxjs';
3
3
  export declare class PeerConnection {
4
4
  pc: RTCPeerConnection;
5
5
  onAudioRtp: Subject<RtpPacket>;
6
6
  onAudioRtcp: Subject<RtcpPacket>;
7
7
  onVideoRtp: Subject<RtpPacket>;
8
8
  onVideoRtcp: Subject<RtcpPacket>;
9
+ onIceCandidate: Subject<RTCIceCandidate>;
10
+ onConnectionState: ReplaySubject<"failed" | "closed" | "connected" | "connecting" | "new" | "disconnected">;
9
11
  returnAudioTrack: MediaStreamTrack;
10
12
  constructor();
13
+ createOffer(): Promise<import("@koush/werift").RTCSessionDescription>;
11
14
  createAnswer(offer: {
12
15
  type: 'offer';
13
16
  sdp: string;
14
17
  }): Promise<import("@koush/werift").RTCSessionDescription>;
18
+ acceptAnswer(answer: {
19
+ type: 'answer';
20
+ sdp: string;
21
+ }): Promise<void>;
15
22
  addIceCandidate(candidate: RTCIceCandidate): Promise<void>;
16
23
  close(): void;
17
24
  }
@@ -20,6 +20,8 @@ class PeerConnection {
20
20
  this.onAudioRtcp = new rxjs_1.Subject();
21
21
  this.onVideoRtp = new rxjs_1.Subject();
22
22
  this.onVideoRtcp = new rxjs_1.Subject();
23
+ this.onIceCandidate = new rxjs_1.Subject();
24
+ this.onConnectionState = new rxjs_1.ReplaySubject(1);
23
25
  this.returnAudioTrack = new werift_1.MediaStreamTrack({ kind: 'audio' });
24
26
  const pc = (this.pc = new werift_1.RTCPeerConnection({
25
27
  codecs: {
@@ -41,7 +43,7 @@ class PeerConnection {
41
43
  { type: 'nack', parameter: 'pli' },
42
44
  { type: 'goog-remb' },
43
45
  ],
44
- parameters: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
46
+ parameters: 'packetization-mode=1;profile-level-id=42801F;level-asymmetry-allowed=1',
45
47
  }),
46
48
  ],
47
49
  },
@@ -77,6 +79,26 @@ class PeerConnection {
77
79
  setInterval(() => videoTransceiver.receiver.sendRtcpPLI(track.ssrc), 2000);
78
80
  });
79
81
  });
82
+ this.pc.onIceCandidate.subscribe((iceCandidate) => {
83
+ this.onIceCandidate.next(iceCandidate);
84
+ });
85
+ pc.iceConnectionStateChange.subscribe(() => {
86
+ (0, util_1.logInfo)(`iceConnectionStateChange: ${pc.iceConnectionState}`);
87
+ if (pc.iceConnectionState === 'closed') {
88
+ this.onConnectionState.next('closed');
89
+ }
90
+ });
91
+ pc.connectionStateChange.subscribe(() => {
92
+ (0, util_1.logInfo)(`connectionStateChange: ${pc.connectionState}`);
93
+ this.onConnectionState.next(pc.connectionState);
94
+ });
95
+ }
96
+ createOffer() {
97
+ return __awaiter(this, void 0, void 0, function* () {
98
+ const offer = yield this.pc.createOffer();
99
+ yield this.pc.setLocalDescription(offer);
100
+ return offer;
101
+ });
80
102
  }
81
103
  createAnswer(offer) {
82
104
  return __awaiter(this, void 0, void 0, function* () {
@@ -86,6 +108,11 @@ class PeerConnection {
86
108
  return answer;
87
109
  });
88
110
  }
111
+ acceptAnswer(answer) {
112
+ return __awaiter(this, void 0, void 0, function* () {
113
+ yield this.pc.setRemoteDescription(answer);
114
+ });
115
+ }
89
116
  addIceCandidate(candidate) {
90
117
  return this.pc.addIceCandidate(candidate);
91
118
  }
@@ -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;
@@ -42,6 +43,7 @@ export declare class RingCamera extends Subscribed {
42
43
  get isCharging(): boolean;
43
44
  get operatingOnBattery(): boolean;
44
45
  get isOffline(): boolean;
46
+ get isRingEdgeEnabled(): boolean;
45
47
  get hasInHomeDoorbell(): boolean;
46
48
  doorbotUrl(path?: string): string;
47
49
  deviceUrl(path?: string): string;
@@ -52,7 +54,7 @@ export declare class RingCamera extends Subscribed {
52
54
  getDeviceSettings(): Promise<CameraDeviceSettingsData & import("./rest-client").ExtendedResponse>;
53
55
  setInHomeDoorbell(enable: boolean): Promise<boolean>;
54
56
  getHealth(): Promise<CameraHealth>;
55
- startLiveCall(): Promise<LiveCall>;
57
+ startLiveCall(): Promise<LiveCall | LiveCallRingEdge>;
56
58
  private removeDingById;
57
59
  processActiveDing(ding: ActiveDing): void;
58
60
  getEvents(options?: CameraEventOptions): Promise<CameraEventResponse & import("./rest-client").ExtendedResponse>;
@@ -86,7 +88,7 @@ export declare class RingCamera extends Subscribed {
86
88
  force?: boolean;
87
89
  }): Promise<Buffer & import("./rest-client").ExtendedResponse>;
88
90
  recordToFile(outputPath: string, duration?: number): Promise<void>;
89
- streamVideo(ffmpegOptions: FfmpegOptions): Promise<LiveCall>;
91
+ streamVideo(ffmpegOptions: FfmpegOptions): Promise<LiveCall | LiveCallRingEdge>;
90
92
  subscribeToDingEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
91
93
  unsubscribeFromDingEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
92
94
  subscribeToMotionEvents(): Promise<void & import("./rest-client").ExtendedResponse>;
@@ -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) {
@@ -145,6 +146,9 @@ class RingCamera extends subscribed_1.Subscribed {
145
146
  get isOffline() {
146
147
  return this.data.alerts.connection === 'offline';
147
148
  }
149
+ get isRingEdgeEnabled() {
150
+ return this.data.settings.sheila_settings.local_storage_enabled === true;
151
+ }
148
152
  get hasInHomeDoorbell() {
149
153
  const { chime_settings } = this.data.settings;
150
154
  return (this.isDoorbot &&
@@ -231,6 +235,10 @@ class RingCamera extends subscribed_1.Subscribed {
231
235
  }
232
236
  startLiveCall() {
233
237
  return __awaiter(this, void 0, void 0, function* () {
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
+ }
234
242
  const liveCall = yield this.restClient
235
243
  .request({
236
244
  method: 'POST',
@@ -362,7 +370,7 @@ class RingCamera extends subscribed_1.Subscribed {
362
370
  getNextSnapshot({ afterMs, maxWaitMs, force, }) {
363
371
  return __awaiter(this, void 0, void 0, function* () {
364
372
  const response = yield this.restClient.request({
365
- url: `https://app-snaps.ring.com/snapshots/next/${this.id}?extras=force`,
373
+ url: `https://app-snaps.ring.com/snapshots/next/${this.id}`,
366
374
  responseType: 'buffer',
367
375
  searchParams: {
368
376
  'after-ms': afterMs,
@@ -379,6 +379,13 @@ export interface CameraData {
379
379
  live_view_disabled?: boolean;
380
380
  motion_detection_enabled?: boolean;
381
381
  power_mode?: 'battery' | 'wired';
382
+ sheila_settings: {
383
+ cv_processing_enabled: boolean | null;
384
+ local_storage_enabled: boolean | null;
385
+ };
386
+ server_settings: {
387
+ ring_media_server_enabled: boolean;
388
+ };
382
389
  };
383
390
  features: {
384
391
  motions_enabled: boolean;
@@ -17,6 +17,7 @@ const operators_1 = require("rxjs/operators");
17
17
  const rxjs_1 = require("rxjs");
18
18
  const fs_1 = require("fs");
19
19
  const util_2 = require("util");
20
+ const ffmpeg_1 = require("../api/ffmpeg");
20
21
  const werift_1 = require("@koush/werift");
21
22
  const readFileAsync = (0, util_2.promisify)(fs_1.readFile), cameraOfflinePath = require.resolve('../../media/camera-offline.jpg'), snapshotsBlockedPath = require.resolve('../../media/snapshots-blocked.jpg');
22
23
  function getDurationSeconds(start) {
@@ -45,13 +46,18 @@ class StreamingSession {
45
46
  this.videoSrtp = (0, camera_utils_1.generateSrtpOptions)();
46
47
  this.audioSplitter = new camera_utils_1.RtpSplitter();
47
48
  this.videoSplitter = new camera_utils_1.RtpSplitter();
48
- this.cameraSpeakerActive = false;
49
- const { targetAddress, audio: { srtp_key: remoteAudioSrtpKey, srtp_salt: remoteAudioSrtpSalt }, video: { port: videoPort }, } = prepareStreamRequest,
50
- // used to decrypt return audio from HomeKit
51
- returnAudioSrtpSession = new werift_1.SrtpSession(getSessionConfig({
52
- srtpKey: remoteAudioSrtpKey,
53
- srtpSalt: remoteAudioSrtpSalt,
54
- })),
49
+ this.libfdkAacInstalledPromise = (0, camera_utils_1.doesFfmpegSupportCodec)('libfdk_aac', (0, ffmpeg_1.getFfmpegPath)())
50
+ .then((supported) => {
51
+ if (!supported) {
52
+ (0, util_1.logError)('Streaming video only - found ffmpeg, but libfdk_aac is not installed. See https://github.com/dgreif/ring/wiki/FFmpeg for details.');
53
+ }
54
+ return supported;
55
+ })
56
+ .catch(() => {
57
+ (0, util_1.logError)('Streaming video only - ffmpeg was not found. See https://github.com/dgreif/ring/wiki/FFmpeg for details.');
58
+ return false;
59
+ });
60
+ const { targetAddress, video: { port: videoPort }, } = prepareStreamRequest,
55
61
  // used to encrypt rtcp to HomeKit for keepalive
56
62
  videoSrtcpSession = new werift_1.SrtcpSession(getSessionConfig(this.videoSrtp)), onReturnPacketReceived = new rxjs_1.Subject();
57
63
  // Watch return packets to detect a dead stream from the HomeKit side
@@ -61,21 +67,9 @@ class StreamingSession {
61
67
  onReturnPacketReceived.next(null);
62
68
  return null;
63
69
  });
64
- this.audioSplitter.addMessageHandler(({ message, isRtpMessage }) => {
65
- if (!isRtpMessage) {
66
- return null;
67
- }
70
+ this.audioSplitter.addMessageHandler(() => {
68
71
  // return packet from HomeKit
69
72
  onReturnPacketReceived.next(null);
70
- // Make sure the external speaker on the camera is active since we will be sending packets to it
71
- if (!this.cameraSpeakerActive) {
72
- this.cameraSpeakerActive = true;
73
- this.liveCall.activateCameraSpeaker().catch(util_1.logError);
74
- }
75
- // decrypt the packet
76
- const decryptedMessage = returnAudioSrtpSession.decrypt(message), rtp = werift_1.RtpPacket.deSerialize(decryptedMessage);
77
- // send to Ring - werift will handle encryption and other header params
78
- this.liveCall.sendAudioPacket(rtp);
79
73
  return null;
80
74
  });
81
75
  liveCall.addSubscriptions((0, rxjs_1.merge)((0, rxjs_1.of)(true).pipe((0, operators_1.delay)(15000)), onReturnPacketReceived)
@@ -107,27 +101,9 @@ class StreamingSession {
107
101
  activate(request) {
108
102
  return __awaiter(this, void 0, void 0, function* () {
109
103
  let sentVideo = false;
110
- const { targetAddress, audio: { port: audioPort }, video: { port: videoPort }, } = this.prepareStreamRequest,
104
+ const { targetAddress, audio: { port: audioPort, srtp_key: remoteAudioSrtpKey, srtp_salt: remoteAudioSrtpSalt, }, video: { port: videoPort }, } = this.prepareStreamRequest,
111
105
  // use to encrypt Ring video to HomeKit
112
- audioSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.audioSrtp)), videoSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.videoSrtp));
113
- let firstTimestamp = 0, packetCount = 0;
114
- // Set up packet forwarding for audio stream
115
- this.liveCall.addSubscriptions(this.liveCall.onAudioRtp.subscribe(({ header, payload }) => {
116
- if (!firstTimestamp) {
117
- firstTimestamp = header.timestamp;
118
- }
119
- header.ssrc = this.audioSsrc;
120
- header.payloadType = request.audio.pt;
121
- header.timestamp = firstTimestamp + packetCount * 3 * 160;
122
- packetCount++;
123
- const encryptedPacket = audioSrtpSession.encrypt(payload, header);
124
- this.audioSplitter
125
- .send(encryptedPacket, {
126
- port: audioPort,
127
- address: targetAddress,
128
- })
129
- .catch(util_1.logError);
130
- }));
106
+ videoSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.videoSrtp));
131
107
  // Set up packet forwarding for video stream
132
108
  this.liveCall.addSubscriptions(this.liveCall.onVideoRtp.subscribe(({ header, payload }) => {
133
109
  header.ssrc = this.videoSsrc;
@@ -144,7 +120,109 @@ class StreamingSession {
144
120
  })
145
121
  .catch(util_1.logError);
146
122
  }));
147
- yield this.liveCall.activate();
123
+ const shouldTranscodeAudio = yield this.libfdkAacInstalledPromise;
124
+ if (!shouldTranscodeAudio) {
125
+ return this.liveCall.activate();
126
+ }
127
+ const transcodingPromise = this.liveCall.startTranscoding({
128
+ input: ['-vn'],
129
+ audio: [
130
+ '-map',
131
+ '0:a',
132
+ // OPUS specific - it works, but audio is very choppy
133
+ // '-acodec',
134
+ // 'libopus',
135
+ // '-vbr',
136
+ // 'on',
137
+ // '-frame_duration',
138
+ // 20,
139
+ // '-application',
140
+ // 'lowdelay',
141
+ // AAC-eld specific
142
+ '-acodec',
143
+ 'libfdk_aac',
144
+ '-profile:a',
145
+ 'aac_eld',
146
+ // Shared options
147
+ '-flags',
148
+ '+global_header',
149
+ '-ac',
150
+ `${request.audio.channel}`,
151
+ '-ar',
152
+ `${request.audio.sample_rate}k`,
153
+ '-b:a',
154
+ `${request.audio.max_bit_rate}k`,
155
+ '-bufsize',
156
+ `${request.audio.max_bit_rate * 4}k`,
157
+ '-payload_type',
158
+ request.audio.pt,
159
+ '-ssrc',
160
+ this.audioSsrc,
161
+ '-f',
162
+ 'rtp',
163
+ '-srtp_out_suite',
164
+ 'AES_CM_128_HMAC_SHA1_80',
165
+ '-srtp_out_params',
166
+ (0, camera_utils_1.encodeSrtpOptions)(this.audioSrtp),
167
+ `srtp://${targetAddress}:${audioPort}?pkt_size=188`,
168
+ ],
169
+ video: false,
170
+ output: [],
171
+ });
172
+ let cameraSpeakerActive = false;
173
+ // used to decrypt return audio from HomeKit to Ring
174
+ const remoteAudioSrtpOptions = {
175
+ srtpKey: remoteAudioSrtpKey,
176
+ srtpSalt: remoteAudioSrtpSalt,
177
+ }, audioSrtpSession = new werift_1.SrtpSession(getSessionConfig(remoteAudioSrtpOptions)), returnAudioTranscodedSplitter = new camera_utils_1.RtpSplitter(({ message }) => {
178
+ if (!cameraSpeakerActive) {
179
+ cameraSpeakerActive = true;
180
+ this.liveCall.activateCameraSpeaker().catch(util_1.logError);
181
+ }
182
+ // decrypt the message
183
+ try {
184
+ const rtp = werift_1.RtpPacket.deSerialize(message);
185
+ rtp.payload = audioSrtpSession.decrypt(rtp.payload);
186
+ // send to Ring - werift will handle encryption and other header params
187
+ this.liveCall.sendAudioPacket(rtp);
188
+ }
189
+ catch (_) {
190
+ // deSerialize will sometimes fail, but the errors can be ignored
191
+ }
192
+ return null;
193
+ }), returnAudioTranscoder = new camera_utils_1.ReturnAudioTranscoder({
194
+ prepareStreamRequest: this.prepareStreamRequest,
195
+ incomingAudioOptions: {
196
+ ssrc: this.audioSsrc,
197
+ rtcpPort: 0, // we don't care about rtcp for incoming audio
198
+ },
199
+ outputArgs: [
200
+ '-acodec',
201
+ 'libopus',
202
+ '-flags',
203
+ '+global_header',
204
+ '-ac',
205
+ 2,
206
+ '-ar',
207
+ '48k',
208
+ '-f',
209
+ 'rtp',
210
+ `rtp://127.0.0.1:${yield returnAudioTranscodedSplitter.portPromise}`,
211
+ ],
212
+ ffmpegPath: (0, ffmpeg_1.getFfmpegPath)(),
213
+ logger: {
214
+ info: util_1.logDebug,
215
+ error: util_1.logError,
216
+ },
217
+ logLabel: `Return Audio (${this.ringCamera.name})`,
218
+ returnAudioSplitter: this.audioSplitter,
219
+ });
220
+ this.liveCall.onCallEnded.pipe((0, operators_1.take)(1)).subscribe(() => {
221
+ returnAudioTranscoder.stop();
222
+ returnAudioTranscodedSplitter.close();
223
+ });
224
+ yield returnAudioTranscoder.start();
225
+ yield transcodingPromise;
148
226
  });
149
227
  }
150
228
  stop() {
@@ -181,8 +259,8 @@ class CameraSource {
181
259
  audio: {
182
260
  codecs: [
183
261
  {
184
- type: "OPUS" /* OPUS */,
185
- samplerate: 24 /* KHZ_24 */,
262
+ type: "AAC-eld" /* AAC_ELD */,
263
+ samplerate: 16 /* KHZ_16 */,
186
264
  },
187
265
  ],
188
266
  },
@@ -211,8 +211,7 @@ class RingPlatform {
211
211
  }
212
212
  this.log.info(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
213
213
  hapDevices.forEach(({ deviceType, device, isCamera, id, name, AccessoryClass }) => {
214
- // the opus qualifier is used to force new camera accessories to be created that support opus instead of aac
215
- const cameraOpusQualifier = isCamera ? 'opus' : '', uuid = hap_1.hap.uuid.generate(debugPrefix + id + cameraOpusQualifier), displayName = debugPrefix + name;
214
+ const uuid = hap_1.hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
216
215
  if (!AccessoryClass ||
217
216
  (config.hideLightGroups &&
218
217
  deviceType === api_1.RingDeviceType.BeamsLightGroupSwitch) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-ring",
3
- "version": "10.0.0-beta.0",
3
+ "version": "10.0.0-beta.3",
4
4
  "description": "Homebridge plugin for Ring doorbells, cameras, security alarm system and smart lighting",
5
5
  "main": "lib/homebridge/index.js",
6
6
  "bin": {
@@ -1,26 +0,0 @@
1
- import { DingKind } from './ring-types';
2
- export interface LiveCallSession {
3
- alexa_port: 18443;
4
- app_session_token: string;
5
- availability_zone: 'availability-zone';
6
- custom_timer: {
7
- max_sec: number;
8
- };
9
- ding_id: string;
10
- ding_kind: DingKind;
11
- doorbot_id: number;
12
- exp: number;
13
- ip: string;
14
- port: number;
15
- private_ip: string;
16
- rms_fqdn: string;
17
- rms_version: string;
18
- rsp_port: number;
19
- rtsp_port: number;
20
- session_id: string;
21
- sip_port: number;
22
- webrtc_port: number;
23
- webrtc_url: string;
24
- wwr_port: number;
25
- }
26
- export declare function parseLiveCallSession(sessionId: string): LiveCallSession;
@@ -1,8 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseLiveCallSession = void 0;
4
- function parseLiveCallSession(sessionId) {
5
- const encodedSession = sessionId.split('.')[1], buff = Buffer.from(encodedSession, 'base64'), text = buff.toString('ascii');
6
- return JSON.parse(text);
7
- }
8
- exports.parseLiveCallSession = parseLiveCallSession;