Skip to content

Commit

Permalink
feat: cameras with snapshots
Browse files Browse the repository at this point in the history
  • Loading branch information
dgreif committed Jun 21, 2019
1 parent 000b618 commit 36de80e
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 199 deletions.
22 changes: 15 additions & 7 deletions api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
84 changes: 71 additions & 13 deletions api/ring-camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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<void>

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<Buffer>({
this.refreshSnapshotInProgress =
this.refreshSnapshotInProgress || this.refreshSnapshot()

try {
await this.refreshSnapshotInProgress
} catch (e) {
logError(e)
}

this.refreshSnapshotInProgress = undefined

return this.restClient.request<Buffer>({
url: clientApi(`snapshots/image/${this.id}`),
responseType: 'arraybuffer'
})

return response
}
}
5 changes: 5 additions & 0 deletions api/ring-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,8 @@ export interface ActiveDing {
sip_token: string
sip_ding_id: string
}

export interface SnapshotTimestamp {
timestamp: number
doorbot_id: number
}
33 changes: 23 additions & 10 deletions homebridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
144 changes: 8 additions & 136 deletions homebridge/camera-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,158 +46,33 @@ export class CameraSource {
}
}

this.createCameraControlService()
this.createStreamControllers(2, options)
}

async handleSnapshotRequest(
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) {
Expand Down
Loading

0 comments on commit 36de80e

Please sign in to comment.