diff --git a/api/api.ts b/api/api.ts index 29b35229..91f89f08 100644 --- a/api/api.ts +++ b/api/api.ts @@ -69,7 +69,14 @@ export class RingApi { updateReceived$ = new Subject(), pollForStatusUpdate$ = cameraStatusPollingSeconds ? updateReceived$.pipe(debounceTime(cameraStatusPollingSeconds * 1000)) - : EMPTY + : EMPTY, + camerasById = cameras.reduce( + (byId, camera) => { + byId[camera.id] = camera + return byId + }, + {} as { [id: number]: RingCamera } + ) if (!cameras.length) { return @@ -91,7 +98,7 @@ export class RingApi { } cameraData.forEach(data => { - const camera = cameras.find(x => x.id === data.id) + const camera = camerasById[data.id] if (camera) { camera.updateData(data) } @@ -119,11 +126,12 @@ export class RingApi { return } - cameras.forEach(camera => - camera.processActiveDings( - activeDings.filter(ding => ding.doorbot_id === camera.id) - ) - ) + activeDings.forEach(activeDing => { + const camera = camerasById[activeDing.doorbot_id] + if (camera) { + camera.processActiveDing(activeDing) + } + }) }) poolForActiveDings$.next() // kick off pooling diff --git a/api/ring-camera.ts b/api/ring-camera.ts index 7bf133ff..f56c0dc5 100644 --- a/api/ring-camera.ts +++ b/api/ring-camera.ts @@ -2,7 +2,8 @@ import { ActiveDing, CameraData, CameraHealth, - HistoricalDingGlobal + HistoricalDingGlobal, + SnapshotTimestamp } from './ring-types' import { clientApi, RingRestClient } from './rest-client' import { BehaviorSubject, Subject } from 'rxjs' @@ -11,8 +12,13 @@ import { filter, map, publishReplay, - refCount + refCount, + take } from 'rxjs/operators' +import { delay, logError } from './util' + +const maxSnapshotRefreshAttempts = 60, + snapshotRefreshDelay = 500 export class RingCamera { id = this.initialData.id @@ -114,18 +120,26 @@ export class RingCamera { }) } - processActiveDings(dings: ActiveDing[]) { - if (!dings.length) { - return - } + async getSipConnectionDetails() { + const vodPromise = this.onNewDing + .pipe( + filter(x => x.kind === 'on_demand'), + take(1) + ) + .toPromise() + await this.startVideoOnDemand() + return vodPromise + } - dings.forEach(ding => this.onNewDing.next(ding)) + processActiveDing(ding: ActiveDing) { + const activeDings = this.activeDings - this.onActiveDings.next(this.activeDings.concat(dings)) + this.onNewDing.next(ding) + this.onActiveDings.next(activeDings.concat([ding])) setTimeout(() => { - const allActiveDings = this.onActiveDings.getValue(), - otherDings = allActiveDings.filter(ding => !dings.includes(ding)) + const allActiveDings = this.activeDings, + otherDings = allActiveDings.filter(oldDing => oldDing !== ding) this.onActiveDings.next(otherDings) }, 65 * 1000) // dings last ~1 minute } @@ -144,12 +158,56 @@ export class RingCamera { return response.url } + private async updateTimestamp() { + const response = await this.restClient.request<{ + timestamps: SnapshotTimestamp[] + }>({ + url: clientApi(`snapshots/timestamps`), + method: 'POST', + data: { + doorbot_ids: [this.id] + }, + json: true + }), + timestamp = response.timestamps[0] + + return timestamp ? timestamp.timestamp : 0 + } + + private refreshSnapshotInProgress?: Promise + + private async refreshSnapshot() { + const initialTimestamp = await this.updateTimestamp() + + for (let i = 0; i < maxSnapshotRefreshAttempts; i++) { + await delay(snapshotRefreshDelay) + + const newTimestamp = await this.updateTimestamp() + if (newTimestamp > initialTimestamp) { + return + } + } + + throw new Error( + `Snapshot failed to refresh after ${maxSnapshotRefreshAttempts} attempts` + ) + } + async getSnapshot() { - const response = await this.restClient.request({ + this.refreshSnapshotInProgress = + this.refreshSnapshotInProgress || this.refreshSnapshot() + + try { + await this.refreshSnapshotInProgress + } catch (e) { + logError(e) + } + + this.refreshSnapshotInProgress = undefined + + return this.restClient.request({ url: clientApi(`snapshots/image/${this.id}`), responseType: 'arraybuffer' }) - - return response } } diff --git a/api/ring-types.ts b/api/ring-types.ts index 55d62b12..b4914874 100644 --- a/api/ring-types.ts +++ b/api/ring-types.ts @@ -356,3 +356,8 @@ export interface ActiveDing { sip_token: string sip_ding_id: string } + +export interface SnapshotTimestamp { + timestamp: number + doorbot_id: number +} diff --git a/homebridge/README.md b/homebridge/README.md index e13fbf12..01f2695e 100644 --- a/homebridge/README.md +++ b/homebridge/README.md @@ -69,16 +69,29 @@ light/siren status do not update in real time and need to be requested periodica `cameraDingsPollingSeconds`: How frequently to poll for new events from your cameras. These include motion and doorbell presses. Defaults to `5` -### Supported Devices - * Cameras (Experimental) - * Does **not** currently have a camera feed. I am actively working on this functionality. - * Motion Sensor - Requires motion alerts to be on for the camera in the Ring App. If you have - motion snooze or a motion schedule enabled, you will not receive motion events via HomeKit either. - * Light On/Off (if equipped) - * Siren On/Off (if equipped) - * Doorbell presses - Requires ring alerts to be on for the camera in the Ring App. - * TODO: Battery - * TODO: Snapshots and live streams +### Camera Setup + +This plugin will connect all of your Ring cameras to homebridge, but they require a little extra work to get set up. +Don't worry, it's really easy. Due to homebridge/HAP limitations, the cameras cannot be added through a bridge and must be added as individual devices. +Configure the homebridge plugin like normal, then click on the "+" in the upper right in +the Home app, then "Don't have a Code or Can't Scan?", then you should see the cameras listed as individual devices which +which you can add. The code that you need for each is the same code you used when setting up homebridge. It should be in +the output when you start homebridge, or in your homebridge `config.js` file. +Walk through the setup pages and when you are done, you should see several devices related to the camera: + + * Camera Feed + * Motion Sensor + * Light (if camera is equipped) + * Siren Switch (if camera is equipped) + +**Please Note - there is not a live feed, just snapshots from the camera.** The snapshots work great for seeing who is +at the door, or what's going on when motion is detected. Live feeds are much more complicated to implement and +are not functional at this time. Please see https://github.com/dgreif/ring-alarm/issues/35 if you want more details. + +If you turn on notifications for the motion sensors, or for any doorbell camera, you will get rich notifications from +HomeKit with a snapshot from the camera + +### Supported Devices via Ring Alarm and Ring Smart Lighting Hubs * Security Panel * This is a software device that represents the alarm for a Ring location * Arm Home / Arm Away / Disarm alarm for Ring location. diff --git a/homebridge/camera-source.ts b/homebridge/camera-source.ts index 64787351..2cef2c9e 100644 --- a/homebridge/camera-source.ts +++ b/homebridge/camera-source.ts @@ -7,9 +7,6 @@ export class CameraSource { services: Service[] = [] streamControllers: any[] = [] - pendingSessions = {} - ongoingSessions = {} - constructor(private ringCamera: RingCamera) { let options = { proxy: false, // Requires RTP/RTCP MUX Proxy @@ -49,6 +46,7 @@ export class CameraSource { } } + this.createCameraControlService() this.createStreamControllers(2, options) } @@ -56,151 +54,25 @@ export class CameraSource { request: { width: number; height: number }, callback: (err?: Error, snapshot?: Buffer) => void ) { - console.log('SNAPSHOT REQUESTED!!', request) try { const snapshot = await this.ringCamera.getSnapshot() - console.log('GOT SNAPSHOT', new Date(), request) - callback(undefined, snapshot) } catch (e) { - console.log('FAILED TO GET SNAPSHOT', e) callback(e) } } - handleCloseConnection(connectionID: any) { - // this.streamControllers.forEach((controller: StreamController) => { - // controller.handleCloseConnection(connectionID) - // }) - } + handleCloseConnection(connectionID: any) {} prepareStream(request: any, callback: (response: any) => void) { - // Invoked when iOS device requires stream - // callback(new Error('Not implemented')) - // var sessionInfo = {}; - // - // let sessionID = request["sessionID"]; - // let targetAddress = request["targetAddress"]; - // - // sessionInfo["address"] = targetAddress; - // - // var response = {}; - // - // let videoInfo = request["video"]; - // if (videoInfo) { - // let targetPort = videoInfo["port"]; - // let srtp_key = videoInfo["srtp_key"]; - // let srtp_salt = videoInfo["srtp_salt"]; - // - // // SSRC is a 32 bit integer that is unique per stream - // let ssrcSource = crypto.randomBytes(4); - // ssrcSource[0] = 0; - // let ssrc = ssrcSource.readInt32BE(0, true); - // - // let videoResp = { - // port: targetPort, - // ssrc: ssrc, - // srtp_key: srtp_key, - // srtp_salt: srtp_salt - // }; - // - // response["video"] = videoResp; - // - // sessionInfo["video_port"] = targetPort; - // sessionInfo["video_srtp"] = Buffer.concat([srtp_key, srtp_salt]); - // sessionInfo["video_ssrc"] = ssrc; - // } - // - // let audioInfo = request["audio"]; - // if (audioInfo) { - // let targetPort = audioInfo["port"]; - // let srtp_key = audioInfo["srtp_key"]; - // let srtp_salt = audioInfo["srtp_salt"]; - // - // // SSRC is a 32 bit integer that is unique per stream - // let ssrcSource = crypto.randomBytes(4); - // ssrcSource[0] = 0; - // let ssrc = ssrcSource.readInt32BE(0, true); - // - // let audioResp = { - // port: targetPort, - // ssrc: ssrc, - // srtp_key: srtp_key, - // srtp_salt: srtp_salt - // }; - // - // response["audio"] = audioResp; - // - // sessionInfo["audio_port"] = targetPort; - // sessionInfo["audio_srtp"] = Buffer.concat([srtp_key, srtp_salt]); - // sessionInfo["audio_ssrc"] = ssrc; - // } - // - // let currentAddress = ip.address(); - // var addressResp = { - // address: currentAddress - // }; - // - // if (ip.isV4Format(currentAddress)) { - // addressResp["type"] = "v4"; - // } else { - // addressResp["type"] = "v6"; - // } - // - // response["address"] = addressResp; - // this.pendingSessions[uuid.unparse(sessionID)] = sessionInfo; - // - // callback(response); + callback(new Error('Not implemented')) } - handleStreamRequest(request: any) { - // Invoked when iOS device asks stream to start/stop/reconfigure - // var sessionID = request["sessionID"]; - // var requestType = request["type"]; - // if (sessionID) { - // let sessionIdentifier = uuid.unparse(sessionID); - // - // if (requestType == "start") { - // var sessionInfo = this.pendingSessions[sessionIdentifier]; - // if (sessionInfo) { - // var width = 1280; - // var height = 720; - // var fps = 30; - // var bitrate = 300; - // - // let videoInfo = request["video"]; - // if (videoInfo) { - // width = videoInfo["width"]; - // height = videoInfo["height"]; - // - // let expectedFPS = videoInfo["fps"]; - // if (expectedFPS < fps) { - // fps = expectedFPS; - // } - // - // bitrate = videoInfo["max_bit_rate"]; - // } - // - // let targetAddress = sessionInfo["address"]; - // let targetVideoPort = sessionInfo["video_port"]; - // let videoKey = sessionInfo["video_srtp"]; - // let videoSsrc = sessionInfo["video_ssrc"]; - // - // let ffmpegCommand = '-re -f avfoundation -r 29.970000 -i 0:0 -threads 0 -vcodec libx264 -an -pix_fmt yuv420p -r '+ fps +' -f rawvideo -tune zerolatency -vf scale='+ width +':'+ height +' -b:v '+ bitrate +'k -bufsize '+ bitrate +'k -payload_type 99 -ssrc '+ videoSsrc +' -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params '+videoKey.toString('base64')+' srtp://'+targetAddress+':'+targetVideoPort+'?rtcpport='+targetVideoPort+'&localrtcpport='+targetVideoPort+'&pkt_size=1378'; - // let ffmpeg = spawn('ffmpeg', ffmpegCommand.split(' '), {env: process.env}); - // this.ongoingSessions[sessionIdentifier] = ffmpeg; - // } - // - // delete this.pendingSessions[sessionIdentifier]; - // } else if (requestType == "stop") { - // var ffmpegProcess = this.ongoingSessions[sessionIdentifier]; - // if (ffmpegProcess) { - // ffmpegProcess.kill('SIGKILL'); - // } - // - // delete this.ongoingSessions[sessionIdentifier]; - // } - // } + handleStreamRequest(request: any) {} + + private createCameraControlService() { + let controlService = new hap.Service.CameraControl() + this.services.push(controlService) } private createStreamControllers(maxStreams: number, options: any) { diff --git a/homebridge/camera.ts b/homebridge/camera.ts index a31b122d..75a46305 100644 --- a/homebridge/camera.ts +++ b/homebridge/camera.ts @@ -3,7 +3,7 @@ import { RingAlarmPlatformConfig } from './config' import { RingCamera } from '../api' import { BaseAccessory } from './base-accessory' import { mapTo } from 'rxjs/operators' -// import { CameraSource } from './camera-source' +import { CameraSource } from './camera-source' export class Camera extends BaseAccessory { constructor( @@ -15,16 +15,8 @@ export class Camera extends BaseAccessory { super() const { Characteristic, Service } = hap - // TODO: snapshots and live stream - // const cameraSource = new CameraSource(device) - // this.getService(Service.CameraControl) - // cameraSource.services.forEach(service => { - // const existingService = accessory.services.find( - // x => x.UUID === service.UUID && x.subtype === service.subtype - // ) - // accessory.removeService(existingService) - // }) - // accessory.configureCameraSource(cameraSource) + const cameraSource = new CameraSource(device) + accessory.configureCameraSource(cameraSource) this.registerObservableCharacteristic( Characteristic.MotionDetected, diff --git a/homebridge/hap.ts b/homebridge/hap.ts index 82b92384..3290be78 100644 --- a/homebridge/hap.ts +++ b/homebridge/hap.ts @@ -2,6 +2,7 @@ export namespace HAP { export interface Accessory { UUID: string displayName: string + category: number services: Service[] on(...args: any[]): void @@ -63,6 +64,7 @@ export namespace HAP { platformName: string, accessories: Accessory[] ): void + publishCameraAccessories(pluginName: string, accessories: Accessory[]): void } } diff --git a/homebridge/ring-alarm-platform.ts b/homebridge/ring-alarm-platform.ts index f9aa4262..6612bb67 100644 --- a/homebridge/ring-alarm-platform.ts +++ b/homebridge/ring-alarm-platform.ts @@ -14,14 +14,11 @@ import { Beam } from './beam' import { MultiLevelSwitch } from './multi-level-switch' import { Camera } from './camera' -function getAccessoryClass(device: RingDevice | RingCamera) { - if (device instanceof RingCamera) { - return Camera - } +const pluginName = 'homebridge-ring-alarm', + platformName = 'RingAlarm' - const { - data: { deviceType } - } = device +function getAccessoryClass(device: RingDevice | RingCamera) { + const { deviceType } = device switch (deviceType) { case RingDeviceType.ContactSensor: @@ -91,6 +88,8 @@ export class RingAlarmPlatform { locations = await ringApi.getLocations(), { api } = this, cachedAccessoryIds = Object.keys(this.homebridgeAccessories), + platformAccessories: HAP.Accessory[] = [], + cameraAccessories: HAP.Accessory[] = [], activeAccessoryIds: string[] = [] await Promise.all( @@ -103,7 +102,11 @@ export class RingAlarmPlatform { `Configuring ${cameras.length} cameras and ${devices.length} devices for locationId ${location.locationId}` ) allDevices.forEach(device => { - const AccessoryClass = getAccessoryClass(device) + const isCamera = device instanceof RingCamera, + AccessoryClass = isCamera ? Camera : getAccessoryClass(device), + id = device.id, + uuid = hap.UUIDGen.generate(id.toString()), + existingAccessory = this.homebridgeAccessories[uuid] if ( !AccessoryClass || @@ -113,24 +116,35 @@ export class RingAlarmPlatform { return } - const id = device.id, - uuid = hap.UUIDGen.generate(id.toString()), - createHomebridgeAccessory = () => { + if ( + isCamera && + existingAccessory && + existingAccessory.category === 11 + ) { + // this will remove bridged cameras from older versions of the plugin + this.removeAccessories([this.homebridgeAccessories[uuid]]) + delete this.homebridgeAccessories[uuid] + } + + const createHomebridgeAccessory = () => { const accessory = new hap.PlatformAccessory( device.name, uuid, - hap.AccessoryCategories.SECURITY_SYSTEM + isCamera + ? hap.AccessoryCategories.CAMERA + : hap.AccessoryCategories.SECURITY_SYSTEM ) this.log.info( `Adding new accessory ${device.deviceType} ${device.name}` ) - api.registerPlatformAccessories( - 'homebridge-ring-alarm', - 'RingAlarm', - [accessory] - ) + if (isCamera) { + cameraAccessories.push(accessory) + } else { + platformAccessories.push(accessory) + } + return accessory }, homebridgeAccessory = @@ -149,6 +163,17 @@ export class RingAlarmPlatform { }) ) + if (platformAccessories.length) { + api.registerPlatformAccessories( + pluginName, + platformName, + platformAccessories + ) + } + if (cameraAccessories.length) { + api.publishCameraAccessories(pluginName, cameraAccessories) + } + const staleAccessories = cachedAccessoryIds .filter(cachedId => !activeAccessoryIds.includes(cachedId)) .map(id => this.homebridgeAccessories[id]) @@ -160,11 +185,15 @@ export class RingAlarmPlatform { }) if (staleAccessories.length) { - this.api.unregisterPlatformAccessories( - 'homebridge-ring-alarm', - 'RingAlarm', - staleAccessories - ) + this.removeAccessories(staleAccessories) } } + + private removeAccessories(accessories: HAP.Accessory[]) { + this.api.unregisterPlatformAccessories( + pluginName, + platformName, + accessories + ) + } }