From 23c2b820d822d1096c0caa19470df8a3b64c0089 Mon Sep 17 00:00:00 2001 From: Alex Leeds Date: Thu, 16 Mar 2023 16:44:32 -0400 Subject: [PATCH 1/2] ring: add video clips support --- plugins/ring/README.md | 13 +- plugins/ring/package-lock.json | 14 +-- plugins/ring/package.json | 4 +- plugins/ring/src/main.ts | 223 +++++++++++++++++++++++++++------ 4 files changed, 203 insertions(+), 51 deletions(-) diff --git a/plugins/ring/README.md b/plugins/ring/README.md index 02c092092d..cfb4c663dd 100644 --- a/plugins/ring/README.md +++ b/plugins/ring/README.md @@ -9,7 +9,9 @@ Do not enable prebuffer on Ring cameras and doorbells. * The persistent live stream will drain the battery faster than it can charge. * The persistent live stream will also count against ISP bandwidth limits. -## Supported Cameras +## Supported Devices + +### Cameras - Ring Video Doorbell Wired, Pro, Pro 2, 4, 3, 2nd Gen - Ring Floodlight Cam Wired Plus - Ring Floodlight Cam Wired Pro @@ -17,6 +19,15 @@ Do not enable prebuffer on Ring cameras and doorbells. - Ring Indoor Cam - Ring Stick-Up Cam (Wired and Battery) +### Other Devices +- Security Panel +- Location Modes +- Contact Sensor / Retrofit Alarm Zones / Tilt Sensor +- Motion Sensor +- Flood / Freeze Sensor +- Water Sensor +- Smart Locks + ## Problems and Solutions I can see artifacts in HKSV recordings diff --git a/plugins/ring/package-lock.json b/plugins/ring/package-lock.json index 56ed45eda9..1e3d39af3e 100644 --- a/plugins/ring/package-lock.json +++ b/plugins/ring/package-lock.json @@ -1,17 +1,17 @@ { "name": "@scrypted/ring", - "version": "0.0.98", + "version": "0.0.100", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scrypted/ring", - "version": "0.0.98", + "version": "0.0.100", "dependencies": { "@koush/ring-client-api": "file:../../external/ring-client-api", "@scrypted/common": "file:../../common", "@scrypted/sdk": "file:../../sdk", - "@types/node": "^18.14.5", + "@types/node": "^18.15.3", "axios": "^1.3.4", "rxjs": "^7.8.0" }, @@ -49,7 +49,7 @@ }, "../../sdk": { "name": "@scrypted/sdk", - "version": "0.2.82", + "version": "0.2.85", "license": "ISC", "dependencies": { "@babel/preset-typescript": "^7.18.6", @@ -148,9 +148,9 @@ } }, "node_modules/@types/node": { - "version": "18.14.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.5.tgz", - "integrity": "sha512-CRT4tMK/DHYhw1fcCEBwME9CSaZNclxfzVMe7GsO6ULSwsttbj70wSiX6rZdIjGblu93sTJxLdhNIT85KKI7Qw==" + "version": "18.15.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.3.tgz", + "integrity": "sha512-p6ua9zBxz5otCmbpb5D3U4B5Nanw6Pk3PPyX05xnxbB/fRv71N7CPmORg7uAD5P70T0xmx1pzAx/FUfa5X+3cw==" }, "node_modules/@types/responselike": { "version": "1.0.0", diff --git a/plugins/ring/package.json b/plugins/ring/package.json index ff9c30d099..eb201c590c 100644 --- a/plugins/ring/package.json +++ b/plugins/ring/package.json @@ -36,7 +36,7 @@ "@koush/ring-client-api": "file:../../external/ring-client-api", "@scrypted/common": "file:../../common", "@scrypted/sdk": "file:../../sdk", - "@types/node": "^18.14.5", + "@types/node": "^18.15.3", "axios": "^1.3.4", "rxjs": "^7.8.0" }, @@ -44,5 +44,5 @@ "got": "11.8.6", "socket.io-client": "^2.5.0" }, - "version": "0.0.98" + "version": "0.0.100" } diff --git a/plugins/ring/src/main.ts b/plugins/ring/src/main.ts index a57f2faa20..d082d3cf76 100644 --- a/plugins/ring/src/main.ts +++ b/plugins/ring/src/main.ts @@ -3,15 +3,15 @@ import { RefreshPromise } from "@scrypted/common/src/promise-utils"; import { connectRTCSignalingClients } from '@scrypted/common/src/rtc-signaling'; import { RtspServer } from '@scrypted/common/src/rtsp-server'; import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils'; +import sdk, { Battery, BinarySensor, Camera, Device, DeviceProvider, EntrySensor, FFmpegInput, FloodSensor, Lock, LockState, MediaObject, MediaStreamUrl, MotionSensor, OnOff, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, SecuritySystem, SecuritySystemMode, Setting, Settings, SettingValue, TamperSensor, VideoCamera, VideoClip, VideoClipOptions, VideoClips } from '@scrypted/sdk'; import { StorageSettings } from '@scrypted/sdk/storage-settings'; -import sdk, { Battery, BinarySensor, Camera, Device, DeviceProvider, EntrySensor, FFmpegInput, FloodSensor, MediaObject, MediaStreamUrl, MotionSensor, OnOff, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, SecuritySystem, SecuritySystemMode, Setting, Settings, SettingValue, TamperSensor, VideoCamera } from '@scrypted/sdk'; import child_process, { ChildProcess } from 'child_process'; import dgram from 'dgram'; import { RtcpReceiverInfo, RtcpRrPacket } from '../../../external/werift/packages/rtp/src/rtcp/rr'; import { RtpPacket } from '../../../external/werift/packages/rtp/src/rtp/rtp'; import { ProtectionProfileAes128CmHmacSha1_80 } from '../../../external/werift/packages/rtp/src/srtp/const'; import { SrtcpSession } from '../../../external/werift/packages/rtp/src/srtp/srtcp'; -import { Location, LocationMode, RingDevice, isStunMessage, RtpDescription, SipSession, BasicPeerConnection, CameraData, clientApi, generateUuid, RingBaseApi, RingRestClient, rxjs, SimpleWebRtcSession, StreamingSession, RingDeviceType, RingDeviceData } from './ring-client-api'; +import { BasicPeerConnection, CameraData, clientApi, generateUuid, isStunMessage, Location, LocationMode, RingBaseApi, RingDevice, RingDeviceData, RingDeviceType, RingRestClient, RtpDescription, rxjs, SimpleWebRtcSession, SipSession, StreamingSession } from './ring-client-api'; import { encodeSrtpOptions, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from './srtp-utils'; const STREAM_TIMEOUT = 120000; @@ -79,7 +79,7 @@ class RingCameraSiren extends ScryptedDeviceBase implements OnOff { } } -class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Camera, MotionSensor, BinarySensor, RTCSignalingChannel { +class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Camera, MotionSensor, BinarySensor, RTCSignalingChannel, VideoClips { buttonTimeout: NodeJS.Timeout; session: SipSession; rtpDescription: RtpDescription; @@ -89,6 +89,7 @@ class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Cam currentMediaMimeType: string; refreshTimeout: NodeJS.Timeout; picturePromise: RefreshPromise; + videoClips = new Map(); constructor(public plugin: RingPlugin, public location: RingLocationDevice, nativeId: string) { super(nativeId); @@ -98,7 +99,6 @@ class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Cam this.batteryLevel = this.findCamera()?.batteryLevel; } - async startIntercom(media: MediaObject): Promise { if (!this.session) throw new Error("not in call"); @@ -659,9 +659,138 @@ class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Cam siren.on = data.siren_status.seconds_remaining > 0 ? true : false; } } + + async getVideoClips(options?: VideoClipOptions): Promise { + this.videoClips = new Map; + const response = await this.findCamera().videoSearch({ + dateFrom: options.startTime, + dateTo: options.endTime, + }); + + return response.video_search.map((result) => { + this.console.log(result); + const videoClip = { + id: result.ding_id, + startTime: result.created_at, + duration: Math.round(result.duration * 1000), + event: result.kind.toString(), + description: result.kind.toString(), + thumbnailId: result.ding_id, + resources: { + thumbnail: { + href: result.thumbnail_url + }, + video: { + href: result.hq_url + } + } + } + this.videoClips.set(result.ding_id, videoClip) + return videoClip; + }); + } + + async getVideoClip(videoId: string): Promise { + if (this.videoClips.has(videoId)) { + return mediaManager.createMediaObjectFromUrl(this.videoClips.get(videoId).resources.video.href); + } + throw new Error('Failed to get video clip.') + } + + async getVideoClipThumbnail(thumbnailId: string): Promise { + if (this.videoClips.has(thumbnailId)) { + return mediaManager.createMediaObjectFromUrl(this.videoClips.get(thumbnailId).resources.thumbnail.href); + } + throw new Error('Failed to get video clip thumbnail.') + } + + async removeVideoClips(...videoClipIds: string[]): Promise { + throw new Error('Removing video clips not supported.'); + } +} + +class RingLock extends ScryptedDeviceBase implements Battery, Lock { + device: RingDevice + + constructor(nativeId: string, device: RingDevice) { + super(nativeId); + this.device = device; + device.onData.subscribe(async (data: RingDeviceData) => { + this.updateState(data); + }); + } + + async lock(): Promise { + return this.device.sendCommand('lock.lock'); + } + + async unlock(): Promise { + return this.device.sendCommand('lock.unlock'); + } + + updateState(data: RingDeviceData) { + this.batteryLevel = data.batteryLevel; + switch (data.locked) { + case 'locked': + this.lockState = LockState.Locked; + break; + case 'unlocked': + this.lockState = LockState.Unlocked; + break; + case 'jammed': + this.lockState = LockState.Jammed; + break; + default: + this.lockState = undefined; + } + } +} + +class RingLock extends ScryptedDeviceBase implements Battery, Lock { + device: RingDevice + + constructor(nativeId: string, device: RingDevice) { + super(nativeId); + this.device = device; + device.onData.subscribe(async (data: RingDeviceData) => { + this.updateState(data); + }); + } + + async lock(): Promise { + return this.device.sendCommand('lock.lock'); + } + + async unlock(): Promise { + return this.device.sendCommand('lock.unlock'); + } + + updateState(data: RingDeviceData) { + this.batteryLevel = data.batteryLevel; + switch (data.locked) { + case 'locked': + this.lockState = LockState.Locked; + break; + case 'unlocked': + this.lockState = LockState.Unlocked; + break; + case 'jammed': + this.lockState = LockState.Jammed; + break; + default: + this.lockState = undefined; + } + } } class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, EntrySensor, MotionSensor, FloodSensor { + constructor(nativeId: string, device: RingDevice) { + super(nativeId); + device.onData.subscribe(async (data: RingDeviceData) => { + this.updateState(data); + }); + } + updateState(data: RingDeviceData) { this.tampered = data.tamperStatus === 'tamper'; this.batteryLevel = data.batteryLevel; @@ -673,6 +802,7 @@ class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, En export class RingLocationDevice extends ScryptedDeviceBase implements DeviceProvider, SecuritySystem { devices = new Map(); + locationDevices = new Map(); constructor(public plugin: RingPlugin, nativeId: string) { super(nativeId); @@ -762,11 +892,21 @@ export class RingLocationDevice extends ScryptedDeviceBase implements DeviceProv return this.plugin.locations.find(l => l.id === this.nativeId); } + async findRingDeviceAtLocation(id: string): Promise { + const location = this.findLocation(); + return (await location.getDevices()).find((x) => x.id === id); + } + async getDevice(nativeId: string) { if (!this.devices.has(nativeId)) { if (nativeId.endsWith('-sensor')) { - const sensor = new RingSensor(nativeId); - this.devices.set(nativeId, sensor); + const ringRevice = await this.findRingDeviceAtLocation(nativeId.replace('-sensor', '')); + const device = new RingSensor(nativeId, ringRevice); + this.devices.set(nativeId, device); + } else if (nativeId.endsWith('-lock')) { + const ringRevice = await this.findRingDeviceAtLocation(nativeId.replace('-lock', '')); + const device = new RingLock(nativeId, ringRevice); + this.devices.set(nativeId, device); } else { const camera = new RingCameraDevice(this.plugin, this, nativeId); this.devices.set(nativeId, camera); @@ -967,6 +1107,7 @@ class RingPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings interfaces.push( ScryptedInterface.VideoCamera, ScryptedInterface.Intercom, + ScryptedInterface.VideoClips, ); } if (camera.operatingOnBattery) @@ -1028,41 +1169,52 @@ class RingPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings }); } - const sensors = (await location.getDevices()).filter(x => { - const supportedSensors = [ - RingDeviceType.ContactSensor, - RingDeviceType.RetrofitZone, - RingDeviceType.TiltSensor, - RingDeviceType.MotionSensor, - RingDeviceType.FloodFreezeSensor, - RingDeviceType.WaterSensor, - ] - return x.data.status !== 'disabled' && (supportedSensors.includes(x.data.deviceType)) - }); - for (const sensor of sensors) { - const nativeId = sensor.id.toString() + '-sensor'; - const data: RingDeviceData = sensor.data; + // add location devices + const locationDevices = await location.getDevices(); + for (const locationDevice of locationDevices) { + const data: RingDeviceData = locationDevice.data; + let nativeId: string; + let type: ScryptedDeviceType; + let interfaces: ScryptedInterface[] = []; + + if (data.status === 'disabled') { + continue; + } - const interfaces = [ScryptedInterface.TamperSensor]; - switch (data.deviceType){ + switch (data.deviceType) { case RingDeviceType.ContactSensor: case RingDeviceType.RetrofitZone: case RingDeviceType.TiltSensor: - interfaces.push(ScryptedInterface.EntrySensor); + nativeId = locationDevice.id.toString() + '-sensor'; + type = ScryptedDeviceType.Sensor + interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.EntrySensor); break; case RingDeviceType.MotionSensor: - interfaces.push(ScryptedInterface.MotionSensor); + nativeId = locationDevice.id.toString() + '-sensor'; + type = ScryptedDeviceType.Sensor + interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.MotionSensor); break; case RingDeviceType.FloodFreezeSensor: case RingDeviceType.WaterSensor: - interfaces.push(ScryptedInterface.FloodSensor); + nativeId = locationDevice.id.toString() + '-sensor'; + type = ScryptedDeviceType.Sensor + interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.FloodSensor); break; - default: break; + default: + if (/^lock($|\.)/.test(data.deviceType)) { + nativeId = locationDevice.id.toString() + '-lock'; + type = ScryptedDeviceType.Lock + interfaces.push(ScryptedInterface.Lock); + break; + } else { + this.console.debug(`discovered and ignoring unsupported '${locationDevice.deviceType}' device: '${locationDevice.name}'`) + continue; + } } - + if (data.batteryStatus !== 'none') interfaces.push(ScryptedInterface.Battery); - + const device: Device = { info: { model: data.deviceType, @@ -1071,22 +1223,11 @@ class RingPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings }, providerNativeId: location.id, nativeId: nativeId, - name: sensor.name, - type: ScryptedDeviceType.Sensor, + name: locationDevice.name, + type: type, interfaces, }; devices.push(device); - - const getScryptedDevice = async () => { - const locationDevice = await this.getDevice(location.id); - const scryptedDevice = await locationDevice?.getDevice(nativeId); - return scryptedDevice as RingSensor; - } - - sensor.onData.subscribe(async (data: RingDeviceData) => { - const scryptedDevice = await getScryptedDevice(); - scryptedDevice?.updateState(data) - }); } await deviceManager.onDevicesChanged({ From 2b7db088275ac43f44efd25b265f5719f5bd4ba1 Mon Sep 17 00:00:00 2001 From: Alex Leeds Date: Thu, 16 Mar 2023 21:40:08 -0400 Subject: [PATCH 2/2] fix merge --- plugins/ring/src/main.ts | 75 ---------------------------------------- 1 file changed, 75 deletions(-) diff --git a/plugins/ring/src/main.ts b/plugins/ring/src/main.ts index 3ea02ff1c0..35c8808fd6 100644 --- a/plugins/ring/src/main.ts +++ b/plugins/ring/src/main.ts @@ -668,7 +668,6 @@ class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Cam }); return response.video_search.map((result) => { - this.console.log(result); const videoClip = { id: result.ding_id, startTime: result.created_at, @@ -746,80 +745,6 @@ class RingLock extends ScryptedDeviceBase implements Battery, Lock { } } -class RingLock extends ScryptedDeviceBase implements Battery, Lock { - device: RingDevice - - constructor(nativeId: string, device: RingDevice) { - super(nativeId); - this.device = device; - device.onData.subscribe(async (data: RingDeviceData) => { - this.updateState(data); - }); - } - - async lock(): Promise { - return this.device.sendCommand('lock.lock'); - } - - async unlock(): Promise { - return this.device.sendCommand('lock.unlock'); - } - - updateState(data: RingDeviceData) { - this.batteryLevel = data.batteryLevel; - switch (data.locked) { - case 'locked': - this.lockState = LockState.Locked; - break; - case 'unlocked': - this.lockState = LockState.Unlocked; - break; - case 'jammed': - this.lockState = LockState.Jammed; - break; - default: - this.lockState = undefined; - } - } -} - -class RingLock extends ScryptedDeviceBase implements Battery, Lock { - device: RingDevice - - constructor(nativeId: string, device: RingDevice) { - super(nativeId); - this.device = device; - device.onData.subscribe(async (data: RingDeviceData) => { - this.updateState(data); - }); - } - - async lock(): Promise { - return this.device.sendCommand('lock.lock'); - } - - async unlock(): Promise { - return this.device.sendCommand('lock.unlock'); - } - - updateState(data: RingDeviceData) { - this.batteryLevel = data.batteryLevel; - switch (data.locked) { - case 'locked': - this.lockState = LockState.Locked; - break; - case 'unlocked': - this.lockState = LockState.Unlocked; - break; - case 'jammed': - this.lockState = LockState.Jammed; - break; - default: - this.lockState = undefined; - } - } -} - class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, EntrySensor, MotionSensor, FloodSensor { constructor(nativeId: string, device: RingDevice) { super(nativeId);