@koush/ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
770 lines (659 loc) • 20.7 kB
text/typescript
import {
ActiveDing,
CameraData,
CameraDeviceSettingsData,
CameraEventOptions,
CameraEventResponse,
CameraHealth,
DoorbellType,
HistoryOptions,
isBatteryCameraKind,
LiveCallResponse,
PeriodicFootageResponse,
RingCameraModel,
VideoSearchResponse,
} from './ring-types'
import { clientApi, deviceApi, RingRestClient } from './rest-client'
import { BehaviorSubject, interval, firstValueFrom, Subject } from 'rxjs'
import {
distinctUntilChanged,
filter,
map,
mapTo,
publishReplay,
refCount,
share,
takeUntil,
} from 'rxjs/operators'
import {
generateSrtpOptions,
getDefaultIpAddress,
isFfmpegInstalled,
reservePorts,
RtpSplitter,
SrtpOptions,
} from '@homebridge/camera-utils'
import { DeepPartial, delay, logDebug, logError } from './util'
import { FfmpegOptions, SipSession } from './sip-session'
import { SipOptions } from './sip-call'
import { Subscribed } from './subscribed'
import { LiveCall } from './live-call'
const maxSnapshotRefreshSeconds = 15,
fullDayMs = 24 * 60 * 60 * 1000
function parseBatteryLife(batteryLife: string | number | null | undefined) {
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: Pick<CameraData, 'battery_life' | 'battery_life_2'>
) {
const levels = [
parseBatteryLife(data.battery_life),
parseBatteryLife(data.battery_life_2),
].filter((level): level is number => level !== null)
if (!levels.length) {
return null
}
return Math.min(...levels)
}
export function getSearchQueryString(
options: CameraEventOptions | (HistoryOptions & { accountId: string })
) {
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 class RingCamera extends Subscribed {
id
deviceType
model
onData
hasLight
hasSiren
onRequestUpdate = new Subject()
onRequestActiveDings = new Subject()
onNewDing = new Subject<ActiveDing>()
onActiveDings = new BehaviorSubject<ActiveDing[]>([])
onDoorbellPressed = this.onNewDing.pipe(
filter(
(ding) =>
ding.kind === 'ding' ||
(this.treatKnockAsDing && ding.kind === 'door_activity')
),
share()
)
onMotionDetected = this.onActiveDings.pipe(
map((dings) => dings.some((ding) => ding.motion || ding.kind === 'motion')),
distinctUntilChanged(),
publishReplay(1),
refCount()
)
onMotionStarted = this.onMotionDetected.pipe(
filter((currentlyDetected) => currentlyDetected),
mapTo(null), // no value needed, event is what matters
share()
)
onBatteryLevel
onInHomeDoorbellStatus
constructor(
private initialData: CameraData,
public isDoorbot: boolean,
private restClient: RingRestClient,
private avoidSnapshotBatteryDrain: boolean,
private treatKnockAsDing: boolean
) {
super()
this.id = this.initialData.id
this.deviceType = this.initialData.kind
this.model = RingCameraModel[this.initialData.kind] || 'Unknown Model'
this.onData = new BehaviorSubject<CameraData>(this.initialData)
this.hasLight = this.initialData.led_status !== undefined
this.hasSiren = this.initialData.siren_status !== undefined
this.onBatteryLevel = this.onData.pipe(
map(getBatteryLevel),
distinctUntilChanged()
)
this.onInHomeDoorbellStatus = this.onData.pipe(
map(({ settings: { chime_settings } }: CameraData) => {
return Boolean(chime_settings?.enable)
}),
distinctUntilChanged()
)
if (!initialData.subscribed) {
this.subscribeToDingEvents().catch((e) => {
logError(
'Failed to subscribe ' + initialData.description + ' to ding events'
)
logError(e)
})
}
if (!initialData.subscribed_motions) {
this.subscribeToMotionEvents().catch((e) => {
logError(
'Failed to subscribe ' + initialData.description + ' to motion events'
)
logError(e)
})
}
}
updateData(update: CameraData) {
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 (
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 &&
[DoorbellType.Mechanical, DoorbellType.Digital].includes(
chime_settings.type
)
)
)
}
doorbotUrl(path = '') {
return clientApi(`doorbots/${this.id}/${path}`)
}
deviceUrl(path = '') {
return deviceApi(`devices/${this.id}/${path}`)
}
async setLight(on: boolean) {
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: boolean) {
if (!this.hasSiren) {
return false
}
await this.restClient.request({
method: 'PUT',
url: this.doorbotUrl('siren_' + (on ? 'on' : 'off')),
})
this.updateData({ ...this.data, siren_status: { seconds_remaining: 1 } })
return true
}
async setSettings(settings: DeepPartial<CameraData['settings']>) {
await this.restClient.request({
method: 'PUT',
url: this.doorbotUrl(),
json: { doorbot: { settings } },
})
this.requestUpdate()
}
async setDeviceSettings(settings: DeepPartial<CameraDeviceSettingsData>) {
const response = await this.restClient.request<CameraDeviceSettingsData>({
method: 'PATCH',
url: this.deviceUrl('settings'),
json: settings,
})
this.requestUpdate()
return response
}
getDeviceSettings() {
return this.restClient.request<CameraDeviceSettingsData>({
method: 'GET',
url: this.deviceUrl('settings'),
})
}
// Enable or disable the in-home doorbell (if digital or mechanical)
async setInHomeDoorbell(enable: boolean) {
if (!this.hasInHomeDoorbell) {
return false
}
await this.setSettings({ chime_settings: { enable } })
return true
}
async getHealth() {
const response = await this.restClient.request<{
device_health: CameraHealth
}>({
url: this.doorbotUrl('health'),
})
return response.device_health
}
async startLiveCallNegotiation() {
const liveCall = await this.restClient
.request<LiveCallResponse>({
method: 'POST',
url: this.doorbotUrl('live_call'),
})
.catch((e) => {
if (e.response?.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.`
logError(errorMessage)
throw new Error(errorMessage)
}
throw e
})
return liveCall.data.session_id;
}
async startLiveCall() {
return new LiveCall(await this.startLiveCallNegotiation(), this)
}
startVideoOnDemand() {
return this.restClient
.request<ActiveDing | ''>({
method: 'POST',
url: this.doorbotUrl('live_view'), // Ring app uses vod for battery cams, but doesn't appear to be necessary
})
.catch((e) => {
if (e.response?.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.`
logError(errorMessage)
throw new Error(errorMessage)
}
throw e
})
}
private pollForActiveDing() {
// try every second until a new ding is received
this.addSubscriptions(
interval(1000)
.pipe(takeUntil(this.onNewDing))
.subscribe(() => {
this.onRequestActiveDings.next(null)
})
)
}
private expiredDingIds: string[] = []
async getSipConnectionDetails() {
const vodPromise = firstValueFrom(this.onNewDing),
videoOnDemandDing = await 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
}
private removeDingById(idToRemove: string) {
const allActiveDings = this.activeDings,
otherDings = allActiveDings.filter((ding) => ding.id_str !== idToRemove)
this.onActiveDings.next(otherDings)
}
processActiveDing(ding: ActiveDing) {
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: CameraEventOptions = {}) {
return this.restClient.request<CameraEventResponse>({
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<VideoSearchResponse>({
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<PeriodicFootageResponse>({
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: string, { transcoded = false } = {}) {
const path = transcoded ? 'recording' : 'share/play',
response = await this.restClient.request<{ url: string }>({
url: clientApi(`dings/${dingIdStr}/${path}?disable_redirect=true`),
})
return response.url
}
private isTimestampInLifeTime(timestampAge: number) {
return timestampAge < this.snapshotLifeTime
}
public get snapshotsAreBlocked() {
return this.data.settings.motion_detection_enabled === false
}
public 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
}
private lastSnapshotTimestamp = 0
private lastSnapshotTimestampLocal = 0
private lastSnapshotPromise?: Promise<Buffer>
get currentTimestampAge() {
return Date.now() - this.lastSnapshotTimestampLocal
}
get hasSnapshotWithinLifetime() {
return this.isTimestampInLifeTime(this.currentTimestampAge)
}
private 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`
)
}
}
private 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
}
}
private fetchingSnapshot = false
async getSnapshot() {
if (this.lastSnapshotPromise && this.shouldUseExistingSnapshotPromise()) {
return this.lastSnapshotPromise
}
this.checkIfSnapshotsAreBlocked()
this.lastSnapshotPromise = Promise.race([
this.getNextSnapshot({
afterMs: this.lastSnapshotTimestamp,
force: true,
}),
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 {
await this.lastSnapshotPromise
} catch (e) {
// snapshot request failed, don't use it again
this.lastSnapshotPromise = undefined
throw e
}
this.fetchingSnapshot = false
return this.lastSnapshotPromise
}
public async getNextSnapshot({
afterMs,
maxWaitMs,
force,
}: {
afterMs?: number
maxWaitMs?: number
force?: boolean
}) {
const response = await this.restClient.request<Buffer>({
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
}
async getSipOptions(): Promise<SipOptions> {
const activeDings = this.onActiveDings.getValue(),
existingDing = activeDings
.filter((ding) => !this.expiredDingIds.includes(ding.id_str))
.slice()
.reverse()[0],
ding = existingDing || (await this.getSipConnectionDetails())
return {
to: ding.sip_to,
from: ding.sip_from,
dingId: ding.id_str,
localIp: await getDefaultIpAddress(),
}
}
getUpdatedSipOptions(expiredDingId: string) {
// Got a 480 from sip session, which means it's no longer active
this.expiredDingIds.push(expiredDingId)
return this.getSipOptions()
}
async createSipSession(
options: {
audio?: SrtpOptions
video?: SrtpOptions
skipFfmpegCheck?: boolean
} = {}
) {
const audioSplitter = new RtpSplitter(),
audioRtcpSplitter = new RtpSplitter(),
videoSplitter = new RtpSplitter(),
videoRtcpSplitter = new RtpSplitter(),
[
sipOptions,
ffmpegIsInstalled,
audioPort,
audioRtcpPort,
videoPort,
videoRtcpPort,
[tlsPort],
] = await Promise.all([
this.getSipOptions(),
options.skipFfmpegCheck ? Promise.resolve(true) : isFfmpegInstalled(),
audioSplitter.portPromise,
audioRtcpSplitter.portPromise,
videoSplitter.portPromise,
videoRtcpSplitter.portPromise,
reservePorts({ type: 'tcp' }),
]),
rtpOptions = {
audio: {
port: audioPort,
rtcpPort: audioRtcpPort,
...(options.audio || generateSrtpOptions()),
},
video: {
port: videoPort,
rtcpPort: videoRtcpPort,
...(options.video || generateSrtpOptions()),
},
}
if (!ffmpegIsInstalled) {
throw new Error(
'Ffmpeg is not installed. See https://github.com/dgreif/ring/wiki/FFmpeg for directions.'
)
}
return new SipSession(
sipOptions,
rtpOptions,
audioSplitter,
audioRtcpSplitter,
videoSplitter,
videoRtcpSplitter,
tlsPort,
this
)
}
async recordToFile(outputPath: string, duration = 30) {
const liveCall = await this.startLiveCall()
await liveCall.startTranscoding({
output: ['-t', duration.toString(), outputPath],
})
await firstValueFrom(liveCall.onCallEnded)
}
async streamVideo(ffmpegOptions: FfmpegOptions) {
const sipSession = await this.createSipSession()
await 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.
*/
async startWebRtcSession(session_uuid: string, sdp: string): Promise<string> {
const response = await this.restClient.request<any>({
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
}
async endWebRtcSession(session_uuid: string): Promise<string> {
const response = await this.restClient.request<any>({
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()
}
}
// SOMEDAY: extract image from video file?
// ffmpeg -i input.mp4 -r 1 -f image2 image-%2d.png