UNPKG

ring-client-api

Version:

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

477 lines (476 loc) 18 kB
import { RingCameraKind } from "./ring-types.js"; import { DoorbellType, PushNotificationAction, RingCameraModel, } from "./ring-types.js"; import { appApi, clientApi, deviceApi } from "./rest-client.js"; import { BehaviorSubject, firstValueFrom, ReplaySubject, Subject } from 'rxjs'; import { distinctUntilChanged, filter, map, share, startWith, throttleTime, } from 'rxjs/operators'; import { buildSearchString, delay, logDebug, logError, } from "./util.js"; import { Subscribed } from "./subscribed.js"; import { WebrtcConnection } from "./streaming/webrtc-connection.js"; import { StreamingSession } from "./streaming/streaming-session.js"; import { SimpleWebRtcSession } from "./streaming/simple-webrtc-session.js"; 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; } export function getBatteryLevel(data) { const levels = [ parseBatteryLife(data.battery_life), parseBatteryLife(data.battery_life_2), ].filter((level) => level !== null), { health } = data; if (!levels.length || (health && !health.battery_percentage && !health.battery_present && !health.second_battery_percentage)) { return null; } return Math.min(...levels); } export 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}` : ''; } export function cleanSnapshotUuid(uuid) { if (!uuid) { return uuid; } return uuid.replace(/:.*$/, ''); } const wiredModelsWithNoSnapshotDuringRecording = new Set([ RingCameraKind.doorbell_graham_cracker, ]), enabledDoorbellTypes = new Set([ DoorbellType.Mechanical, DoorbellType.Digital, ]); export class RingCamera extends Subscribed { id; deviceType; model; onData; hasLight; hasSiren; onRequestUpdate = new Subject(); onNewNotification = new Subject(); onActiveNotifications = new BehaviorSubject([]); onDoorbellPressed = this.onNewNotification.pipe(filter((notification) => notification.android_config.category === PushNotificationAction.Ding), share()); onMotionDetected = this.onActiveNotifications.pipe(map((notifications) => notifications.some((notification) => notification.android_config.category === PushNotificationAction.Motion)), distinctUntilChanged(), share({ connector: () => new ReplaySubject(1), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false, })); onMotionStarted = this.onMotionDetected.pipe(filter((currentlyDetected) => currentlyDetected), map(() => null), // no value needed, event is what matters share()); onBatteryLevel; onInHomeDoorbellStatus; initialData; isDoorbot; restClient; avoidSnapshotBatteryDrain; constructor(initialData, isDoorbot, restClient, avoidSnapshotBatteryDrain) { super(); this.initialData = initialData; this.isDoorbot = isDoorbot; this.restClient = restClient; this.avoidSnapshotBatteryDrain = avoidSnapshotBatteryDrain; this.id = this.initialData.id; this.deviceType = this.initialData.kind; this.model = RingCameraModel[this.initialData.kind] || 'Unknown Model'; this.onData = new BehaviorSubject(this.initialData); this.hasLight = this.initialData.led_status !== undefined; this.hasSiren = this.initialData.siren_status !== undefined; this.onBatteryLevel = this.onData.pipe(map((data) => { if (!('battery_life' in data)) { return null; } return getBatteryLevel(data); }), distinctUntilChanged()); this.onInHomeDoorbellStatus = this.onData.pipe(map(({ settings: { chime_settings } }) => { return Boolean(chime_settings?.enable); }), distinctUntilChanged()); this.addSubscriptions(this.restClient.onSession .pipe(startWith(undefined), throttleTime(1000)) // Force this to run immediately, but don't double run if a session is created due to these api calls .subscribe(() => { this.subscribeToDingEvents().catch((e) => { logError('Failed to subscribe ' + initialData.description + ' to ding events'); logError(e); }); this.subscribeToMotionEvents().catch((e) => { logError('Failed to subscribe ' + initialData.description + ' to motion events'); 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 activeNotifications() { return this.onActiveNotifications.getValue(); } get latestNotification() { const notifications = this.activeNotifications; return notifications[notifications.length - 1]; } get latestNotificationSnapshotUuid() { const notification = this.latestNotification; return notification?.img?.snapshot_uuid; } get batteryLevel() { if (!('battery_life' in this.data)) { return null; } return getBatteryLevel(this.data); } get hasBattery() { return this.batteryLevel !== null; } get hasLowBattery() { return this.data.alerts.battery === 'low'; } get isCharging() { if (!('external_connection' in this.data)) { return false; } return this.data.external_connection; } get operatingOnBattery() { return this.hasBattery && this.data.settings.power_mode !== 'wired'; } get canTakeSnapshotWhileRecording() { return (!this.operatingOnBattery && !wiredModelsWithNoSnapshotDuringRecording.has(this.data.kind)); } get isOffline() { return this.data.alerts.connection === 'offline'; } get isRingEdgeEnabled() { return this.data.settings.sheila_settings.local_storage_enabled === true; } get hasInHomeDoorbell() { const { chime_settings } = this.data.settings; return (this.isDoorbot && Boolean(chime_settings && enabledDoorbellTypes.has(chime_settings.type))); } doorbotUrl(path = '') { return clientApi(`doorbots/${this.id}/${path}`); } deviceUrl(path = '') { return deviceApi(`devices/${this.id}/${path}`); } async setLight(on) { if (!this.hasLight) { return false; } const state = on ? 'on' : 'off'; await this.restClient.request({ method: 'PUT', url: this.doorbotUrl('floodlight_light_' + state), }); this.updateData({ ...this.data, led_status: state }); return true; } async setSiren(on) { if (!this.hasSiren) { return false; } await this.restClient.request({ method: 'PUT', url: this.doorbotUrl('siren_' + (on ? 'on' : 'off')), }); const seconds = on ? 1 : 0; this.updateData({ ...this.data, siren_status: { seconds_remaining: seconds }, }); return true; } async setSettings(settings) { await this.restClient.request({ method: 'PUT', url: this.doorbotUrl(), json: { doorbot: { settings } }, }); this.requestUpdate(); } async setDeviceSettings(settings) { const response = await 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) async setInHomeDoorbell(enable) { if (!this.hasInHomeDoorbell) { return false; } await this.setSettings({ chime_settings: { enable } }); return true; } async getHealth() { const response = await this.restClient.request({ url: this.doorbotUrl('health'), }); return response.device_health; } async createStreamingConnection(options) { const response = await this.restClient .request({ method: 'POST', url: appApi('clap/ticket/request/signalsocket'), }) .catch((e) => { throw e; }); return new WebrtcConnection(response.ticket, this, options); } async startLiveCall(options = {}) { const connection = await this.createStreamingConnection(options); return new StreamingSession(this, connection); } removeDingById(idToRemove) { const allActiveDings = this.activeNotifications, otherDings = allActiveDings.filter(({ data }) => data.event.ding.id !== idToRemove); this.onActiveNotifications.next(otherDings); } processPushNotification(notification) { if (!('android_config' in notification) || !('event' in notification.data) || !('ding' in (notification.data?.event ?? {}))) { // only process ding/motion notifications return; } const activeDings = this.activeNotifications, dingId = notification.data.event.ding.id; this.onActiveNotifications.next(activeDings .filter((d) => d.data.event.ding.id !== dingId) .concat([notification])); this.onNewNotification.next(notification); setTimeout(() => { this.removeDingById(dingId); }, 65 * 1000); // dings last ~1 minute } getEvents(options = {}) { return this.restClient.request({ url: 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: 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`, }); } async getRecordingUrl(dingIdStr, { transcoded = false } = {}) { const path = transcoded ? 'recording' : 'share/play', response = await this.restClient.request({ url: 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 } lastSnapshotTimestamp = 0; lastSnapshotTimestampLocal = 0; lastSnapshotPromise; 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) { 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; } } fetchingSnapshot = false; async getSnapshot({ uuid } = {}) { if (this.lastSnapshotPromise && this.shouldUseExistingSnapshotPromise()) { return this.lastSnapshotPromise; } this.checkIfSnapshotsAreBlocked(); this.lastSnapshotPromise = Promise.race([ this.getNextSnapshot(uuid ? { uuid } : { afterMs: this.lastSnapshotTimestamp, force: true, }), delay(maxSnapshotRefreshSeconds * 1000).then(() => { const extraMessageForBatteryCam = !this.canTakeSnapshotWhileRecording ? '. 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 { await this.lastSnapshotPromise; } catch (e) { // snapshot request failed, don't use it again this.lastSnapshotPromise = undefined; throw e; } this.fetchingSnapshot = false; return this.lastSnapshotPromise; } async getNextSnapshot({ afterMs, maxWaitMs, force, uuid, }) { const response = await this.restClient.request({ url: `https://app-snaps.ring.com/snapshots/next/${this.id}${buildSearchString({ 'after-ms': afterMs, 'max-wait-ms': maxWaitMs, extras: force ? 'force' : undefined, uuid: cleanSnapshotUuid(uuid), })}`, responseType: 'buffer', headers: { accept: 'image/jpeg', }, allowNoResponse: true, }), { responseTimestamp, timeMillis } = response, timestampAge = Math.abs(responseTimestamp - timeMillis); this.lastSnapshotTimestamp = timeMillis; this.lastSnapshotTimestampLocal = Date.now() - timestampAge; return response; } getSnapshotByUuid(uuid) { return this.restClient.request({ url: clientApi('snapshots/uuid?uuid=' + cleanSnapshotUuid(uuid)), responseType: 'buffer', headers: { accept: 'image/jpeg', }, }); } async recordToFile(outputPath, duration = 30) { const liveCall = await this.streamVideo({ output: ['-t', duration.toString(), outputPath], }); await firstValueFrom(liveCall.onCallEnded); } async streamVideo(ffmpegOptions) { const liveCall = await this.startLiveCall(); await liveCall.startTranscoding(ffmpegOptions); return liveCall; } /** * Returns a SimpleWebRtcSession, which can be initiated with an sdp offer. * This session has no backplane for trickle ICE, and is designed for use in a * browser setting. Note, cameras with Ring Edge enabled will stream with the speaker * enabled as soon as the stream starts, which can drain the battery more quickly. */ createSimpleWebRtcSession() { return new SimpleWebRtcSession(this, this.restClient); } 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(); } }