homebridge-ring 9.25.0-beta.0 → 9.25.0-beta.1
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.d.ts +41 -0
- package/lib/api/live-call.js +201 -0
- package/lib/api/peer-connection.d.ts +17 -0
- package/lib/api/peer-connection.js +96 -0
- package/lib/api/ring-camera.d.ts +2 -0
- package/lib/api/ring-camera.js +23 -2
- package/lib/api/ring-types.d.ts +15 -0
- package/lib/api/rtp-utils.d.ts +26 -0
- package/lib/api/rtp-utils.js +6 -1
- package/lib/homebridge/camera-source.d.ts +1 -1
- package/lib/homebridge/camera-source.js +238 -218
- package/package.json +7 -3
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
|
+
## [9.25.0-beta.1](https://github.com/dgreif/ring/compare/v9.25.0-beta.0...v9.25.0-beta.1) (2022-02-14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* use new WebRTC endpoint for live calls ([6e7779f](https://github.com/dgreif/ring/commit/6e7779feef9789583921fc44bab32c099f3026d2))
|
|
11
|
+
|
|
5
12
|
## [9.25.0-beta.0](https://github.com/dgreif/ring/compare/v9.24.0...v9.25.0-beta.0) (2022-01-29)
|
|
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 LiveCall extends Subscribed {
|
|
13
|
+
private sessionId;
|
|
14
|
+
private camera;
|
|
15
|
+
private readonly ws;
|
|
16
|
+
private readonly onWsOpen;
|
|
17
|
+
private readonly onMessage;
|
|
18
|
+
private readonly pc;
|
|
19
|
+
readonly onCallAnswered: ReplaySubject<void>;
|
|
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
|
+
constructor(sessionId: string, camera: RingCamera);
|
|
26
|
+
private handleMessage;
|
|
27
|
+
reservePort(bufferPorts?: number): Promise<number>;
|
|
28
|
+
prepareTranscoder(transcodeVideoStream: boolean, ffmpegInputOptions: SpawnInput[] | undefined, audioPort: number, videoPort: number, sdpInput: string): {
|
|
29
|
+
ffmpegInputArguments: SpawnInput[];
|
|
30
|
+
inputSdpLines: string[];
|
|
31
|
+
};
|
|
32
|
+
startTranscoding(ffmpegOptions: FfmpegOptions): Promise<void>;
|
|
33
|
+
activated: boolean;
|
|
34
|
+
activate(): Promise<void>;
|
|
35
|
+
activateCameraSpeaker(): Promise<void>;
|
|
36
|
+
private sendMessage;
|
|
37
|
+
private callEnded;
|
|
38
|
+
stop(): void;
|
|
39
|
+
sendAudioPacket(rtp: RtpPacket): void;
|
|
40
|
+
}
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
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.LiveCall = void 0;
|
|
13
|
+
const rtp_utils_1 = require("./rtp-utils");
|
|
14
|
+
const ws_1 = require("ws");
|
|
15
|
+
const rxjs_1 = require("rxjs");
|
|
16
|
+
const peer_connection_1 = require("./peer-connection");
|
|
17
|
+
const util_1 = require("./util");
|
|
18
|
+
const operators_1 = require("rxjs/operators");
|
|
19
|
+
const subscribed_1 = require("./subscribed");
|
|
20
|
+
const camera_utils_1 = require("@homebridge/camera-utils");
|
|
21
|
+
const ffmpeg_1 = require("./ffmpeg");
|
|
22
|
+
class LiveCall extends subscribed_1.Subscribed {
|
|
23
|
+
constructor(sessionId, camera) {
|
|
24
|
+
super();
|
|
25
|
+
this.sessionId = sessionId;
|
|
26
|
+
this.camera = camera;
|
|
27
|
+
this.onCallAnswered = new rxjs_1.ReplaySubject(1);
|
|
28
|
+
this.onCallEnded = new rxjs_1.ReplaySubject(1);
|
|
29
|
+
this.audioSplitter = new camera_utils_1.RtpSplitter();
|
|
30
|
+
this.videoSplitter = new camera_utils_1.RtpSplitter();
|
|
31
|
+
this.activated = false;
|
|
32
|
+
const liveCallSession = (0, rtp_utils_1.parseLiveCallSession)(sessionId);
|
|
33
|
+
this.pc = new peer_connection_1.PeerConnection();
|
|
34
|
+
this.ws = new ws_1.WebSocket(`wss://${liveCallSession.rms_fqdn}:${liveCallSession.webrtc_port}/`, {
|
|
35
|
+
headers: {
|
|
36
|
+
API_VERSION: '3.1',
|
|
37
|
+
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',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
this.onAudioRtp = this.pc.onAudioRtp;
|
|
42
|
+
this.onVideoRtp = this.pc.onVideoRtp;
|
|
43
|
+
this.onMessage = (0, rxjs_1.fromEvent)(this.ws, 'message');
|
|
44
|
+
this.onWsOpen = (0, rxjs_1.fromEvent)(this.ws, 'open');
|
|
45
|
+
const onError = (0, rxjs_1.fromEvent)(this.ws, 'error'), onClose = (0, rxjs_1.fromEvent)(this.ws, 'close');
|
|
46
|
+
this.addSubscriptions(this.onMessage
|
|
47
|
+
.pipe((0, operators_1.concatMap)((message) => {
|
|
48
|
+
return this.handleMessage(JSON.parse(message.data));
|
|
49
|
+
}))
|
|
50
|
+
.subscribe(), this.onWsOpen.subscribe(() => {
|
|
51
|
+
(0, util_1.logDebug)(`WebSocket connected for ${this.camera.name}`);
|
|
52
|
+
}), onError.subscribe((e) => {
|
|
53
|
+
(0, util_1.logError)(e);
|
|
54
|
+
this.callEnded();
|
|
55
|
+
}), onClose.subscribe(() => {
|
|
56
|
+
this.callEnded();
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
handleMessage(message) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
switch (message.method) {
|
|
62
|
+
case 'sdp':
|
|
63
|
+
const answer = yield this.pc.createAnswer(message);
|
|
64
|
+
this.sendMessage(Object.assign({ method: 'sdp' }, answer));
|
|
65
|
+
this.onCallAnswered.next();
|
|
66
|
+
return;
|
|
67
|
+
case 'ice':
|
|
68
|
+
yield this.pc.addIceCandidate({
|
|
69
|
+
candidate: message.ice,
|
|
70
|
+
sdpMLineIndex: message.mlineindex,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
reservePort(bufferPorts = 0) {
|
|
77
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
78
|
+
const ports = yield (0, camera_utils_1.reservePorts)({ count: bufferPorts + 1 });
|
|
79
|
+
return ports[0];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
prepareTranscoder(transcodeVideoStream, ffmpegInputOptions, audioPort, videoPort, sdpInput) {
|
|
83
|
+
const ffmpegInputArguments = [
|
|
84
|
+
'-hide_banner',
|
|
85
|
+
'-protocol_whitelist',
|
|
86
|
+
'pipe,udp,rtp,file,crypto',
|
|
87
|
+
'-acodec',
|
|
88
|
+
'libopus',
|
|
89
|
+
'-f',
|
|
90
|
+
'sdp',
|
|
91
|
+
...(ffmpegInputOptions || []),
|
|
92
|
+
'-i',
|
|
93
|
+
sdpInput,
|
|
94
|
+
], inputSdpLines = [
|
|
95
|
+
'v=0',
|
|
96
|
+
'o=105202070 3747 461 IN IP4 127.0.0.1',
|
|
97
|
+
's=Talk',
|
|
98
|
+
'c=IN IP4 127.0.0.1',
|
|
99
|
+
'b=AS:380',
|
|
100
|
+
't=0 0',
|
|
101
|
+
'a=rtcp-xr:rcvr-rtt=all:10000 stat-summary=loss,dup,jitt,TTL voip-metrics',
|
|
102
|
+
`m=audio ${audioPort} RTP/SAVP 101`,
|
|
103
|
+
'a=rtpmap:101 OPUS/48000/2',
|
|
104
|
+
'a=rtcp-fb:101 nack pli',
|
|
105
|
+
'a=fmtp:101 useinbandfec=1;sprop-stereo=0',
|
|
106
|
+
'a=rtcp-mux',
|
|
107
|
+
];
|
|
108
|
+
if (transcodeVideoStream) {
|
|
109
|
+
inputSdpLines.push(`m=video ${videoPort} RTP/SAVP 96`, 'a=rtpmap:96 H264/90000', 'a=rtcp-fb:96 nack', 'a=rtcp-fb:96 nack pli', 'a=fmtp:96 packetization-mode=1;profile-level-id=640029;level-asymmetry-allowed=1', 'a=rtcp-mux');
|
|
110
|
+
this.addSubscriptions(this.onVideoRtp
|
|
111
|
+
.pipe((0, operators_1.concatMap)((rtp) => {
|
|
112
|
+
return this.videoSplitter.send(rtp.serialize(), {
|
|
113
|
+
port: videoPort,
|
|
114
|
+
});
|
|
115
|
+
}))
|
|
116
|
+
.subscribe());
|
|
117
|
+
}
|
|
118
|
+
this.addSubscriptions(this.onAudioRtp
|
|
119
|
+
.pipe((0, operators_1.concatMap)((rtp) => {
|
|
120
|
+
return this.audioSplitter.send(rtp.serialize(), {
|
|
121
|
+
port: audioPort,
|
|
122
|
+
});
|
|
123
|
+
}))
|
|
124
|
+
.subscribe());
|
|
125
|
+
return {
|
|
126
|
+
ffmpegInputArguments,
|
|
127
|
+
inputSdpLines,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
startTranscoding(ffmpegOptions) {
|
|
131
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
132
|
+
const videoPort = yield this.reservePort(1), audioPort = yield this.reservePort(1), transcodeVideoStream = ffmpegOptions.video !== false, { ffmpegInputArguments, inputSdpLines } = this.prepareTranscoder(transcodeVideoStream, ffmpegOptions.input, audioPort, videoPort, 'pipe:'), ff = new camera_utils_1.FfmpegProcess({
|
|
133
|
+
ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
|
|
134
|
+
? ffmpegOptions.video || ['-vcodec', 'copy']
|
|
135
|
+
: []), ...(ffmpegOptions.output || [])),
|
|
136
|
+
ffmpegPath: (0, ffmpeg_1.getFfmpegPath)(),
|
|
137
|
+
exitCallback: () => this.callEnded(),
|
|
138
|
+
logLabel: `From Ring (${this.camera.name})`,
|
|
139
|
+
logger: {
|
|
140
|
+
error: util_1.logError,
|
|
141
|
+
info: util_1.logDebug,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
this.onCallEnded.subscribe(() => ff.stop());
|
|
145
|
+
ff.writeStdin(inputSdpLines.filter((x) => Boolean(x)).join('\n'));
|
|
146
|
+
// Activate the stream now that ffmpeg is ready to receive
|
|
147
|
+
yield this.activate();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
activate() {
|
|
151
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
152
|
+
if (this.activated) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.activated = true;
|
|
156
|
+
yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered);
|
|
157
|
+
this.sendMessage({ method: 'activate_session' });
|
|
158
|
+
this.sendMessage({
|
|
159
|
+
video_enabled: true,
|
|
160
|
+
audio_enabled: true,
|
|
161
|
+
method: 'stream_options',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
activateCameraSpeaker() {
|
|
166
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
167
|
+
yield (0, rxjs_1.firstValueFrom)(this.onCallAnswered);
|
|
168
|
+
this.sendMessage({
|
|
169
|
+
stealth_mode: false,
|
|
170
|
+
method: 'camera_options',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
sendMessage(message) {
|
|
175
|
+
this.ws.send(JSON.stringify(message));
|
|
176
|
+
}
|
|
177
|
+
callEnded() {
|
|
178
|
+
try {
|
|
179
|
+
this.sendMessage({
|
|
180
|
+
reason: { code: 0, text: '' },
|
|
181
|
+
method: 'close',
|
|
182
|
+
});
|
|
183
|
+
this.ws.close();
|
|
184
|
+
}
|
|
185
|
+
catch (_) {
|
|
186
|
+
// ignore any errors since we are stopping the call
|
|
187
|
+
}
|
|
188
|
+
this.unsubscribe();
|
|
189
|
+
this.onCallEnded.next();
|
|
190
|
+
this.pc.close();
|
|
191
|
+
this.audioSplitter.close();
|
|
192
|
+
this.videoSplitter.close();
|
|
193
|
+
}
|
|
194
|
+
stop() {
|
|
195
|
+
this.callEnded();
|
|
196
|
+
}
|
|
197
|
+
sendAudioPacket(rtp) {
|
|
198
|
+
this.pc.returnAudioTrack.writeRtp(rtp);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
exports.LiveCall = LiveCall;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { MediaStreamTrack, RTCIceCandidate, RTCPeerConnection, RtcpPacket, RtpPacket } from '@koush/werift';
|
|
2
|
+
import { Subject } from 'rxjs';
|
|
3
|
+
export declare class PeerConnection {
|
|
4
|
+
pc: RTCPeerConnection;
|
|
5
|
+
onAudioRtp: Subject<RtpPacket>;
|
|
6
|
+
onAudioRtcp: Subject<RtcpPacket>;
|
|
7
|
+
onVideoRtp: Subject<RtpPacket>;
|
|
8
|
+
onVideoRtcp: Subject<RtcpPacket>;
|
|
9
|
+
returnAudioTrack: MediaStreamTrack;
|
|
10
|
+
constructor();
|
|
11
|
+
createAnswer(offer: {
|
|
12
|
+
type: 'offer';
|
|
13
|
+
sdp: string;
|
|
14
|
+
}): Promise<import("@koush/werift").RTCSessionDescription>;
|
|
15
|
+
addIceCandidate(candidate: RTCIceCandidate): Promise<void>;
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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.PeerConnection = void 0;
|
|
13
|
+
const werift_1 = require("@koush/werift");
|
|
14
|
+
const rxjs_1 = require("rxjs");
|
|
15
|
+
const util_1 = require("./util");
|
|
16
|
+
const debug = false;
|
|
17
|
+
class PeerConnection {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.onAudioRtp = new rxjs_1.Subject();
|
|
20
|
+
this.onAudioRtcp = new rxjs_1.Subject();
|
|
21
|
+
this.onVideoRtp = new rxjs_1.Subject();
|
|
22
|
+
this.onVideoRtcp = new rxjs_1.Subject();
|
|
23
|
+
this.returnAudioTrack = new werift_1.MediaStreamTrack({ kind: 'audio' });
|
|
24
|
+
const pc = (this.pc = new werift_1.RTCPeerConnection({
|
|
25
|
+
codecs: {
|
|
26
|
+
audio: [
|
|
27
|
+
new werift_1.RTCRtpCodecParameters({
|
|
28
|
+
mimeType: 'audio/opus',
|
|
29
|
+
clockRate: 48000,
|
|
30
|
+
channels: 2,
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
video: [
|
|
34
|
+
new werift_1.RTCRtpCodecParameters({
|
|
35
|
+
mimeType: 'video/H264',
|
|
36
|
+
clockRate: 90000,
|
|
37
|
+
rtcpFeedback: [
|
|
38
|
+
{ type: 'transport-cc' },
|
|
39
|
+
{ type: 'ccm', parameter: 'fir' },
|
|
40
|
+
{ type: 'nack' },
|
|
41
|
+
{ type: 'nack', parameter: 'pli' },
|
|
42
|
+
{ type: 'goog-remb' },
|
|
43
|
+
],
|
|
44
|
+
parameters: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
})), audioTransceiver = pc.addTransceiver(this.returnAudioTrack, {
|
|
49
|
+
direction: 'sendrecv',
|
|
50
|
+
}), videoTransceiver = pc.addTransceiver('video', {
|
|
51
|
+
direction: 'recvonly',
|
|
52
|
+
});
|
|
53
|
+
audioTransceiver.onTrack.subscribe((track) => {
|
|
54
|
+
track.onReceiveRtp.subscribe((rtp) => {
|
|
55
|
+
this.onAudioRtp.next(rtp);
|
|
56
|
+
});
|
|
57
|
+
track.onReceiveRtcp.subscribe((rtcp) => {
|
|
58
|
+
this.onAudioRtcp.next(rtcp);
|
|
59
|
+
});
|
|
60
|
+
if (debug) {
|
|
61
|
+
track.onReceiveRtp.once(() => {
|
|
62
|
+
(0, util_1.logInfo)('received first audio packet');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
videoTransceiver.onTrack.subscribe((track) => {
|
|
67
|
+
track.onReceiveRtp.subscribe((rtp) => {
|
|
68
|
+
this.onVideoRtp.next(rtp);
|
|
69
|
+
});
|
|
70
|
+
track.onReceiveRtcp.subscribe((rtcp) => {
|
|
71
|
+
this.onVideoRtcp.next(rtcp);
|
|
72
|
+
});
|
|
73
|
+
track.onReceiveRtp.once(() => {
|
|
74
|
+
if (debug) {
|
|
75
|
+
(0, util_1.logInfo)('received first video packet');
|
|
76
|
+
}
|
|
77
|
+
setInterval(() => videoTransceiver.receiver.sendRtcpPLI(track.ssrc), 2000);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
createAnswer(offer) {
|
|
82
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
83
|
+
yield this.pc.setRemoteDescription(offer);
|
|
84
|
+
const answer = yield this.pc.createAnswer();
|
|
85
|
+
yield this.pc.setLocalDescription(answer);
|
|
86
|
+
return answer;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
addIceCandidate(candidate) {
|
|
90
|
+
return this.pc.addIceCandidate(candidate);
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
this.pc.close().catch(util_1.logError);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.PeerConnection = PeerConnection;
|
package/lib/api/ring-camera.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { DeepPartial } from './util';
|
|
|
7
7
|
import { FfmpegOptions, SipSession } from './sip-session';
|
|
8
8
|
import { SipOptions } from './sip-call';
|
|
9
9
|
import { Subscribed } from './subscribed';
|
|
10
|
+
import { LiveCall } from './live-call';
|
|
10
11
|
export declare function getBatteryLevel(data: Pick<CameraData, 'battery_life' | 'battery_life_2'>): number | null;
|
|
11
12
|
export declare function getSearchQueryString(options: CameraEventOptions | (HistoryOptions & {
|
|
12
13
|
accountId: string;
|
|
@@ -54,6 +55,7 @@ export declare class RingCamera extends Subscribed {
|
|
|
54
55
|
getDeviceSettings(): Promise<CameraDeviceSettingsData & import("./rest-client").ExtendedResponse>;
|
|
55
56
|
setInHomeDoorbell(enable: boolean): Promise<boolean>;
|
|
56
57
|
getHealth(): Promise<CameraHealth>;
|
|
58
|
+
startLiveCall(): Promise<LiveCall>;
|
|
57
59
|
startVideoOnDemand(): Promise<("" | ActiveDing) & import("./rest-client").ExtendedResponse>;
|
|
58
60
|
private pollForActiveDing;
|
|
59
61
|
private expiredDingIds;
|
package/lib/api/ring-camera.js
CHANGED
|
@@ -18,6 +18,7 @@ const camera_utils_1 = require("@homebridge/camera-utils");
|
|
|
18
18
|
const util_1 = require("./util");
|
|
19
19
|
const sip_session_1 = require("./sip-session");
|
|
20
20
|
const subscribed_1 = require("./subscribed");
|
|
21
|
+
const live_call_1 = require("./live-call");
|
|
21
22
|
const maxSnapshotRefreshSeconds = 15, fullDayMs = 24 * 60 * 60 * 1000;
|
|
22
23
|
function parseBatteryLife(batteryLife) {
|
|
23
24
|
if (batteryLife === null || batteryLife === undefined) {
|
|
@@ -231,6 +232,25 @@ class RingCamera extends subscribed_1.Subscribed {
|
|
|
231
232
|
return response.device_health;
|
|
232
233
|
});
|
|
233
234
|
}
|
|
235
|
+
startLiveCall() {
|
|
236
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
237
|
+
const liveCall = yield this.restClient
|
|
238
|
+
.request({
|
|
239
|
+
method: 'POST',
|
|
240
|
+
url: this.doorbotUrl('live_call'),
|
|
241
|
+
})
|
|
242
|
+
.catch((e) => {
|
|
243
|
+
var _a;
|
|
244
|
+
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 403) {
|
|
245
|
+
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.`;
|
|
246
|
+
(0, util_1.logError)(errorMessage);
|
|
247
|
+
throw new Error(errorMessage);
|
|
248
|
+
}
|
|
249
|
+
throw e;
|
|
250
|
+
});
|
|
251
|
+
return new live_call_1.LiveCall(liveCall.data.session_id, this);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
234
254
|
startVideoOnDemand() {
|
|
235
255
|
return this.restClient
|
|
236
256
|
.request({
|
|
@@ -439,10 +459,11 @@ class RingCamera extends subscribed_1.Subscribed {
|
|
|
439
459
|
}
|
|
440
460
|
recordToFile(outputPath, duration = 30) {
|
|
441
461
|
return __awaiter(this, void 0, void 0, function* () {
|
|
442
|
-
const
|
|
462
|
+
const liveCall = yield this.startLiveCall();
|
|
463
|
+
yield liveCall.startTranscoding({
|
|
443
464
|
output: ['-t', duration.toString(), outputPath],
|
|
444
465
|
});
|
|
445
|
-
yield (0, rxjs_1.firstValueFrom)(
|
|
466
|
+
yield (0, rxjs_1.firstValueFrom)(liveCall.onCallEnded);
|
|
446
467
|
});
|
|
447
468
|
}
|
|
448
469
|
streamVideo(ffmpegOptions) {
|
package/lib/api/ring-types.d.ts
CHANGED
|
@@ -688,6 +688,21 @@ export interface ActiveDing {
|
|
|
688
688
|
sip_token: string;
|
|
689
689
|
sip_ding_id: string;
|
|
690
690
|
}
|
|
691
|
+
export interface LiveCallResponse {
|
|
692
|
+
ding_id: string;
|
|
693
|
+
ding_kind: DingKind;
|
|
694
|
+
device_id: number;
|
|
695
|
+
device_kind: RingCameraKind;
|
|
696
|
+
device_description: string;
|
|
697
|
+
state: DingState;
|
|
698
|
+
protocol: 'ring_media_server' | unknown;
|
|
699
|
+
now: number;
|
|
700
|
+
video_jitter_buffer_ms: number;
|
|
701
|
+
audio_jitter_buffer_ms: number;
|
|
702
|
+
data: {
|
|
703
|
+
session_id: string;
|
|
704
|
+
};
|
|
705
|
+
}
|
|
691
706
|
export interface AuthTokenResponse {
|
|
692
707
|
access_token: string;
|
|
693
708
|
expires_in: number;
|
package/lib/api/rtp-utils.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
import { RtpSplitter, SrtpOptions } from '@homebridge/camera-utils';
|
|
3
|
+
import { DingKind } from './ring-types';
|
|
3
4
|
export interface RtpStreamOptions extends SrtpOptions {
|
|
4
5
|
port: number;
|
|
5
6
|
rtcpPort: number;
|
|
@@ -27,3 +28,28 @@ export declare function sendStunBindingRequest({ rtpDescription, rtpSplitter, rt
|
|
|
27
28
|
type: 'video' | 'audio';
|
|
28
29
|
}): void;
|
|
29
30
|
export declare function createStunResponder(rtpSplitter: RtpSplitter): void;
|
|
31
|
+
export interface LiveCallSession {
|
|
32
|
+
alexa_port: 18443;
|
|
33
|
+
app_session_token: string;
|
|
34
|
+
availability_zone: 'availability-zone';
|
|
35
|
+
custom_timer: {
|
|
36
|
+
max_sec: number;
|
|
37
|
+
};
|
|
38
|
+
ding_id: string;
|
|
39
|
+
ding_kind: DingKind;
|
|
40
|
+
doorbot_id: number;
|
|
41
|
+
exp: number;
|
|
42
|
+
ip: string;
|
|
43
|
+
port: number;
|
|
44
|
+
private_ip: string;
|
|
45
|
+
rms_fqdn: string;
|
|
46
|
+
rms_version: string;
|
|
47
|
+
rsp_port: number;
|
|
48
|
+
rtsp_port: number;
|
|
49
|
+
session_id: string;
|
|
50
|
+
sip_port: number;
|
|
51
|
+
webrtc_port: number;
|
|
52
|
+
webrtc_url: string;
|
|
53
|
+
wwr_port: number;
|
|
54
|
+
}
|
|
55
|
+
export declare function parseLiveCallSession(sessionId: string): LiveCallSession;
|
package/lib/api/rtp-utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createStunResponder = exports.sendStunBindingRequest = exports.isStunMessage = void 0;
|
|
3
|
+
exports.parseLiveCallSession = exports.createStunResponder = exports.sendStunBindingRequest = exports.isStunMessage = void 0;
|
|
4
4
|
const util_1 = require("./util");
|
|
5
5
|
const stun = require('stun');
|
|
6
6
|
const stunMagicCookie = 0x2112a442; // https://tools.ietf.org/html/rfc5389#section-6
|
|
@@ -54,3 +54,8 @@ function createStunResponder(rtpSplitter) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
exports.createStunResponder = createStunResponder;
|
|
57
|
+
function parseLiveCallSession(sessionId) {
|
|
58
|
+
const encodedSession = sessionId.split('.')[1], buff = new Buffer(encodedSession, 'base64'), text = buff.toString('ascii');
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
exports.parseLiveCallSession = parseLiveCallSession;
|
|
@@ -12,5 +12,5 @@ export declare class CameraSource implements CameraStreamingDelegate {
|
|
|
12
12
|
private getCurrentSnapshot;
|
|
13
13
|
handleSnapshotRequest(request: SnapshotRequest, callback: SnapshotRequestCallback): Promise<void>;
|
|
14
14
|
prepareStream(request: PrepareStreamRequest, callback: PrepareStreamCallback): Promise<void>;
|
|
15
|
-
handleStreamRequest(request: StreamingRequest, callback: StreamRequestCallback): void
|
|
15
|
+
handleStreamRequest(request: StreamingRequest, callback: StreamRequestCallback): Promise<void>;
|
|
16
16
|
}
|
|
@@ -17,12 +17,220 @@ 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 rtp_utils_1 = require("../api/rtp-utils");
|
|
21
20
|
const ffmpeg_1 = require("../api/ffmpeg");
|
|
21
|
+
const werift_1 = require("@koush/werift");
|
|
22
22
|
const readFileAsync = (0, util_2.promisify)(fs_1.readFile), cameraOfflinePath = require.resolve('../../media/camera-offline.jpg'), snapshotsBlockedPath = require.resolve('../../media/snapshots-blocked.jpg');
|
|
23
23
|
function getDurationSeconds(start) {
|
|
24
24
|
return (Date.now() - start) / 1000;
|
|
25
25
|
}
|
|
26
|
+
function getSessionConfig(srtpOptions) {
|
|
27
|
+
return {
|
|
28
|
+
keys: {
|
|
29
|
+
localMasterKey: srtpOptions.srtpKey,
|
|
30
|
+
localMasterSalt: srtpOptions.srtpSalt,
|
|
31
|
+
remoteMasterKey: srtpOptions.srtpKey,
|
|
32
|
+
remoteMasterSalt: srtpOptions.srtpSalt,
|
|
33
|
+
},
|
|
34
|
+
profile: 1,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
class StreamingSession {
|
|
38
|
+
constructor(liveCall, prepareStreamRequest, ringCamera, start) {
|
|
39
|
+
this.liveCall = liveCall;
|
|
40
|
+
this.prepareStreamRequest = prepareStreamRequest;
|
|
41
|
+
this.ringCamera = ringCamera;
|
|
42
|
+
this.start = start;
|
|
43
|
+
this.audioSsrc = hap_1.hap.CameraController.generateSynchronisationSource();
|
|
44
|
+
this.videoSsrc = hap_1.hap.CameraController.generateSynchronisationSource();
|
|
45
|
+
this.audioSrtp = (0, camera_utils_1.generateSrtpOptions)();
|
|
46
|
+
this.videoSrtp = (0, camera_utils_1.generateSrtpOptions)();
|
|
47
|
+
this.audioSplitter = new camera_utils_1.RtpSplitter();
|
|
48
|
+
this.videoSplitter = new camera_utils_1.RtpSplitter();
|
|
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,
|
|
61
|
+
// used to encrypt rtcp to HomeKit for keepalive
|
|
62
|
+
videoSrtcpSession = new werift_1.SrtcpSession(getSessionConfig(this.videoSrtp)), onReturnPacketReceived = new rxjs_1.Subject();
|
|
63
|
+
// Watch return packets to detect a dead stream from the HomeKit side
|
|
64
|
+
// This can happen if the user force-quits the Home app
|
|
65
|
+
this.videoSplitter.addMessageHandler(() => {
|
|
66
|
+
// return packet from HomeKit
|
|
67
|
+
onReturnPacketReceived.next(null);
|
|
68
|
+
return null;
|
|
69
|
+
});
|
|
70
|
+
this.audioSplitter.addMessageHandler(() => {
|
|
71
|
+
// return packet from HomeKit
|
|
72
|
+
onReturnPacketReceived.next(null);
|
|
73
|
+
return null;
|
|
74
|
+
});
|
|
75
|
+
liveCall.addSubscriptions((0, rxjs_1.merge)((0, rxjs_1.of)(true).pipe((0, operators_1.delay)(15000)), onReturnPacketReceived)
|
|
76
|
+
.pipe((0, operators_1.debounceTime)(5000))
|
|
77
|
+
.subscribe(() => {
|
|
78
|
+
(0, util_1.logInfo)(`Live stream for ${this.ringCamera.name} appears to be inactive. (${getDurationSeconds(start)}s)`);
|
|
79
|
+
liveCall.stop();
|
|
80
|
+
}));
|
|
81
|
+
// Periodically send a blank RTCP packet to the HomeKit video port
|
|
82
|
+
// Without this, HomeKit assumes the stream is dead after 30 second and sends a stop request
|
|
83
|
+
liveCall.addSubscriptions((0, rxjs_1.interval)(500).subscribe(() => {
|
|
84
|
+
const senderInfo = new werift_1.RtcpSenderInfo({
|
|
85
|
+
ntpTimestamp: BigInt(0),
|
|
86
|
+
packetCount: 0,
|
|
87
|
+
octetCount: 0,
|
|
88
|
+
rtpTimestamp: 0,
|
|
89
|
+
}), senderReport = new werift_1.RtcpSrPacket({
|
|
90
|
+
ssrc: this.videoSsrc,
|
|
91
|
+
senderInfo: senderInfo,
|
|
92
|
+
}), message = videoSrtcpSession.encrypt(senderReport.serialize());
|
|
93
|
+
this.videoSplitter
|
|
94
|
+
.send(message, {
|
|
95
|
+
port: videoPort,
|
|
96
|
+
address: targetAddress,
|
|
97
|
+
})
|
|
98
|
+
.catch(util_1.logError);
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
activate(request) {
|
|
102
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
103
|
+
let sentVideo = false;
|
|
104
|
+
const { targetAddress, audio: { port: audioPort, srtp_key: remoteAudioSrtpKey, srtp_salt: remoteAudioSrtpSalt, }, video: { port: videoPort }, } = this.prepareStreamRequest,
|
|
105
|
+
// use to encrypt Ring video to HomeKit
|
|
106
|
+
videoSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.videoSrtp));
|
|
107
|
+
// Set up packet forwarding for video stream
|
|
108
|
+
this.liveCall.addSubscriptions(this.liveCall.onVideoRtp.subscribe(({ header, payload }) => {
|
|
109
|
+
header.ssrc = this.videoSsrc;
|
|
110
|
+
header.payloadType = request.video.pt;
|
|
111
|
+
const encryptedPacket = videoSrtpSession.encrypt(payload, header);
|
|
112
|
+
if (!sentVideo) {
|
|
113
|
+
sentVideo = true;
|
|
114
|
+
(0, util_1.logInfo)(`Received stream data from ${this.ringCamera.name} (${getDurationSeconds(this.start)}s)`);
|
|
115
|
+
}
|
|
116
|
+
this.videoSplitter
|
|
117
|
+
.send(encryptedPacket, {
|
|
118
|
+
port: videoPort,
|
|
119
|
+
address: targetAddress,
|
|
120
|
+
})
|
|
121
|
+
.catch(util_1.logError);
|
|
122
|
+
}));
|
|
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
|
+
1,
|
|
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;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
stop() {
|
|
229
|
+
this.audioSplitter.close();
|
|
230
|
+
this.videoSplitter.close();
|
|
231
|
+
this.liveCall.stop();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
26
234
|
class CameraSource {
|
|
27
235
|
constructor(ringCamera) {
|
|
28
236
|
this.ringCamera = ringCamera;
|
|
@@ -142,213 +350,23 @@ class CameraSource {
|
|
|
142
350
|
const start = Date.now();
|
|
143
351
|
(0, util_1.logInfo)(`Preparing Live Stream for ${this.ringCamera.name}`);
|
|
144
352
|
try {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
srtpKey: audioSrtpKey,
|
|
149
|
-
srtpSalt: audioSrtpSalt,
|
|
150
|
-
},
|
|
151
|
-
video: {
|
|
152
|
-
srtpKey: videoSrtpKey,
|
|
153
|
-
srtpSalt: videoSrtpSalt,
|
|
154
|
-
},
|
|
155
|
-
skipFfmpegCheck: true,
|
|
156
|
-
}),
|
|
157
|
-
(0, camera_utils_1.doesFfmpegSupportCodec)('libfdk_aac', ffmpegPath)
|
|
158
|
-
.then((supported) => {
|
|
159
|
-
if (!supported) {
|
|
160
|
-
(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.');
|
|
161
|
-
}
|
|
162
|
-
return supported;
|
|
163
|
-
})
|
|
164
|
-
.catch(() => {
|
|
165
|
-
(0, util_1.logError)('Streaming video only - ffmpeg was not found. See https://github.com/dgreif/ring/wiki/FFmpeg for details.');
|
|
166
|
-
return false;
|
|
167
|
-
}),
|
|
168
|
-
]), onReturnPacketReceived = new rxjs_1.Subject();
|
|
169
|
-
sipSession.addSubscriptions((0, rxjs_1.merge)((0, rxjs_1.of)(true).pipe((0, operators_1.delay)(15000)), onReturnPacketReceived)
|
|
170
|
-
.pipe((0, operators_1.debounceTime)(5000))
|
|
171
|
-
.subscribe(() => {
|
|
172
|
-
(0, util_1.logInfo)(`Live stream for ${this.ringCamera.name} appears to be inactive. (${getDurationSeconds(start)}s)`);
|
|
173
|
-
sipSession.stop();
|
|
174
|
-
}));
|
|
175
|
-
this.sessions[sessionID] = sipSession;
|
|
176
|
-
const audioSsrc = hap_1.hap.CameraController.generateSynchronisationSource(), incomingAudioRtcpPort = yield sipSession.reservePort(), videoSsrcPromise = (0, rxjs_1.firstValueFrom)(sipSession.videoSplitter.onMessage.pipe((0, operators_1.filter)(({ info }) => info.address !== targetAddress), // Ignore return packets from HomeKit
|
|
177
|
-
(0, operators_1.map)((m) => (0, camera_utils_1.getSsrc)(m.message)), (0, operators_1.filter)((ssrc) => ssrc !== null))).catch(() => 0), ringRtpDescription = yield sipSession.start(libfdkAacInstalled
|
|
178
|
-
? {
|
|
179
|
-
input: ['-vn'],
|
|
180
|
-
audio: [
|
|
181
|
-
'-map',
|
|
182
|
-
'0:a',
|
|
183
|
-
// OPUS specific - it works, but audio is very choppy
|
|
184
|
-
// '-acodec',
|
|
185
|
-
// 'libopus',
|
|
186
|
-
// '-vbr',
|
|
187
|
-
// 'on',
|
|
188
|
-
// '-frame_duration',
|
|
189
|
-
// 20,
|
|
190
|
-
// '-application',
|
|
191
|
-
// 'lowdelay',
|
|
192
|
-
// AAC-eld specific
|
|
193
|
-
'-acodec',
|
|
194
|
-
'libfdk_aac',
|
|
195
|
-
'-profile:a',
|
|
196
|
-
'aac_eld',
|
|
197
|
-
// Shared options
|
|
198
|
-
'-flags',
|
|
199
|
-
'+global_header',
|
|
200
|
-
'-ac',
|
|
201
|
-
1,
|
|
202
|
-
'-ar',
|
|
203
|
-
'16k',
|
|
204
|
-
'-b:a',
|
|
205
|
-
'24k',
|
|
206
|
-
'-bufsize',
|
|
207
|
-
'24k',
|
|
208
|
-
'-payload_type',
|
|
209
|
-
110,
|
|
210
|
-
'-ssrc',
|
|
211
|
-
audioSsrc,
|
|
212
|
-
'-f',
|
|
213
|
-
'rtp',
|
|
214
|
-
'-srtp_out_suite',
|
|
215
|
-
'AES_CM_128_HMAC_SHA1_80',
|
|
216
|
-
'-srtp_out_params',
|
|
217
|
-
(0, camera_utils_1.encodeSrtpOptions)(sipSession.rtpOptions.audio),
|
|
218
|
-
`srtp://${targetAddress}:${audioPort}?localrtcpport=${incomingAudioRtcpPort}&pkt_size=188`,
|
|
219
|
-
],
|
|
220
|
-
video: false,
|
|
221
|
-
output: [],
|
|
222
|
-
}
|
|
223
|
-
: undefined);
|
|
224
|
-
let videoPacketReceived = false;
|
|
225
|
-
sipSession.videoSplitter.addMessageHandler(({ info, message, isRtpMessage }) => {
|
|
226
|
-
if (info.address === targetAddress) {
|
|
227
|
-
// return packet from HomeKit
|
|
228
|
-
onReturnPacketReceived.next(null);
|
|
229
|
-
if (!isRtpMessage) {
|
|
230
|
-
// Only need to handle RTCP packets. We really shouldn't receive RTP, but check just in case
|
|
231
|
-
sipSession.videoRtcpSplitter
|
|
232
|
-
.send(message, {
|
|
233
|
-
port: ringRtpDescription.video.rtcpPort,
|
|
234
|
-
address: ringRtpDescription.address,
|
|
235
|
-
})
|
|
236
|
-
.catch(util_1.logError);
|
|
237
|
-
}
|
|
238
|
-
// don't need to forward it along from the RTP splitter since it's only RTCP we care about
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
if ((0, rtp_utils_1.isStunMessage)(message) || !isRtpMessage) {
|
|
242
|
-
// we don't need to forward stun messages to HomeKit since they are for connection establishment purposes only
|
|
243
|
-
// if not rtp, probably rtcp which will be handled from rtcp splitter
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
if (!videoPacketReceived) {
|
|
247
|
-
videoPacketReceived = true;
|
|
248
|
-
(0, util_1.logInfo)(`Received stream data from ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
|
|
249
|
-
}
|
|
250
|
-
return {
|
|
251
|
-
port: videoPort,
|
|
252
|
-
address: targetAddress,
|
|
253
|
-
};
|
|
254
|
-
});
|
|
255
|
-
sipSession.videoRtcpSplitter.addMessageHandler(({ message, info, isRtpMessage }) => {
|
|
256
|
-
// for ICE connections, Rtcp splitter is the same as Rtp splitter, so we need to filter other messages out
|
|
257
|
-
if ((0, rtp_utils_1.isStunMessage)(message) ||
|
|
258
|
-
isRtpMessage ||
|
|
259
|
-
info.address === targetAddress) {
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
sipSession.videoSplitter
|
|
263
|
-
.send(message, {
|
|
264
|
-
port: videoPort,
|
|
265
|
-
address: targetAddress,
|
|
266
|
-
})
|
|
267
|
-
.catch(util_1.logError);
|
|
268
|
-
return null;
|
|
269
|
-
});
|
|
270
|
-
let returnAudioPort = null;
|
|
271
|
-
if (libfdkAacInstalled) {
|
|
272
|
-
let cameraSpeakerActive = false;
|
|
273
|
-
const ringAudioLocation = {
|
|
274
|
-
address: ringRtpDescription.address,
|
|
275
|
-
port: ringRtpDescription.audio.port,
|
|
276
|
-
}, returnAudioTranscodedSplitter = new camera_utils_1.RtpSplitter((description) => {
|
|
277
|
-
if (!cameraSpeakerActive) {
|
|
278
|
-
cameraSpeakerActive = true;
|
|
279
|
-
sipSession.activateCameraSpeaker().catch(util_1.logError);
|
|
280
|
-
}
|
|
281
|
-
sipSession.audioSplitter
|
|
282
|
-
.send(description.message, ringAudioLocation)
|
|
283
|
-
.catch(util_1.logError);
|
|
284
|
-
return null;
|
|
285
|
-
}), returnAudioTranscoder = new camera_utils_1.ReturnAudioTranscoder({
|
|
286
|
-
prepareStreamRequest: request,
|
|
287
|
-
incomingAudioOptions: {
|
|
288
|
-
ssrc: audioSsrc,
|
|
289
|
-
rtcpPort: incomingAudioRtcpPort,
|
|
290
|
-
},
|
|
291
|
-
outputArgs: [
|
|
292
|
-
'-acodec',
|
|
293
|
-
'pcm_mulaw',
|
|
294
|
-
'-flags',
|
|
295
|
-
'+global_header',
|
|
296
|
-
'-ac',
|
|
297
|
-
1,
|
|
298
|
-
'-ar',
|
|
299
|
-
'8k',
|
|
300
|
-
'-f',
|
|
301
|
-
'rtp',
|
|
302
|
-
'-srtp_out_suite',
|
|
303
|
-
'AES_CM_128_HMAC_SHA1_80',
|
|
304
|
-
'-srtp_out_params',
|
|
305
|
-
(0, camera_utils_1.encodeSrtpOptions)(sipSession.rtpOptions.audio),
|
|
306
|
-
`srtp://127.0.0.1:${yield returnAudioTranscodedSplitter.portPromise}?pkt_size=188`,
|
|
307
|
-
],
|
|
308
|
-
ffmpegPath,
|
|
309
|
-
logger: {
|
|
310
|
-
info: util_1.logDebug,
|
|
311
|
-
error: util_1.logError,
|
|
312
|
-
},
|
|
313
|
-
logLabel: `Return Audio (${this.ringCamera.name})`,
|
|
314
|
-
});
|
|
315
|
-
sipSession.onCallEnded.pipe((0, operators_1.take)(1)).subscribe(() => {
|
|
316
|
-
returnAudioTranscoder.stop();
|
|
317
|
-
returnAudioTranscodedSplitter.close();
|
|
318
|
-
});
|
|
319
|
-
returnAudioPort = yield returnAudioTranscoder.start();
|
|
320
|
-
}
|
|
321
|
-
let videoSsrc = ringRtpDescription.video.ssrc;
|
|
322
|
-
if (videoSsrc) {
|
|
323
|
-
// Server supported ICE, which means response SDP included SSRC
|
|
324
|
-
(0, util_1.logInfo)(`Stream Prepared for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
// Server uses RTP latching. Need to wait for first packet to determine SSRC
|
|
328
|
-
// NOTE: we could avoid this if we want to decrypt/re-encrypt each packets with a new SSRC
|
|
329
|
-
(0, util_1.logInfo)(`Waiting for stream data from ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
|
|
330
|
-
videoSsrc = yield videoSsrcPromise;
|
|
331
|
-
}
|
|
332
|
-
if (!videoSsrc) {
|
|
333
|
-
// failed to get video packet
|
|
334
|
-
(0, util_1.logInfo)(`Stream was closed before it was ready for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
|
|
335
|
-
return callback(undefined);
|
|
336
|
-
}
|
|
353
|
+
const liveCall = yield this.ringCamera.startLiveCall(), session = new StreamingSession(liveCall, request, this.ringCamera, start);
|
|
354
|
+
this.sessions[request.sessionID] = session;
|
|
355
|
+
(0, util_1.logInfo)(`Stream Prepared for ${this.ringCamera.name} (${getDurationSeconds(start)}s)`);
|
|
337
356
|
callback(undefined, {
|
|
338
357
|
// SOMEDAY: remove address as it is not needed after homebridge 1.1.3
|
|
339
358
|
address: yield (0, camera_utils_1.getDefaultIpAddress)(request.addressVersion === 'ipv6'),
|
|
340
359
|
audio: {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
srtp_salt: audioSrtpSalt,
|
|
360
|
+
port: yield session.audioSplitter.portPromise,
|
|
361
|
+
ssrc: session.audioSsrc,
|
|
362
|
+
srtp_key: session.audioSrtp.srtpKey,
|
|
363
|
+
srtp_salt: session.audioSrtp.srtpSalt,
|
|
346
364
|
},
|
|
347
365
|
video: {
|
|
348
|
-
port: yield
|
|
349
|
-
ssrc: videoSsrc,
|
|
350
|
-
srtp_key:
|
|
351
|
-
srtp_salt:
|
|
366
|
+
port: yield session.videoSplitter.portPromise,
|
|
367
|
+
ssrc: session.videoSsrc,
|
|
368
|
+
srtp_key: session.videoSrtp.srtpKey,
|
|
369
|
+
srtp_salt: session.videoSrtp.srtpSalt,
|
|
352
370
|
},
|
|
353
371
|
});
|
|
354
372
|
}
|
|
@@ -360,22 +378,24 @@ class CameraSource {
|
|
|
360
378
|
});
|
|
361
379
|
}
|
|
362
380
|
handleStreamRequest(request, callback) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
382
|
+
const sessionID = request.sessionID, session = this.sessions[sessionID], requestType = request.type;
|
|
383
|
+
if (!session) {
|
|
384
|
+
callback(new Error('Cannot find session for stream ' + sessionID));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (requestType === 'start') {
|
|
388
|
+
(0, util_1.logInfo)(`Activating stream for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
|
|
389
|
+
yield session.activate(request);
|
|
390
|
+
(0, util_1.logInfo)(`Streaming active for ${this.ringCamera.name} (${getDurationSeconds(session.start)}s)`);
|
|
391
|
+
}
|
|
392
|
+
else if (requestType === 'stop') {
|
|
393
|
+
(0, util_1.logInfo)(`Stopped Live Stream for ${this.ringCamera.name}`);
|
|
394
|
+
session.stop();
|
|
395
|
+
delete this.sessions[sessionID];
|
|
396
|
+
}
|
|
397
|
+
callback();
|
|
398
|
+
});
|
|
379
399
|
}
|
|
380
400
|
}
|
|
381
401
|
exports.CameraSource = CameraSource;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ring",
|
|
3
|
-
"version": "9.25.0-beta.
|
|
3
|
+
"version": "9.25.0-beta.1",
|
|
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": {
|
|
@@ -39,8 +39,9 @@
|
|
|
39
39
|
"author": "dgreif",
|
|
40
40
|
"license": "MIT",
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@homebridge/camera-utils": "2.0
|
|
42
|
+
"@homebridge/camera-utils": "2.1.0",
|
|
43
43
|
"@homebridge/plugin-ui-utils": "0.0.19",
|
|
44
|
+
"@koush/werift": "0.14.5-beta5",
|
|
44
45
|
"@types/socket.io-client": "1.4.36",
|
|
45
46
|
"colors": "1.4.0",
|
|
46
47
|
"debug": "4.3.3",
|
|
@@ -50,18 +51,21 @@
|
|
|
50
51
|
"socket.io-client": "2.4.0",
|
|
51
52
|
"stun": "2.1.0",
|
|
52
53
|
"systeminformation": "5.10.7",
|
|
53
|
-
"uuid": "8.3.2"
|
|
54
|
+
"uuid": "8.3.2",
|
|
55
|
+
"ws": "8.4.2"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@types/debug": "4.1.7",
|
|
57
59
|
"@types/jest": "27.4.0",
|
|
58
60
|
"@types/node": "17.0.10",
|
|
59
61
|
"@types/uuid": "8.3.4",
|
|
62
|
+
"@types/ws": "^8.2.2",
|
|
60
63
|
"@typescript-eslint/eslint-plugin": "5.10.0",
|
|
61
64
|
"@typescript-eslint/parser": "5.10.0",
|
|
62
65
|
"concurrently": "^7.0.0",
|
|
63
66
|
"conventional-github-releaser": "3.1.5",
|
|
64
67
|
"dotenv": "14.2.0",
|
|
68
|
+
"esbuild": "^0.14.19",
|
|
65
69
|
"eslint": "8.7.0",
|
|
66
70
|
"eslint-config-prettier": "8.3.0",
|
|
67
71
|
"eslint-plugin-jest": "25.7.0",
|