ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
257 lines (256 loc) • 9.52 kB
JavaScript
import { WebSocket } from 'undici';
import { firstValueFrom, fromEvent, interval, ReplaySubject, Subject, } from 'rxjs';
import { concatMap, take } from 'rxjs/operators';
import { generateUuid, logDebug, logError, logInfo } from "../util.js";
import { WeriftPeerConnection, } from "./peer-connection.js";
import { Subscribed } from "../subscribed.js";
export class WebrtcConnection extends Subscribed {
onSessionId = new ReplaySubject(1);
onOfferSent = new ReplaySubject(1);
dialogId = generateUuid();
onCameraConnected = new ReplaySubject(1);
onCallAnswered = new ReplaySubject(1);
onCallEnded = new ReplaySubject(1);
onError = new ReplaySubject(1);
onMessage = new ReplaySubject();
onWsOpen;
onAudioRtp;
onVideoRtp;
pc;
ws;
camera;
constructor(ticket, camera, options) {
super();
this.camera = camera;
this.ws = new WebSocket(`wss://api.prod.signalling.ring.devices.a2z.com:443/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-${generateUuid()}&token=${ticket}`, {
headers: {
// This must exist or the socket will close immediately but content does not seem to matter
'User-Agent': 'android:com.ringapp',
},
});
if (options.createPeerConnection) {
// we were passed a custom peer connection factory
this.pc = options.createPeerConnection();
// passing rtp packets is not supported for custom peer connections
this.onAudioRtp = new Subject();
this.onVideoRtp = new Subject();
}
else {
// no custom peer connection factory, use the werift and pass along rtp packets
const pc = new WeriftPeerConnection();
this.pc = pc;
this.onAudioRtp = pc.onAudioRtp;
this.onVideoRtp = pc.onVideoRtp;
}
this.onWsOpen = fromEvent(this.ws, 'open');
const onMessage = fromEvent(this.ws, 'message'), onError = fromEvent(this.ws, 'error'), onClose = fromEvent(this.ws, 'close');
this.addSubscriptions(onMessage
.pipe(concatMap((event) => {
const message = JSON.parse(event.data);
this.onMessage.next(message);
return this.handleMessage(message).catch((e) => {
if (e instanceof Error &&
e.message.includes('negotiate codecs failed')) {
e = new Error('Failed to negotiate codecs. This is a known issue with Ring cameras. Please see https://github.com/dgreif/ring/wiki/Streaming-Legacy-Mode');
}
this.onError.next(e);
throw e;
});
}))
.subscribe(), onError.subscribe((e) => {
logError(e);
this.callEnded();
}), onClose.subscribe(() => {
this.callEnded();
}), this.pc.onConnectionState.subscribe((state) => {
if (state === 'failed') {
logError('Stream connection failed');
this.callEnded();
}
if (state === 'closed') {
logDebug('Stream connection closed');
this.callEnded();
}
}), this.onError.subscribe((e) => {
logError(e);
this.callEnded();
}), this.onWsOpen.subscribe(() => {
const connectionType = camera.isRingEdgeEnabled ? 'Ring Edge' : 'Cloud';
logDebug(`WebSocket connected for ${camera.name} (${connectionType})`);
this.initiateCall().catch((e) => {
logError(e);
this.callEnded();
});
}),
// The ring-edge session needs a ping every 5 seconds to keep the connection alive
interval(5000).subscribe(() => {
this.sendSessionMessage('ping');
}), this.pc.onIceCandidate.subscribe(async (iceCandidate) => {
await firstValueFrom(this.onOfferSent);
this.sendMessage({
method: 'ice',
dialog_id: this.dialogId,
body: {
doorbot_id: camera.id,
ice: iceCandidate.candidate,
mlineindex: iceCandidate.sdpMLineIndex,
},
});
}));
}
async initiateCall() {
const { sdp } = await this.pc.createOffer();
this.sendMessage({
method: 'live_view',
dialog_id: this.dialogId,
body: {
doorbot_id: this.camera.id,
stream_options: { audio_enabled: true, video_enabled: true },
sdp,
},
});
this.onOfferSent.next();
}
sessionId = null;
async handleMessage(message) {
if (message.body.doorbot_id !== this.camera.id) {
// ignore messages for other cameras
return;
}
if (['session_created', 'session_started'].includes(message.method) &&
'session_id' in message.body &&
!this.sessionId) {
this.sessionId = message.body.session_id;
this.onSessionId.next(this.sessionId);
}
if (message.body.session_id && message.body.session_id !== this.sessionId) {
// ignore messages for other sessions
return;
}
switch (message.method) {
case 'session_created':
case 'session_started':
// session already stored above
return;
case 'sdp':
await this.pc.acceptAnswer(message.body);
this.onCallAnswered.next(message.body.sdp);
this.activate();
return;
case 'ice':
await this.pc.addIceCandidate({
candidate: message.body.ice,
sdpMLineIndex: message.body.mlineindex,
});
return;
case 'pong':
return;
case 'notification': {
const { text } = message.body;
if (text === 'camera_connected') {
this.onCameraConnected.next();
return;
}
else if (text === 'PeerConnectionState::kConnecting' ||
text === 'PeerConnectionState::kConnected') {
return;
}
break;
}
case 'close':
logError('Video stream closed');
logError(message.body);
this.callEnded();
return;
case 'camera_started':
case 'stream_info':
// ignore these messages as we don't use them
return;
}
logError('UNKNOWN MESSAGE');
logError(message);
}
sendSessionMessage(method, body = {}) {
const sendSessionMessage = (sessionId) => {
const message = {
method,
dialog_id: this.dialogId,
body: {
...body,
doorbot_id: this.camera.id,
session_id: sessionId,
},
};
this.sendMessage(message);
};
if (this.sessionId) {
// Send immediately if we already have a session id
// This is needed to send `close` before closing the websocket
sendSessionMessage(this.sessionId);
}
else {
// Otherwise wait for the session id to be set and then send the message
this.addSubscriptions(this.onSessionId.pipe(take(1)).subscribe(sendSessionMessage));
}
}
sendMessage(message) {
if (this.hasEnded) {
return;
}
this.ws.send(JSON.stringify(message));
}
sendAudioPacket(rtp) {
if (this.hasEnded) {
return;
}
if (this.pc instanceof WeriftPeerConnection) {
this.pc.returnAudioTrack.writeRtp(rtp);
}
else {
throw new Error('Cannot send audio packets to a custom peer connection implementation');
}
}
activate() {
logInfo('Activating Session');
// the activate_session message is required to keep the stream alive longer than 70 seconds
this.sendSessionMessage('activate_session');
this.sendSessionMessage('stream_options', {
audio_enabled: true,
video_enabled: true,
});
}
activateCameraSpeaker() {
// Fire and forget this call so that callers don't get hung up waiting for answer (which might not happen)
this.addSubscriptions(this.onCameraConnected.pipe(take(1)).subscribe(() => {
this.sendSessionMessage('camera_options', {
stealth_mode: false,
});
}));
}
hasEnded = false;
callEnded() {
if (this.hasEnded) {
return;
}
try {
this.sendMessage({
reason: { code: 0, text: '' },
method: 'close',
});
this.ws.close();
}
catch (_) {
// ignore any errors since we are stopping the call
}
this.hasEnded = true;
this.unsubscribe();
this.onCallEnded.next();
this.pc.close();
}
stop() {
this.callEnded();
}
requestKeyFrame() {
this.pc.requestKeyFrame?.();
}
}