UNPKG

@koush/ring-client-api

Version:

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

555 lines (554 loc) 24.3 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RingCamera = exports.getSearchQueryString = exports.getBatteryLevel = void 0; const ring_types_1 = require("./ring-types"); const rest_client_1 = require("./rest-client"); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const camera_utils_1 = require("@homebridge/camera-utils"); const util_1 = require("./util"); const sip_session_1 = require("./sip-session"); const subscribed_1 = require("./subscribed"); const live_call_1 = require("./live-call"); const maxSnapshotRefreshSeconds = 15, fullDayMs = 24 * 60 * 60 * 1000; function parseBatteryLife(batteryLife) { if (batteryLife === null || batteryLife === undefined) { return null; } const batteryLevel = typeof batteryLife === 'number' ? batteryLife : Number.parseFloat(batteryLife); if (isNaN(batteryLevel)) { return null; } return batteryLevel; } function getStartOfToday() { return new Date(new Date().toLocaleDateString()).getTime(); } function getEndOfToday() { return getStartOfToday() + fullDayMs - 1; } function getBatteryLevel(data) { const levels = [ parseBatteryLife(data.battery_life), parseBatteryLife(data.battery_life_2), ].filter((level) => level !== null); if (!levels.length) { return null; } return Math.min(...levels); } exports.getBatteryLevel = getBatteryLevel; function getSearchQueryString(options) { const queryString = Object.entries(options) .map(([key, value]) => { if (value === undefined) { return ''; } if (key === 'olderThanId') { key = 'pagination_key'; } return `${key}=${value}`; }) .filter((x) => x) .join('&'); return queryString.length ? `?${queryString}` : ''; } exports.getSearchQueryString = getSearchQueryString; class RingCamera extends subscribed_1.Subscribed { constructor(initialData, isDoorbot, restClient, avoidSnapshotBatteryDrain, treatKnockAsDing) { super(); this.initialData = initialData; this.isDoorbot = isDoorbot; this.restClient = restClient; this.avoidSnapshotBatteryDrain = avoidSnapshotBatteryDrain; this.treatKnockAsDing = treatKnockAsDing; this.onRequestUpdate = new rxjs_1.Subject(); this.onRequestActiveDings = new rxjs_1.Subject(); this.onNewDing = new rxjs_1.Subject(); this.onActiveDings = new rxjs_1.BehaviorSubject([]); this.onDoorbellPressed = this.onNewDing.pipe((0, operators_1.filter)((ding) => ding.kind === 'ding' || (this.treatKnockAsDing && ding.kind === 'door_activity')), (0, operators_1.share)()); this.onMotionDetected = this.onActiveDings.pipe((0, operators_1.map)((dings) => dings.some((ding) => ding.motion || ding.kind === 'motion')), (0, operators_1.distinctUntilChanged)(), (0, operators_1.publishReplay)(1), (0, operators_1.refCount)()); this.onMotionStarted = this.onMotionDetected.pipe((0, operators_1.filter)((currentlyDetected) => currentlyDetected), (0, operators_1.mapTo)(null), // no value needed, event is what matters (0, operators_1.share)()); this.expiredDingIds = []; this.lastSnapshotTimestamp = 0; this.lastSnapshotTimestampLocal = 0; this.fetchingSnapshot = false; this.id = this.initialData.id; this.deviceType = this.initialData.kind; this.model = ring_types_1.RingCameraModel[this.initialData.kind] || 'Unknown Model'; this.onData = new rxjs_1.BehaviorSubject(this.initialData); this.hasLight = this.initialData.led_status !== undefined; this.hasSiren = this.initialData.siren_status !== undefined; this.onBatteryLevel = this.onData.pipe((0, operators_1.map)(getBatteryLevel), (0, operators_1.distinctUntilChanged)()); this.onInHomeDoorbellStatus = this.onData.pipe((0, operators_1.map)(({ settings: { chime_settings } }) => { return Boolean(chime_settings === null || chime_settings === void 0 ? void 0 : chime_settings.enable); }), (0, operators_1.distinctUntilChanged)()); if (!initialData.subscribed) { this.subscribeToDingEvents().catch((e) => { (0, util_1.logError)('Failed to subscribe ' + initialData.description + ' to ding events'); (0, util_1.logError)(e); }); } if (!initialData.subscribed_motions) { this.subscribeToMotionEvents().catch((e) => { (0, util_1.logError)('Failed to subscribe ' + initialData.description + ' to motion events'); (0, util_1.logError)(e); }); } } updateData(update) { this.onData.next(update); } requestUpdate() { this.onRequestUpdate.next(null); } get data() { return this.onData.getValue(); } get name() { return this.data.description; } get activeDings() { return this.onActiveDings.getValue(); } get batteryLevel() { return getBatteryLevel(this.data); } get hasBattery() { if (this.batteryLevel === null) { return false; } return ((0, ring_types_1.isBatteryCameraKind)(this.deviceType) || (typeof this.initialData.battery_life === 'string' && this.batteryLevel < 100 && this.batteryLevel >= 0)); } get hasLowBattery() { return this.data.alerts.battery === 'low'; } get isCharging() { return this.initialData.external_connection; } get operatingOnBattery() { return this.hasBattery && this.data.settings.power_mode !== 'wired'; } get isOffline() { return this.data.alerts.connection === 'offline'; } get hasInHomeDoorbell() { const { chime_settings } = this.data.settings; return (this.isDoorbot && Boolean(chime_settings && [ring_types_1.DoorbellType.Mechanical, ring_types_1.DoorbellType.Digital].includes(chime_settings.type))); } doorbotUrl(path = '') { return (0, rest_client_1.clientApi)(`doorbots/${this.id}/${path}`); } deviceUrl(path = '') { return (0, rest_client_1.deviceApi)(`devices/${this.id}/${path}`); } setLight(on) { return __awaiter(this, void 0, void 0, function* () { if (!this.hasLight) { return false; } const state = on ? 'on' : 'off'; yield this.restClient.request({ method: 'PUT', url: this.doorbotUrl('floodlight_light_' + state), }); this.updateData(Object.assign(Object.assign({}, this.data), { led_status: state })); return true; }); } setSiren(on) { return __awaiter(this, void 0, void 0, function* () { if (!this.hasSiren) { return false; } yield this.restClient.request({ method: 'PUT', url: this.doorbotUrl('siren_' + (on ? 'on' : 'off')), }); this.updateData(Object.assign(Object.assign({}, this.data), { siren_status: { seconds_remaining: 1 } })); return true; }); } setSettings(settings) { return __awaiter(this, void 0, void 0, function* () { yield this.restClient.request({ method: 'PUT', url: this.doorbotUrl(), json: { doorbot: { settings } }, }); this.requestUpdate(); }); } setDeviceSettings(settings) { return __awaiter(this, void 0, void 0, function* () { const response = yield this.restClient.request({ method: 'PATCH', url: this.deviceUrl('settings'), json: settings, }); this.requestUpdate(); return response; }); } getDeviceSettings() { return this.restClient.request({ method: 'GET', url: this.deviceUrl('settings'), }); } // Enable or disable the in-home doorbell (if digital or mechanical) setInHomeDoorbell(enable) { return __awaiter(this, void 0, void 0, function* () { if (!this.hasInHomeDoorbell) { return false; } yield this.setSettings({ chime_settings: { enable } }); return true; }); } getHealth() { return __awaiter(this, void 0, void 0, function* () { const response = yield this.restClient.request({ url: this.doorbotUrl('health'), }); return response.device_health; }); } startLiveCallNegotiation() { return __awaiter(this, void 0, void 0, function* () { const liveCall = yield this.restClient .request({ method: 'POST', url: this.doorbotUrl('live_call'), }) .catch((e) => { var _a; if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 403) { 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.`; (0, util_1.logError)(errorMessage); throw new Error(errorMessage); } throw e; }); return liveCall.data.session_id; }); } startLiveCall() { return __awaiter(this, void 0, void 0, function* () { return new live_call_1.LiveCall(yield this.startLiveCallNegotiation(), this); }); } startVideoOnDemand() { return this.restClient .request({ method: 'POST', url: this.doorbotUrl('live_view'), // Ring app uses vod for battery cams, but doesn't appear to be necessary }) .catch((e) => { var _a; if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 403) { 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.`; (0, util_1.logError)(errorMessage); throw new Error(errorMessage); } throw e; }); } pollForActiveDing() { // try every second until a new ding is received this.addSubscriptions((0, rxjs_1.interval)(1000) .pipe((0, operators_1.takeUntil)(this.onNewDing)) .subscribe(() => { this.onRequestActiveDings.next(null); })); } getSipConnectionDetails() { return __awaiter(this, void 0, void 0, function* () { const vodPromise = (0, rxjs_1.firstValueFrom)(this.onNewDing), videoOnDemandDing = yield this.startVideoOnDemand(); if (videoOnDemandDing && 'sip_from' in videoOnDemandDing) { // wired cams return a ding from live_view so we don't need to wait return videoOnDemandDing; } // battery cams return '' from live_view so we need to request active dings and wait this.pollForActiveDing(); return vodPromise; }); } removeDingById(idToRemove) { const allActiveDings = this.activeDings, otherDings = allActiveDings.filter((ding) => ding.id_str !== idToRemove); this.onActiveDings.next(otherDings); } processActiveDing(ding) { const activeDings = this.activeDings, dingId = ding.id_str; this.onActiveDings.next(activeDings.filter((d) => d.id_str !== dingId).concat([ding])); this.onNewDing.next(ding); setTimeout(() => { this.removeDingById(ding.id_str); this.expiredDingIds = this.expiredDingIds.filter((id) => id !== dingId); }, 65 * 1000); // dings last ~1 minute } getEvents(options = {}) { return this.restClient.request({ url: (0, rest_client_1.clientApi)(`locations/${this.data.location_id}/devices/${this.id}/events${getSearchQueryString(options)}`), }); } videoSearch({ dateFrom, dateTo, order = 'asc' } = { dateFrom: getStartOfToday(), dateTo: getEndOfToday(), }) { return this.restClient.request({ url: (0, rest_client_1.clientApi)(`video_search/history?doorbot_id=${this.id}&date_from=${dateFrom}&date_to=${dateTo}&order=${order}&api_version=11&includes%5B%5D=pva`), }); } getPeriodicalFootage({ startAtMs, endAtMs } = { startAtMs: getStartOfToday(), endAtMs: getEndOfToday(), }) { // These will be mp4 clips that are created using periodic snapshots return this.restClient.request({ url: `https://api.ring.com/recordings/public/footages/${this.id}?start_at_ms=${startAtMs}&end_at_ms=${endAtMs}&kinds=online_periodical&kinds=offline_periodical`, }); } getRecordingUrl(dingIdStr, { transcoded = false } = {}) { return __awaiter(this, void 0, void 0, function* () { const path = transcoded ? 'recording' : 'share/play', response = yield this.restClient.request({ url: (0, rest_client_1.clientApi)(`dings/${dingIdStr}/${path}?disable_redirect=true`), }); return response.url; }); } isTimestampInLifeTime(timestampAge) { return timestampAge < this.snapshotLifeTime; } get snapshotsAreBlocked() { return this.data.settings.motion_detection_enabled === false; } get snapshotLifeTime() { return this.avoidSnapshotBatteryDrain && this.operatingOnBattery ? 600 * 1000 // battery cams only refresh timestamp every 10 minutes : 10 * 1000; // snapshot updates will be forced. Limit to 10s lifetime } get currentTimestampAge() { return Date.now() - this.lastSnapshotTimestampLocal; } get hasSnapshotWithinLifetime() { return this.isTimestampInLifeTime(this.currentTimestampAge); } checkIfSnapshotsAreBlocked() { if (this.snapshotsAreBlocked) { throw new Error(`Motion detection is disabled for ${this.name}, which prevents snapshots from this camera. This can be caused by Modes settings or by turning off the Record Motion setting.`); } if (this.isOffline) { throw new Error(`Cannot fetch snapshot for ${this.name} because it is offline`); } } shouldUseExistingSnapshotPromise() { if (this.fetchingSnapshot) { return true; } if (this.hasSnapshotWithinLifetime) { (0, util_1.logDebug)(`Snapshot for ${this.name} is still within its life time (${this.currentTimestampAge / 1000}s old)`); return true; } if (!this.avoidSnapshotBatteryDrain || !this.operatingOnBattery) { // tell the camera to update snapshot immediately. // avoidSnapshotBatteryDrain is best if you have a battery cam that you request snapshots for frequently. This can lead to battery drain if snapshot updates are forced. return false; } } getSnapshot() { return __awaiter(this, void 0, void 0, function* () { if (this.lastSnapshotPromise && this.shouldUseExistingSnapshotPromise()) { return this.lastSnapshotPromise; } this.checkIfSnapshotsAreBlocked(); this.lastSnapshotPromise = Promise.race([ this.getNextSnapshot({ afterMs: this.lastSnapshotTimestamp, force: true, }), (0, util_1.delay)(maxSnapshotRefreshSeconds * 1000).then(() => { const extraMessageForBatteryCam = this.operatingOnBattery ? '. This is normal behavior since this camera is unable to capture snapshots while streaming' : ''; throw new Error(`Snapshot for ${this.name} (${this.deviceType} - ${this.model}) failed to refresh after ${maxSnapshotRefreshSeconds} seconds${extraMessageForBatteryCam}`); }), ]); try { yield this.lastSnapshotPromise; } catch (e) { // snapshot request failed, don't use it again this.lastSnapshotPromise = undefined; throw e; } this.fetchingSnapshot = false; return this.lastSnapshotPromise; }); } getNextSnapshot({ afterMs, maxWaitMs, force, }) { return __awaiter(this, void 0, void 0, function* () { const response = yield this.restClient.request({ url: `https://app-snaps.ring.com/snapshots/next/${this.id}?extras=force`, responseType: 'buffer', searchParams: { 'after-ms': afterMs, 'max-wait-ms': maxWaitMs, extras: force ? 'force' : undefined, }, headers: { accept: 'image/jpeg', }, }), { responseTimestamp, timeMillis } = response, timestampAge = Math.abs(responseTimestamp - timeMillis); this.lastSnapshotTimestamp = timeMillis; this.lastSnapshotTimestampLocal = Date.now() - timestampAge; return response; }); } getSipOptions() { return __awaiter(this, void 0, void 0, function* () { const activeDings = this.onActiveDings.getValue(), existingDing = activeDings .filter((ding) => !this.expiredDingIds.includes(ding.id_str)) .slice() .reverse()[0], ding = existingDing || (yield this.getSipConnectionDetails()); return { to: ding.sip_to, from: ding.sip_from, dingId: ding.id_str, localIp: yield (0, camera_utils_1.getDefaultIpAddress)(), }; }); } getUpdatedSipOptions(expiredDingId) { // Got a 480 from sip session, which means it's no longer active this.expiredDingIds.push(expiredDingId); return this.getSipOptions(); } createSipSession(options = {}) { return __awaiter(this, void 0, void 0, function* () { const audioSplitter = new camera_utils_1.RtpSplitter(), audioRtcpSplitter = new camera_utils_1.RtpSplitter(), videoSplitter = new camera_utils_1.RtpSplitter(), videoRtcpSplitter = new camera_utils_1.RtpSplitter(), [sipOptions, ffmpegIsInstalled, audioPort, audioRtcpPort, videoPort, videoRtcpPort, [tlsPort],] = yield Promise.all([ this.getSipOptions(), options.skipFfmpegCheck ? Promise.resolve(true) : (0, camera_utils_1.isFfmpegInstalled)(), audioSplitter.portPromise, audioRtcpSplitter.portPromise, videoSplitter.portPromise, videoRtcpSplitter.portPromise, (0, camera_utils_1.reservePorts)({ type: 'tcp' }), ]), rtpOptions = { audio: Object.assign({ port: audioPort, rtcpPort: audioRtcpPort }, (options.audio || (0, camera_utils_1.generateSrtpOptions)())), video: Object.assign({ port: videoPort, rtcpPort: videoRtcpPort }, (options.video || (0, camera_utils_1.generateSrtpOptions)())), }; if (!ffmpegIsInstalled) { throw new Error('Ffmpeg is not installed. See https://github.com/dgreif/ring/wiki/FFmpeg for directions.'); } return new sip_session_1.SipSession(sipOptions, rtpOptions, audioSplitter, audioRtcpSplitter, videoSplitter, videoRtcpSplitter, tlsPort, this); }); } recordToFile(outputPath, duration = 30) { return __awaiter(this, void 0, void 0, function* () { const liveCall = yield this.startLiveCall(); yield liveCall.startTranscoding({ output: ['-t', duration.toString(), outputPath], }); yield (0, rxjs_1.firstValueFrom)(liveCall.onCallEnded); }); } streamVideo(ffmpegOptions) { return __awaiter(this, void 0, void 0, function* () { const sipSession = yield this.createSipSession(); yield sipSession.start(ffmpegOptions); return sipSession; }); } /** * Exchange an Offer SDP for an Answer SDP. Unknown if this endpoint supports trickle with * the same session UUID. The Answer SDP advertises trickle. Invalid SDP will result in error * 400. Calling this too often will result in what seems to be a soft lockout for 5 minutes, * resulting in error 500s. * @param session_uuid A session UUID that can be later used to end the WebRTC session. * Unknown if stopping the session is actually necessary since WebRTC knows the peer connection state. * @param sdp Offer SDP. audio channel must be set to sendrecv. * @returns Answer SDP. */ startWebRtcSession(session_uuid, sdp) { return __awaiter(this, void 0, void 0, function* () { const response = yield this.restClient.request({ method: 'POST', url: 'https://api.ring.com/integrations/v1/liveview/start', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ session_id: session_uuid, device_id: this.id, sdp: sdp, protocol: 'webrtc', }), }); return response.sdp; }); } endWebRtcSession(session_uuid) { return __awaiter(this, void 0, void 0, function* () { const response = yield this.restClient.request({ method: 'POST', url: 'https://api.ring.com/integrations/v1/liveview/end', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ session_id: session_uuid, }), }); return response.sdp; }); } subscribeToDingEvents() { return this.restClient.request({ method: 'POST', url: this.doorbotUrl('subscribe'), }); } unsubscribeFromDingEvents() { return this.restClient.request({ method: 'POST', url: this.doorbotUrl('unsubscribe'), }); } subscribeToMotionEvents() { return this.restClient.request({ method: 'POST', url: this.doorbotUrl('motions_subscribe'), }); } unsubscribeFromMotionEvents() { return this.restClient.request({ method: 'POST', url: this.doorbotUrl('motions_unsubscribe'), }); } disconnect() { this.unsubscribe(); } } exports.RingCamera = RingCamera; // SOMEDAY: extract image from video file? // ffmpeg -i input.mp4 -r 1 -f image2 image-%2d.png