UNPKG

ring-client-api

Version:

Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting

257 lines (256 loc) 9.52 kB
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?.(); } }